Files
gitea/tools/migrate-wiki.sh
jiantw83 9942cf2506 fix(migrate-wiki): 候選鍵兼收條列與表格兩種目錄頁形狀
搬頁時蒐集候選鍵的那一段,原本只讀目錄頁表格的存取庫、主機、帳號、工具、
期間五欄。現在改成 H2 區塊底下的「- {欄位名}:{值}」也照樣讀,表格的欄位
維持原樣收下,兩種形狀都認得。

目錄頁已經改成條列,還沒轉檔的線上舊頁卻仍是表格,同一輪搬頁會同時遇到
兩種。只讀表格的話,條列頁一個候選鍵都收不到,那些頁的舊頁名就對不到新
頁名,搬頁會把它們當成沒有對應而漏掉。

讀取改成逐行判斷:碰到「## 」就把上一個區塊收掉並開新的一筆,區塊內的
條列按欄位名對照收值。欄位值各自算一個候選鍵,再依固定欄位順序串一個組合
鍵,跟表格那一路的產出規則完全一致。條列的冒號正本寫全形,半形也一併收,
舊頁手寫的那幾條才不會整條漏掉。

功能範圍:目錄頁版面改版的搬頁相容。
2026-09-02 17:22:24 +08:00

487 lines
18 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# migrate-wiki.sh — 把既有 wiki 頁搬到新規則。
#
# 一次改兩件事:
# 目錄頁 {TYPE}_CONTENTS 換存取庫,頁名不變。
# 內容頁 {TYPE}_{舊 HASH} 換頁名,存取庫不變。
#
# 為什麼只做正推:SHA-1 不可逆,新頁名算不回舊頁名。所以拿候選鍵跑舊演算法,
# 對得上現有頁名才算配對成功。配不上就列成孤兒,交給人處理,絕不猜。
#
# 用法:
# migrate-wiki.sh [--apply] [--key <候選鍵>]...
# 不帶 --apply 時只印對照表與孤兒清單,不寫任何東西。
# --key 可重複,補充自動蒐集不到的候選鍵。
#
# 候選鍵來源:
# 目錄頁每一筆的存取庫、主機、帳號、工具、期間,以及這幾項依序串成的組合鍵。
# 目錄頁是 H2 區塊加條列的形狀,所以讀「- {欄位名}:{值}」那幾條;還沒轉檔的舊頁
# 仍是 markdown 表格,所以表格的欄位也照樣讀,兩種形狀都收。
# 內容頁的 H1 標題(CHECK 頁的 H1 直接就是 {主機}/{帳號})。
# --key 補充的候選。
#
# --apply 的順序:先把每一頁寫到新位置,再改連結,最後才刪舊頁。
# 連結改寫不能省:[[...]] 只在同一個 wiki 內解析,頁名或存取庫一變,
# 連結就指向一個不存在的頁,而畫面上看不出異常。
# 先全部寫到新位置,wiki-url 才查得到每一個目的地的絕對網址。
# 刪除排在確認新頁讀得回來之後:先刪再寫,中間出錯就兩邊都沒有。
# 每一頁寫到新位置之前一定先讀目的地,只有結束碼 4 才准寫:目的地那一頁可能是
# 前一次搬到一半留下的,也可能是 wiki-contents.sh 剛建好的活頁,直接寫就是覆蓋。
#
# 連結改寫的範圍限制:只改寫被搬的那些頁自己的內容。沒被搬的頁——已經合規的頁、
# 目的地存取庫裡原有的頁——裡面指向被搬頁的 [[...]] 這支不動。要改寫全部,
# 得把每個存取庫的每一頁都讀回來重寫,代價是整批頁的讀寫都變成寫入風險。
# 所以改採列清單:報告的「引用被搬頁、但自己沒被搬」一節列出每一個引用方與它指到的頁名,
# 交給人改。清單不空時結束碼是 3。
#
# 逐列處理的迴圈一律從 fd 3 讀清單,不佔用 stdin。wiki-put 與 wiki-delete 的
# 人工確認要從終端讀一行,stdin 被清單檔佔走的話,每一次寫入都會被判成
# 「沒有互動終端」而拒絕。
# 結束碼: 0=全部搬完 1=有頁搬移失敗 2=用法錯誤,含 CONTENTS 存取庫未設定
# 3=有需人工處理的項目:孤兒頁、沒被搬的引用方、目的地已有內容的頁
set -eu
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
gitea="$dir/gitea.sh"
hash_id="$dir/hash-id"
TAB=$(printf '\t')
TYPES='QUESTION PLAN ANALYZE DELIVER MAINTAIN REPO LOG LEARN ERROR CHECK REPORT SKILLSET TOOLING MONITOR'
usage() {
echo 'usage: migrate-wiki.sh [--apply] [--key <候選鍵>]...' >&2
exit 2
}
apply=0
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
: > "$work/keys"
while [ "$#" -gt 0 ]; do
case "$1" in
--apply) apply=1; shift ;;
--key)
[ "$#" -ge 2 ] && [ -n "$2" ] || usage
printf '%s\n' "$2" >> "$work/keys"; shift 2 ;;
*) usage ;;
esac
done
old_hash() { # 舊演算法:一個候選鍵可能對到兩種舊頁名,兩種都印出來,一行一個
# 這裡刻意印兩種,因為頁名規則改過兩次,兩代的頁至今並存:
# 第一代 只取 SHA-1 前 8 碼大寫。
# 第二代 在第一代之上,首碼落在 0-9ABC 就改成 H 加前 7 碼。
# 只算第二代的話,第一代那些首碼落在 0-9ABC 的頁一律配不到,會被誤判成孤兒
# 留在原地。實例:同一個鍵在第一代是 1C516D85、第二代是 H1C516D8,兩張頁都在。
# 首碼落在 D、E、F 的鍵兩代同值,這時只印一行。
if command -v sha1sum >/dev/null 2>&1; then
raw=$(printf '%s' "$1" | sha1sum | awk '{print $1}')
else
raw=$(printf '%s' "$1" | shasum -a 1 | awk '{print $1}')
fi
h=$(printf '%s' "$raw" | cut -c1-8 | tr a-f A-F)
printf '%s\n' "$h"
case "$h" in [0-9ABC]*) printf 'H%s\n' "$(printf '%s' "$h" | cut -c1-7)" ;; esac
}
rc=0
contents_repo=$(sh "$gitea" wiki-repo CONTENTS) || rc=$?
if [ "$rc" -ne 0 ]; then
echo '[jsc][gitea][ERR]:目錄頁的專用存取庫沒有設定。請先設 JSC_WIKI_REPO_CONTENTS 或 JSC_WIKI_REPO。' >&2
exit 2
fi
: > "$work/map" # 舊存取庫 \t 舊頁名 \t 新存取庫 \t 新頁名
: > "$work/orphans" # 存取庫 \t 頁名 \t 原因
: > "$work/pending" # 存取庫 \t 頁名 \t 型別 \t 舊 HASH
: > "$work/allpages" # 存取庫 \t 頁名(列得出來的每一頁,含不搬的)
: > "$work/refs" # 存取庫 \t 頁名 \t 被指到的舊頁名
: > "$work/manual" # 需人工確認的項目,一行一句
: > "$work/notes"
# ---- 第一輪:列出各型別的頁,分成目錄頁、待配對的內容頁、已經合規的頁 ----
for ty in $TYPES; do
rc=0
repo=$(sh "$gitea" wiki-repo "$ty" 2>/dev/null) || rc=$?
if [ "$rc" -ne 0 ]; then
printf '%s:沒有設定存取庫,略過。\n' "$ty" >> "$work/notes"
continue
fi
rc=0
pages=$(sh "$gitea" wiki-list "$repo" 2>/dev/null) || rc=$?
if [ "$rc" -ne 0 ]; then
printf '%s(%s):列不出 wiki 頁(結束碼 %s)。\n' "$ty" "$repo" "$rc" >> "$work/notes"
continue
fi
printf '%s\n' "$pages" > "$work/pagelist"
while IFS= read -r pg <&3; do
[ -n "$pg" ] || continue
# 先收進全頁清單,再過型別前綴。引用方掃描要看的是沒被搬的那些頁。
printf '%s\t%s\n' "$repo" "$pg" >> "$work/allpages"
case "$pg" in "${ty}_"*) ;; *) continue ;; esac
suffix=${pg#"${ty}_"}
if [ "$suffix" = CONTENTS ]; then
if [ "$repo" = "$contents_repo" ]; then
printf '%s:已經在目錄頁存取庫。\n' "$pg" >> "$work/notes"
else
printf '%s\t%s\t%s\t%s\n' "$repo" "$pg" "$contents_repo" "$pg" >> "$work/map"
fi
elif printf '%s' "$suffix" | grep -Eq '^[0-9A-F]{40}$'; then
printf '%s:已經是 40 碼頁名。\n' "$pg" >> "$work/notes"
elif printf '%s' "$suffix" | grep -Eq '^([0-9A-F]{8}|H[0-9A-F]{7})$'; then
printf '%s\t%s\t%s\t%s\n' "$repo" "$pg" "$ty" "$suffix" >> "$work/pending"
else
printf '%s\t%s\t%s\n' "$repo" "$pg" '頁名不符任何已知樣式' >> "$work/orphans"
fi
done 3< "$work/pagelist"
done
# ---- 第二輪:蒐集候選鍵 ----
# 目錄頁每一筆的欄位值,以及依序串成的組合鍵
while IFS="$TAB" read -r orepo opage nrepo npage <&3; do
[ -n "$opage" ] || continue
case "$opage" in *_CONTENTS) ;; *) continue ;; esac
sh "$gitea" wiki-get "$orepo" "$opage" 2>/dev/null > "$work/one" || continue
python3 - "$work/one" <<'PY' >> "$work/keys" || true
import re
import sys
WANT = ['存取庫', '主機', '工具', '帳號', '期間']
lines = open(sys.argv[1], encoding='utf-8').read().split('\n')
def cells(line):
s = line.strip()
if not s.startswith('|'):
return None
s = s[1:]
if s.endswith('|'):
s = s[:-1]
return [c.strip() for c in s.split('|')]
def is_sep(cs):
return bool(cs) and all(c and set(c) <= set('-: ') for c in cs)
def plain(v):
v = re.sub(r'\[\[([^\]|]*)\|([^\]]*)\]\]', r'\1', v)
v = re.sub(r'\[\[([^\]]*)\]\]', r'\1', v)
v = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', v)
return v.replace('`', '').strip()
idx = {}
seen_sep = False
out = []
block = None
def take(found):
"""一筆紀錄收到的欄位值,各自算一個候選鍵,依 WANT 順序再串一個組合鍵。"""
if not found:
return
parts = []
for w in WANT:
v = found.get(w)
if not v:
continue
out.append(v)
parts.append(v)
if len(parts) > 1:
out.append('/'.join(parts))
for line in lines:
# 目錄頁一筆一個 H2 區塊,欄位在標題底下一條一條列。
if line.startswith('## '):
take(block)
block = {}
idx, seen_sep = {}, False
continue
cs = cells(line)
if cs is None:
if block is not None:
s = line.strip()
if s.startswith('- '):
body = s[2:]
# 正本寫全形冒號;半形也收,舊頁手寫的那幾條才不會整條漏掉。
for mark in (':', ':'):
if mark in body:
label, value = body.split(mark, 1)
label = plain(label)
for w in WANT:
if w in label:
v = plain(value)
if v:
block[w] = v
break
break
idx, seen_sep = {}, False
continue
if is_sep(cs):
seen_sep = True
continue
if not seen_sep:
# 分隔列之前的那一列是表頭,欄位位置從它認出來。
idx = {}
for w in WANT:
for i, h in enumerate(cs):
if w in h:
idx[w] = i
break
continue
if not idx:
continue
found = {}
for w in WANT:
i = idx.get(w)
if i is None or i >= len(cs):
continue
v = plain(cs[i])
if not v:
continue
found[w] = v
take(found)
take(block)
for v in out:
print(v)
PY
done 3< "$work/map"
# 內容頁的 H1 標題
while IFS="$TAB" read -r repo pg ty suffix <&3; do
[ -n "$pg" ] || continue
sh "$gitea" wiki-get "$repo" "$pg" 2>/dev/null > "$work/one" || continue
sed -n 's/^# \{1,\}//p' "$work/one" | head -n 3 >> "$work/keys"
done 3< "$work/pending"
sort -u "$work/keys" > "$work/keys.uniq"
# 候選鍵的舊 HASH 對照表
: > "$work/hashes"
while IFS= read -r k <&3; do
[ -n "$k" ] || continue
# old_hash 一個鍵可能印兩行(兩代頁名規則),每一行都要能配得到同一個鍵
old_hash "$k" | while IFS= read -r h; do
printf '%s\t%s\n' "$h" "$k" >> "$work/hashes"
done
done 3< "$work/keys.uniq"
# ---- 第三輪:正推配對 ----
while IFS="$TAB" read -r repo pg ty suffix <&3; do
[ -n "$pg" ] || continue
key=$(awk -F'\t' -v h="$suffix" '$1==h {print $2; exit}' "$work/hashes")
if [ -z "$key" ]; then
printf '%s\t%s\t%s\n' "$repo" "$pg" '找不到能正推出這個 HASH 的候選鍵' >> "$work/orphans"
continue
fi
printf '%s\t%s\t%s\t%s_%s\n' "$repo" "$pg" "$repo" "$ty" "$(sh "$hash_id" "$key")" >> "$work/map"
done 3< "$work/pending"
# ---- 第四輪:找出指向被搬頁、但自己沒被搬的引用方 ----
# 連結改寫只動被搬的那些頁自己的內容。沒被搬的頁裡那些 [[...]] 這支碰不到,
# 所以至少要把它們列出來交給人改,不能讓它們搬完就靜靜 404。
if [ -s "$work/map" ]; then
cut -f2 "$work/map" | sort -u > "$work/movednames"
cut -f1,2 "$work/map" | sort -u > "$work/movedkeys"
sort -u "$work/allpages" > "$work/allpages.uniq"
while IFS="$TAB" read -r repo pg <&3; do
[ -n "$pg" ] || continue
if grep -qxF "$repo$TAB$pg" "$work/movedkeys"; then continue; fi
sh "$gitea" wiki-get "$repo" "$pg" 2>/dev/null > "$work/one" || continue
# [[顯示文字|頁名]] 的頁名在豎線右邊,[[頁名]] 就是整段。兩種都收成頁名再比對。
targets=$(grep -oE '\[\[[^]]+\]\]' "$work/one" 2>/dev/null \
| sed -e 's/^\[\[//' -e 's/\]\]$//' -e 's/^.*|//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
| sort -u | grep -xF -f "$work/movednames" 2>/dev/null || true)
for t in $targets; do
printf '%s\t%s\t%s\n' "$repo" "$pg" "$t" >> "$work/refs"
done
done 3< "$work/allpages.uniq"
fi
# ---- 報告 ----
echo '對照表(舊存取庫/舊頁名 → 新存取庫/新頁名)'
if [ -s "$work/map" ]; then
while IFS="$TAB" read -r orepo opage nrepo npage <&3; do
[ -n "$opage" ] || continue
printf ' %s/%s → %s/%s\n' "$orepo" "$opage" "$nrepo" "$npage"
done 3< "$work/map"
else
echo ' (沒有需要搬移的頁)'
fi
echo '孤兒頁(需人工處理)'
if [ -s "$work/orphans" ]; then
while IFS="$TAB" read -r repo pg why <&3; do
[ -n "$pg" ] || continue
printf ' %s/%s:%s\n' "$repo" "$pg" "$why"
done 3< "$work/orphans"
else
echo ' (無)'
fi
echo '引用被搬頁、但自己沒被搬(連結改寫碰不到,需人工改)'
if [ -s "$work/refs" ]; then
while IFS="$TAB" read -r repo pg target <&3; do
[ -n "$pg" ] || continue
printf ' %s/%s 指向 %s\n' "$repo" "$pg" "$target"
done 3< "$work/refs"
else
echo ' (無)'
fi
if [ -s "$work/notes" ]; then
echo '備註'
sed 's/^/ /' "$work/notes"
fi
if [ "$apply" -eq 0 ]; then
echo '(預覽模式,沒有寫入任何內容。加 --apply 才真的搬。)'
if [ -s "$work/orphans" ] || [ -s "$work/refs" ]; then exit 3; fi
exit 0
fi
# ---- 搬移第一步:把每一頁寫到新位置 ----
mkdir -p "$work/body"
: > "$work/done" # 舊存取庫 \t 舊頁名 \t 新存取庫 \t 新頁名 \t 序號
: > "$work/failed"
abort() { # $1=訊息。金鑰或 API 出問題就整個停下,把已經做完的部分照樣報出來。
printf '%s\n' "$1" >> "$work/failed"
echo '搬移中止'
sed 's/^/ /' "$work/failed"
if [ -s "$work/manual" ]; then
echo '需人工確認'
sed 's/^/ /' "$work/manual"
fi
exit 1
}
n=0
while IFS="$TAB" read -r orepo opage nrepo npage <&3; do
[ -n "$opage" ] || continue
n=$((n+1))
rc=0
sh "$gitea" wiki-get "$orepo" "$opage" > "$work/body/$n" 2>/dev/null || rc=$?
if [ "$rc" -ne 0 ]; then
printf '%s/%s:讀不回舊頁(結束碼 %s),這一頁不搬。\n' "$orepo" "$opage" "$rc" >> "$work/failed"
continue
fi
# 寫之前先讀目的地。只有 4 才准建新頁——部分搬完後重跑,或目的地那一頁已經由
# wiki-contents.sh 建好,直接寫就是拿舊庫那份蓋掉一個活著的頁。
rc=0
sh "$gitea" wiki-get "$nrepo" "$npage" >/dev/null 2>&1 || rc=$?
case "$rc" in
4) ;;
0)
printf '%s/%s 已經有內容,不覆蓋。請比對它與 %s/%s 之後自行決定。\n' \
"$nrepo" "$npage" "$orepo" "$opage" >> "$work/manual"
continue ;;
7)
abort "$(printf '讀 %s/%s 遇金鑰失效或權限不足(結束碼 7)。' "$nrepo" "$npage")" ;;
*)
abort "$(printf '讀 %s/%s 失敗(結束碼 %s)。' "$nrepo" "$npage" "$rc")" ;;
esac
rc=0
sh "$gitea" wiki-put "$nrepo" "$npage" "$work/body/$n" >/dev/null || rc=$?
if [ "$rc" -ne 0 ]; then
printf '%s/%s:寫不進 %s/%s(結束碼 %s)。\n' "$orepo" "$opage" "$nrepo" "$npage" "$rc" >> "$work/failed"
continue
fi
printf '%s\t%s\t%s\t%s\t%s\n' "$orepo" "$opage" "$nrepo" "$npage" "$n" >> "$work/done"
done 3< "$work/map"
# ---- 搬移第二步:查每一個目的地的絕對網址,改寫 [[...]] 連結 ----
: > "$work/urls" # 舊頁名 \t 新頁名 \t 絕對網址
while IFS="$TAB" read -r orepo opage nrepo npage idx <&3; do
[ -n "$opage" ] || continue
url=$(sh "$gitea" wiki-url "$nrepo" "$npage" 2>/dev/null) || continue
printf '%s\t%s\t%s\n' "$opage" "$npage" "$url" >> "$work/urls"
done 3< "$work/done"
while IFS="$TAB" read -r orepo opage nrepo npage idx <&3; do
[ -n "$opage" ] || continue
rc=0
python3 - "$work/body/$idx" "$work/urls" "$work/body/$idx.new" <<'PY' || rc=$?
import re
import sys
src, urlmap_path, out = sys.argv[1:4]
urlmap = {}
for line in open(urlmap_path, encoding='utf-8'):
parts = line.rstrip('\n').split('\t')
if len(parts) == 3:
urlmap[parts[0]] = (parts[1], parts[2])
text = open(src, encoding='utf-8').read()
def repl(m):
inner = m.group(1)
if '|' in inner:
label, target = inner.split('|', 1)
label, target = label.strip(), target.strip()
else:
target = inner.strip()
label = None
hit = urlmap.get(target)
if not hit:
return m.group(0)
newname, url = hit
# 原本沒有顯示文字的寫法,顯示的就是頁名本身;頁名換了就跟著顯示新頁名。
return '[%s](%s)' % (label if label is not None else newname, url)
new = re.sub(r'\[\[([^\]]+)\]\]', repl, text)
open(out, 'w', encoding='utf-8').write(new)
# 9 代表這一頁沒有需要改寫的連結,不必再寫一次。
raise SystemExit(0 if new != text else 9)
PY
if [ "$rc" -eq 9 ]; then continue; fi
if [ "$rc" -ne 0 ]; then
printf '%s/%s:連結改寫失敗。\n' "$nrepo" "$npage" >> "$work/failed"
continue
fi
if ! sh "$gitea" wiki-put "$nrepo" "$npage" "$work/body/$idx.new" >/dev/null; then
printf '%s/%s:連結改寫後寫不回去。\n' "$nrepo" "$npage" >> "$work/failed"
fi
done 3< "$work/done"
# ---- 搬移第三步:確認新頁讀得回來,才刪舊頁 ----
: > "$work/moved"
while IFS="$TAB" read -r orepo opage nrepo npage idx <&3; do
[ -n "$opage" ] || continue
if ! sh "$gitea" wiki-get "$nrepo" "$npage" >/dev/null 2>&1; then
printf '%s/%s:新頁讀不回來,舊頁保留不刪。\n' "$nrepo" "$npage" >> "$work/failed"
continue
fi
if ! sh "$gitea" wiki-delete "$orepo" "$opage" >/dev/null; then
printf '%s/%s:新頁已就位,舊頁刪不掉,請人工刪除。\n' "$orepo" "$opage" >> "$work/failed"
continue
fi
printf '%s/%s → %s/%s\n' "$orepo" "$opage" "$nrepo" "$npage" >> "$work/moved"
done 3< "$work/done"
echo '已搬移'
if [ -s "$work/moved" ]; then sed 's/^/ /' "$work/moved"; else echo ' (無)'; fi
if [ -s "$work/manual" ]; then
echo '需人工確認'
sed 's/^/ /' "$work/manual"
fi
if [ -s "$work/failed" ]; then
echo '搬移失敗'
sed 's/^/ /' "$work/failed"
exit 1
fi
if [ -s "$work/orphans" ] || [ -s "$work/refs" ] || [ -s "$work/manual" ]; then exit 3; fi
exit 0