Files
gitea/tools/migrate-wiki.sh
T
jiantw83 b53cfeaf34 fix(migrate-wiki): 正推比對涵蓋兩代頁名規則
What:候選鍵的舊 HASH 改成兩種都算——第一代只取 SHA-1 前 8 碼大寫,第二代在其上
把首碼落在 0-9ABC 的改寫成 H 加前 7 碼。兩種都進對照表,任一種配得上就算配對成功。

Why:原本只算第二代,所以第一代那些首碼落在 0-9ABC 的頁一律配不到,被誤判成孤兒
留在原地。實測同一個鍵在第一代是 1C516D85、第二代是 H1C516D8,兩張頁至今並存,
只算第二代就會漏掉第一代那一張。

How:實地重跑對照表,可搬頁數從 35 增為 48,孤兒從 14 減為 2。剩下兩頁一頁是更早的
{型別}_{日期}_{HASH} 命名、頁名不符任何已知樣式,一頁反推不到候選鍵,兩頁都照原則
只列不猜。首碼落在 D、E、F 的鍵兩代同值,只印一行,不會重複。

Who:jsc-gitea
2026-09-02 12:16:44 +08:00

445 lines
16 KiB
Bash
Executable File
Raw 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 可重複,補充自動蒐集不到的候選鍵。
#
# 候選鍵來源:
# 目錄頁表格的存取庫、主機、帳號、工具、期間欄,以及這幾欄依序串成的組合鍵。
# 內容頁的 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 = []
for line in lines:
cs = cells(line)
if cs is None:
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
parts = []
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
out.append(v)
parts.append(v)
if len(parts) > 1:
out.append('/'.join(parts))
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