2024年5月13日 星期一

如何 Excel 垂直合併儲存格中的文字到第一個

如何 Excel 垂直合併儲存格中的文字到第一個

應該經常遇到說上下文被儲存格分開了,需要合併又只能手動剪下文字,不然直接按合併第二格之後的文字會通通不見。

寫了個腳本來處理這件事件,腳本可以指定到快捷鍵加速操作,平常也不需要貼到目標 xlsx 檔案上只需要儲存在一個屬於自己的 xlsm 上面,當你需要這功能的時候打開這個檔案在背景就可以了。

快捷鍵可能每次都要在目標 xlsx 上重設,不過這幾乎不成問題就是了。



腳本

Sub CombineTextInColumns()
    Dim rng As Range
    Dim cell As Range
    Dim startCell As Range
    Dim combinedText As String
    Dim col As Long
    Dim shouldMerge As Boolean

    ' 控制是否合併儲存格的變數
    shouldMerge = False  ' 如果設為 False 則不合併儲存格,如果設為 True 則合併儲存格

    ' 確認用戶已選擇儲存格
    If Not TypeName(Selection) = "Range" Then
        MsgBox "請選擇儲存格"
        Exit Sub
    End If

    Set rng = Selection
    ' 按列處理選定範圍
    For col = 1 To rng.Columns.Count
        Set startCell = rng.Cells(1, col)
        combinedText = startCell.Value
        ' 合併每一列中的文字到第一個儲存格
        For Each cell In rng.Columns(col).Cells
            If cell.Address <> startCell.Address Then
                If combinedText <> "" And cell.Value <> "" Then
                    combinedText = combinedText & Chr(10) & cell.Value
                ElseIf cell.Value <> "" Then
                    combinedText = cell.Value
                End If
            End If
        Next cell

        ' 更新第一個儲存格的內容
        startCell.Value = combinedText
        startCell.WrapText = True

        ' 清除該列中第一個儲存格以外的其他儲存格內容
        For Each cell In rng.Columns(col).Cells
            If cell.Address <> startCell.Address Then
                cell.ClearContents
            End If
        Next cell

        ' 根據條件合併儲存格
        If shouldMerge Then
            rng.Columns(col).Merge
            rng.Columns(col).VerticalAlignment = xlTop
        End If
    Next col

    ' 自動調整所有選定範圍的行高
    rng.Rows.AutoFit
End Sub






補一個日文註解版本的

Sub CombineTextInColumns()
    Dim rng As Range
    Dim cell As Range
    Dim startCell As Range
    Dim combinedText As String
    Dim col As Long
    Dim shouldMerge As Boolean

    ' セルをマージするかどうかを制御する変数
    shouldMerge = False  ' False の場合はセルをマージしない、 True の場合はセルをマージ

    ' ユーザーがセルを選択していることを確認
    If Not TypeName(Selection) = "Range" Then
        MsgBox "セルを選択してください"
        Exit Sub
    End If

    Set rng = Selection
    ' 選択範囲の各列を処理
    For col = 1 To rng.Columns.Count
        Set startCell = rng.Cells(1, col)
        combinedText = startCell.Value
        ' 各列のセルのテキストを最初のセルに結合
        For Each cell In rng.Columns(col).Cells
            If cell.Address <> startCell.Address Then
                If combinedText <> "" And cell.Value <> "" Then
                    combinedText = combinedText & Chr(10) & cell.Value
                ElseIf cell.Value <> "" Then
                    combinedText = cell.Value
                End If
            End If
        Next cell

        ' 最初のセルの内容を更新
        startCell.Value = combinedText
        startCell.WrapText = True

        ' 最初のセル以外のセルの内容をクリア
        For Each cell In rng.Columns(col).Cells
            If cell.Address <> startCell.Address Then
                cell.ClearContents
            End If
        Next cell

        ' 条件に基づいてセルをマージ
        If shouldMerge Then
            rng.Columns(col).Merge
            rng.Columns(col).VerticalAlignment = xlTop
        End If
    Next col

    ' 選択範囲のすべての行の高さを自動調整
    rng.Rows.AutoFit
End Sub



2024年5月5日 星期日

PowerShell獲取檔案權限表格

PowerShell獲取檔案權限表格

原本的物件不是很好使,寫了個函式方便快速查詢

輸出範例

Identity                         FullControl Modify ReadAndExecute Read  Write
--------                         ----------- ------ -------------- ----  -----
NT AUTHORITY\SYSTEM              Allow
NT AUTHORITY\Authenticated Users             Allow
BUILTIN\Administrators           Allow
BUILTIN\Users                                       Allow          Allow Deny




函式代碼

Get-FilePermissions

function Get-FilePermissions {
     [CmdletBinding(DefaultParameterSetName = "")]
     param(
         [Parameter(Position = 0, ParameterSetName = "", Mandatory)]
         [string]$Path,
         [Parameter(Position = 1, ParameterSetName = "")]
         [string]$Identity
     )

     # 確認指定的路徑是否存在
     if (!(Test-Path $Path)) {
         Write-Error "Cannot find path '$Path' because it does not exist." -ErrorAction Stop
     }

     # 取得指定路徑的存取控制清單 (ACL)
     $acl = Get-Acl $Path

     # 定義需要檢查的權限列表
     $permissions = @('FullControl', 'Modify', 'ReadAndExecute', 'Read', 'Write')

     # 初始化一個字典來儲存每個使用者的權限狀態
     $permissionDict = @{}

     # 遍歷存取控制規則
     foreach ($accessRule in $acl.Access) {
         # 取得使用者名稱
         $user = $accessRule.IdentityReference.Value

         # 如果字典尚未包含目前用戶,則新增一個條目
         if (-not $permissionDict.ContainsKey($user)) {
             # 動態建立權限字段
             $userPermissions = [ordered]@{ Identity = $user }
             foreach ($permission in $permissions) {
                 $userPermissions[$permission] = ''
             }
             $permissionDict[$user] = $userPermissions
         }

         # 更新權限狀態,拒絕權限優先
         foreach ($permission in $permissions) {
             if ($accessRule.FileSystemRights -like "*$permission*") {
                 $state = $accessRule.AccessControlType.ToString()
                 if ($permissionDict[$user][$permission] -ne 'Deny') {
                     $permissionDict[$user][$permission] = $state
                 }
             }
         }
     }

     # 將字典中的值轉換為物件數組
     if ($Identity) {
         $permission = [PSCustomObject]($permissionDict[$Identity])
     } else {
         $permission = $permissionDict.Values | ForEach-Object { [PSCustomObject]$_ }
     }

     # 傳回產生的權限表格
     return $permission
}

# Get-FilePermissions "Z:\test.txt"|Format-Table|Out-String
# Get-FilePermissions "Z:\test.txt" -Identity 'BUILTIN\Administrators'


使用範例

# 輸出表格
Get-FilePermissions "Z:\test.txt"|Format-Table|Out-String

# 指定特定使用者或群組名稱
Get-FilePermissions "Z:\test.txt" -Identity 'BUILTIN\Administrators'



2024年5月1日 星期三

Linux Ubuntu 自動更新腳本

Linux Ubuntu 自動更新腳本

更新要打的指令其實就那麼幾個只是經常打覺得煩了,習慣上會弄成一個 update.sh 這樣來做比較快

#!/bin/bash

# 更新軟件包列表
sudo apt update

# 升級已安裝的軟件包
sudo apt upgrade -y

# 清理不再需要的軟件包
sudo apt autoremove -y

# 清理下載的安裝包緩存
sudo apt clean

複製之後使用下面的指令打開編輯器

nano ~/update.sh


然後貼上按下 CTRL+S 儲存,再按 CTRL+X 離開
接著在更改權限讓他變成可執行檔案

chmod +x ~/update.sh


到這邊就大功告成了可以使用下面快捷更新

./update.sh



執行之後還是有可能沒更完全

  1. 保留的軟件包:某些軟件包可能因為依賴關係的問題被保留下來,不進行自動升級。
  2. 需要手動干預:有時候,軟件包需要你手動確認升級,尤其是那些可能影響系統設定的重要軟件包。


查看哪些沒有被更新

sudo apt list --upgradable


1. 強制更新

sudo apt full-upgrade

2. 移除

sudo apt remove YOUR_APP_NAME

3. 移除(連同所有設定檔)

sudo apt purge YOUR_APP_NAME




PowerShell 如何接收外部程式返回的二進制值

PowerShell 如何接收外部程式返回的二進制值

這個超坑人阿 PowerShell 預設會自動把收到的數值編碼,導致二進制數值讀進來的瞬間就因為轉換的關係資料遺失了

舉個例子來說如果要用 OpenSSL 簽名,簽名有點複雜在簡化一點產生一個隨機數值並返回二進位數值

openssl rand -out - 8

拿到的值就是爛掉的因為轉換編碼的過程就發生不可逆的資料丟失了
查了一下還好是有解的

shell - How to get original binary data from external command output in PowerShell? - Stack Overflow

只能說真愛生命不要用 PowerShell 管道傳輸非字串的東西…




完成的函式

代碼我把他弄好成一個函式了

function Invoke-CommandAndGetBinaryOutput {
    [CmdletBinding()]
    Param(
        [Parameter(Position = 0, Mandatory, ValueFromPipeline)]
        [string]$CommandLine
    )
    Process {
        # 用空白分割命令行,並分配文件名與參數
        $parts = $CommandLine -split ' ', 2
        $fileName = $parts[0]
        $arguments = $parts[1]

        # 建立 ProcessStartInfo 對象並配置
        $processInfo = New-Object System.Diagnostics.ProcessStartInfo -Property @{
            FileName = $fileName
            Arguments = $arguments
            RedirectStandardOutput = $true
            RedirectStandardError = $true
            UseShellExecute = $false
            CreateNoWindow = $true
        }

        # 開始進程
        $process = New-Object System.Diagnostics.Process
        $process.StartInfo = $processInfo
        $process.Start() | Out-Null
        $process.WaitForExit()

        # 使用 MemoryStream 讀取二進制數據
        $memoryStream = New-Object System.IO.MemoryStream
        try {
            $process.StandardOutput.BaseStream.CopyTo($memoryStream)
            $binaryOutput = $memoryStream.ToArray()

            # 返回二進制數據
            return $binaryOutput
        } finally {
            $memoryStream.Dispose()
        }
    }
} # Write-Host (Invoke-CommandAndGetBinaryOutput "openssl rand -out - 8")

使用範例就是

Write-Host (Invoke-CommandAndGetBinaryOutput "openssl rand -out - 8")

拿到的是二進位數值,這個不要隨便亂成任意編碼(UTF-8)會丟失信息的。

想要安全的攜帶大概就是走base64的路子了,轉法下面有。




驗證

就能拿到正確的數值了,不過 OpenSSL 只有簽名的時候結果才是固定的這邊有點難驗證就是了,大概寫一下可驗證的辦法

走檔案路線的不會受到影響結果一定是對的

# 調用 OpenSSL 進行簽名 (走檔案路線)
$toSignData | Set-Content $toSignDataPath -NoNewline
openssl dgst -sha512 -sign $privatekeyPath -binary -out $signaturePath $toSignDataPath
$base64Signature = [System.Web.HttpServerUtility]::UrlTokenEncode([System.IO.File]::ReadAllBytes($signaturePath)) -replace('[012]$')

然後是副程式的用法

$toSignData | Set-Content $toSignDataPath -NoNewline
$bytes = Invoke-CommandAndGetBinaryOutput "OpenSSL dgst -sha512 -binary -sign `"$privatekeyPath`" `"$toSignDataPath`""
$base64Signature = [System.Web.HttpServerUtility]::UrlTokenEncode($bytes) -replace('[012]$')

寫個大概而已自己在琢磨一下 OpenSSL 怎麼使用




2024年4月27日 星期六

利用 Curl.exe 提交 Box 的 JWT 請求獲取 Access Token

利用 Curl.exe 提交 Box 的 JWT 請求獲取 Access Token

這邊比較難搞的是JWT,比較無腦一點就是直接載人家函式來弄,編碼之後會變成一長串文字,由Post送出去呼叫伺服器的API。


獲取 Box JSON File

請從BOX建立應用程式下載JWT認證Json私鑰檔案

$config 是直接從BOX上抓下來的Json檔案,他格式大概會長這樣子

{
  "boxAppSettings": {
    "clientID": "clientID",
    "clientSecret": "clientSecret",
    "appAuth": {
      "publicKeyID": "publicKeyID",
      "privateKey": "-----BEGIN ENCRYPTED PRIVATE KEY-----\nSTRING\n-----END ENCRYPTED PRIVATE KEY-----",
      "passphrase": "secret_passphrase"
    }
  },
  "enterpriseID": "enterpriseID"
}


打包成JWT令牌

一個完整的JWT是由三個部分的base64url組成,分別是 "$heder.$payload.$Signature"

這部分請參考這份代碼獲取 $assertion

hunandy14/PsJwt (github.com)



向伺服器發送JWT請求

$assertion 是完整的 JWT 令牌,包含簽名後的字串

要快速驗證內容有工具網站可以看 JSON Web Tokens - jwt.io



利用 Invoke-RestMethod 請求 AccessToken

    # Generate BoxToken Request
    $irmParams = @{
        Uri = 'https://api.box.com/oauth2/token'
        Method = 'Post'
        Body = @{
            grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
            assertion = "$tokenData.$signature"
            client_id = $config.boxAppSettings.clientID
            client_secret = $config.boxAppSettings.clientSecret
        }
        ContentType = 'application/x-www-form-urlencoded'
    }; if ($env:HTTP_PROXY) { $irmParams['Proxy'] = $env:HTTP_PROXY }

    # Request AccessToken
    try {
        $response = Invoke-RestMethod @irmParams
    } catch {
        Write-Error $PSItem.Exception.Message -ea 1
    }

    # Check AccessToken
    $response



利用外部程序 curl.exe 請求 AccessToken 

下面是用 PowerShell 寫的 Curl 請求代碼。這邊要注意一個坑是如果沒有加上 .exe 的話預設會呼叫內建的指令,內建會直接回傳 PowerShell 物件而不是文字,然後參數用法有不同不能直接換過去。

function RequestBoxToken {
    param (
        [string]$Assertion,
        [string]$ConfigPath
    )
    # Read configuration from JSON file
    $configContent = Get-Content $ConfigPath -Raw
    $config = ConvertFrom-Json $configContent

    # Generate Request queryString
    $url = 'https://api.box.com/oauth2/token'
    $proxyUrl = $env:http_proxy
    $response = curl.exe `
        -x $proxyUrl `
        -X POST $url `
        -d 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' `
        -d "assertion=$Assertion" `
        -d "client_id=$($config.boxAppSettings.clientID)" `
        -d "client_secret=$($config.boxAppSettings.clientSecret)"
    # RequestBoxAccessToken
    $response = ConvertFrom-Json $response
    return $response
}



Python的請求方法 

import requests
import os
import json

def request_box_token(assertion, config_path):
    # 讀取設定檔
    with open(config_path, 'r') as file:
        config = json.load(file)

    url = 'https://api.box.com/oauth2/token'

    # 設置代理
    http_proxy = os.getenv('http_proxy')
    https_proxy = os.getenv('https_proxy')
    proxies = {}
    if http_proxy:
        proxies['http'] = http_proxy
    if https_proxy:
        proxies['https'] = https_proxy

    payload = {
        'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        'assertion': assertion,
        'client_id': config['boxAppSettings']['clientID'],
        'client_secret': config['boxAppSettings']['clientSecret']
    }
    response = requests.post(url, data=payload, proxies=proxies if proxies else None)
    return response.json()

# 使用方法
assertion = '你的assertion'
config_path = 'config.json'  # 設定檔路徑
response = request_box_token(assertion, config_path)
print(response)




官方SDK請求方法

import json
from boxsdk import Client, JWTAuth

# 載入官方 JSON 檔案
with open('config.json') as json_file:
    config = json.load(json_file)

# 創建 JWTAuth 認證對象
auth = JWTAuth(
    client_id=config['boxAppSettings']['clientID'],
    client_secret=config['boxAppSettings']['clientSecret'],
    enterprise_id=config['enterpriseID'],
    jwt_key_id=config['boxAppSettings']['appAuth']['publicKeyID'],
    rsa_private_key_data=config['boxAppSettings']['appAuth']['privateKey'].replace('\\n', '\n'),
    rsa_private_key_passphrase=config['boxAppSettings']['appAuth']['passphrase']
)

# 獲取 Access Token
access_token = auth.authenticate_instance()
print(f"access_token: {access_token}")

# 使用 Access Token 創建 Box Client
client = Client(auth)

# 驗證登錄並獲取當前用戶信息
current_user = client.user().get()
print('Authenticated as:', current_user.name)


這個只要拿到 json 檔案並且審核通過後就可以直接拿來用了。至於如何通過 proxy 可以參考這一篇的說明 
CHG: 利用 Curl.exe 提交 Box 的 JWT 請求獲取 Access Token (charlottehong.blogspot.com)



PowerShell 如何生成時間戳記 相對於 1970-01-01 的秒數

PowerShell 如何生成時間戳記 相對於 1970-01-01 的秒數

在做JWT呼叫API相關的事情,被這時間戳記搞了一會

先說Python上的時間戳記,這東西不用太管直接出來就是正確的

import time
round(time.time())

但是如果要手動實現這個功能就會牽涉到相對時間跟時區的問題了

這裡關鍵就是 Get-Date '1970-01-01 00:00:00' 已經是絕對時間了就不能在加入時差運算了,原本兩邊都加了算出來奇怪怎麼對不上

# 獲取 PowerShell 的時間戳
$TimeStamp1 = [int][Math]::Round(((Get-Date).ToUniversalTime() - (Get-Date '1970-01-01 00:00:00')).TotalSeconds)
Write-Host "PowerShell: $TimeStamp1"

# 獲取 Python 的時間戳
$TimeStamp2 = & python -c "import time; print(round(time.time()))"
Write-Host "Python    : $TimeStamp2"

# 時差
($TimeStamp1-$TimeStamp2)/60/60

-

2024年4月19日 星期五

Pnp.PowerShell 如何安裝 與 基本用法

Pnp.PowerShell 如何安裝 與 基本用法

經常要測試,每次都從頭來有點煩了把常見的語法整合起來



安裝

安裝有分好幾個版本,比較要注意的是從2版開始必須要 pwsh7 才能動,環境不允許的話只能裝舊版的。

各版本可以從這裡點出來
powershell/README.md at dev · pnp/powershell (github.com)


夜間最新版

Install-Module -Name PnP.PowerShell -AllowPrerelease

最新穩定版 (PowerShell Gallery | PnP.PowerShell 2.4.0)

Install-Module -Name PnP.PowerShell -RequiredVersion 2.4.0

舊版

Install-Module PnP.PowerShell -RequiredVersion 1.12.0 -Force


安裝好之後可以通過這個代碼檢查

Get-Module -ListAvailable PnP.PowerShell


基於版本問題,建議在代碼中把這段放進去開頭,可以少走不少彎路

function GetDateString { $((Get-Date).Tostring("yyyy/MM/dd HH:mm:ss.fff")) }
$Module = (Get-Module -ListAvailable PnP.PowerShell)[0]
if (!$Module) { Write-Error "The module 'PnP.PowerShell' is not installed." -EA 1 }
$ver = "PowerShellVersion $($PSVersionTable.PSVersion), PnP.PowerShellVersion $($Module.Version)"
Write-Host "[$(GetDateString)] $ver" -ForegroundColor DarkGray

其中 Get-Module 有使用 [0] 取第一個,是因為有可能同時存在多個版本



登入

登入有好幾種方法,先介紹文字介面的

$SiteUrl = "https://chg.sharepoint.com/sites/Maple"
$User    = "UserName@chg.onmicrosoft.com"
$PWord   = "PassWord"
$PWord = $PWord | ConvertTo-SecureString -AsPlainText -Force
$Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $User, $PWord
Connect-PnPOnline -Url $SiteUrl -Credentials $Credential

對於如何合理的儲存登入情報,可以參考這個專案https://github.com/hunandy14/ConvertFrom-Env 



這樣就登入了,再來可以用這個測試現在登入哪個網站

# 目前登入的用戶
(Get-PnPConnection).PSCredential

# 目前登入的網站
Get-PnPWeb


第二種方式是通過UI介面來登入

.Net登入窗口

$SiteUrl = "https://chg.sharepoint.com/sites/Maple"
Connect-PnPOnline -Url $SiteUrl -Interactive

外部瀏覽器登入窗口

$SiteUrl = "https://chg.sharepoint.com/sites/Maple"
Connect-PnPOnline -Url $SiteUrl -UseWebLogin




上傳檔案

這邊要注意一點是斜線的方向,一開始不知道弄了好一會兒。


先用預設的資料夾測試,這個是創建SharePoint時預設會建立的文件庫。

# 獲取預設資料夾
Get-PnPFolder -Url "/Shared Documents"

# 獲取預設資料夾中的子資料夾
Get-PnPFolder -Url "/Shared Documents/File"


創建資料夾

try { # 檢查資料夾是否存在
    Get-PnPFolder -Url "/Shared Documents/File" -ErrorAction Stop
} catch { # 如果資料夾不存在,則建立它
    Add-PnPFolder -Name "File" -Folder "Shared Documents"
}


上傳文件

Add-PnPFile -Path ".\README.md" -Folder "Shared Documents/File"


查看文件

Get-PnPFile -Url "Shared Documents/File/README.md"