Author SHA1 Message Date
admin 93c18023f6 Merge pull request '釋出 wiki 目錄頁的 H2 區塊改寫與連結驗證至 master,版本 0.2.2 升到 0.2.5' (#53) from develop into master
Reviewed-on: #53
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 03:17:28 +00:00
admin cfb6a89e82 Merge pull request 'wiki 目錄頁改成大標題加條列,舊表格讀到就自動轉檔' (#52) from feat/contents-list/main into develop
Reviewed-on: #52
2026-09-02 10:00:21 +00:00
jiantw83 a229964dfe chore(plugin): 三份 manifest 升版至 0.2.5
三份 plugin manifest 的版本號同步升到 0.2.5。

目錄頁格式改動了工具的對外行為,其餘七個 domain 的範本要對上這一版才組得
出正確的區塊。版本號沒動,版本守衛就看不出機器上裝的是舊版工具。

三份一起改,維持既有的同版策略。

功能範圍:目錄頁版面改版的版本標記。
2026-09-02 17:22:35 +08:00
jiantw83 89463cd263 docs(wiki): 同步目錄頁條列格式的敘述與工具用法
README 的工具用法、技能行為清單、連結規則與 wiki 技能本文,全部改寫成
目錄頁的區塊格式:參數名從整列改成整個區塊、key-col 的用途縮回只給轉檔
用、補上取標題與換引言的判準,並登錄新增的驗證腳本。

文件與工具講的不是同一件事,呼叫端就會照舊敘述傳整列的表格文字進去,寫出
半表格半條列的頁面。key-col 的語意變動最容易誤解——它從「要換掉哪一欄」
變成「轉檔時哪一欄持有身分」,敘述不改就會被填成別的欄位。

目錄頁條列、內容頁維持圖表優先,這個區分在每一份文件裡都寫明,避免把改版
範圍誤讀成整個 wiki。結束碼說明一併對回工具現況。

功能範圍:目錄頁版面改版的文件同步。
2026-09-02 17:22:29 +08:00
jiantw83 9942cf2506 fix(migrate-wiki): 候選鍵兼收條列與表格兩種目錄頁形狀
搬頁時蒐集候選鍵的那一段,原本只讀目錄頁表格的存取庫、主機、帳號、工具、
期間五欄。現在改成 H2 區塊底下的「- {欄位名}:{值}」也照樣讀,表格的欄位
維持原樣收下,兩種形狀都認得。

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

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

功能範圍:目錄頁版面改版的搬頁相容。
2026-09-02 17:22:24 +08:00
jiantw83 c7c14445e6 test(check-contents-format): 新增目錄頁區塊格式的離線驗證
新增一支只讀寫暫存檔的驗證腳本,全程走 format 子命令,涵蓋五種舊頁狀態:
純表格、已是條列且鍵命中、已是條列且鍵未命中、表格與區塊混合、用範本建
新頁。另外驗身分欄取標題的四種情形與引言的五種情形。

轉檔與 upsert 的錯法都是靜默的:標題取錯只會多長一個區塊,引言沒換掉只是
說明過期,兩種都不會報錯,要等到線上頁面壞掉才看得出來。手動打 API 驗又
會在正式頁上留下試出來的垃圾紀錄。

驗證只比對輸入與輸出檔,不碰網路,任何機器上都跑得完。除了正常流程還特別
釘住三件事:表格取不出鍵那一列要擋下來不猜標題、轉檔後拿結果重跑同一筆
必須一字不變、區塊檔沒帶標題也照樣補上。任一項不符就印出期望值與實際值
之後立刻停住,不續跑其餘項目。

功能範圍:目錄頁版面改版的迴歸防護。
2026-09-02 17:22:18 +08:00
jiantw83 2e8712126f refactor(wiki-contents): 目錄頁改成 H2 區塊 upsert,舊表格自動轉檔
目錄頁的一筆紀錄從 markdown 表格的一列,改成一個 H2 區塊:標題就是這一筆
對應內容頁的頁名,欄位變成標題底下一層的條列「- {欄位名}:{值}」。upsert
換掉標題相同那一塊,找不到就附加到頁尾。

表格一列擠著所有欄位,欄位一多就超出可讀寬度,得橫向捲才看得完;換行之後
也分不出哪幾格屬於同一筆。條列沒有寬度上限,一筆看得完整。

讀到的舊頁還是表格,就先整頁轉成區塊再在轉好的頁面上做 upsert,一頁同時
有表格與區塊也照樣接得起來。轉檔取標題只看持有身分那一欄:有連結取網址
最後一段路徑並解掉百分號編碼,沒連結取純文字——連結的顯示文字常常是計畫
或工作包名稱而不是頁名,拿它當標題會跟呼叫端傳進來的鍵對不上,同一筆長出
第二個區塊,舊的那塊從此再也更新不到。那一欄取不出鍵就整支擋下來,不猜
標題。轉檔那一次有給範本,就連 H1 與引言一起換成範本那一份,舊引言否則
會一直講「一列一筆」;頁面已是條列時只更新自己那一筆,不動引言。組頁邏輯
另外拆出 format 子命令,離線驗證才叫得到,不必打 API。

功能範圍:目錄頁版面改版,涵蓋所有以 _CONTENTS 結尾的頁面與每一支寫目錄
頁的技能。
2026-09-02 17:22:06 +08:00
admin bac7810f51 Merge pull request '收尾寫一筆 skill-end 事件,執行狀態才回報得到助理' (#51) from feat/status-report into develop
Reviewed-on: #51
2026-09-02 08:04:24 +00:00
jiantw83 a1a00eae74 chore(plugin 版本): 三份 manifest 升版至 0.2.4 2026-09-02 16:01:15 +08:00
jiantw83 f69b4b6f85 feat(狀態回報): 收尾寫一筆 skill-end 事件
現行紀錄只記「被叫用」,沒有成敗也沒有結束碼。跑完整輪的技能與開場就
中止的技能,在紀錄裡長得一模一樣。

start 由技能用量 hook 順手發,不必改技能文件。end 只能由技能自己在收尾
步驟寫——hook 接在技能工具呼叫上,而實際工作發生在之後的模型輪次,它在
原理上看不到成敗。有 start 沒有配對的 end,就是那一輪中止了。

status 五選一,每支技能各自寫明什麼情況選哪一個。找不到回報腳本就安靜
跳過,回報失敗一律不改變技能自己的結論。
2026-09-02 16:01:15 +08:00
admin e045cc804f Merge pull request '連結一律寫成 [文字](絕對網址),並在寫入前驗證連得到' (#50) from feat/link-verification/main into develop
Reviewed-on: #50
2026-09-02 06:46:13 +00:00
jiantw83 b59782d093 chore(plugin 版本): 三份 manifest 升版至 0.2.3 2026-09-02 14:27:18 +08:00
jiantw83 9ec18b8a94 feat(link): 連結一律寫成 [文字](絕對網址),寫入前先驗證連得到
取消 [[頁名]] 與 [[顯示文字|頁名]] 兩種同 wiki 寫法,不再分「同存取庫」與
「跨存取庫」兩條規則。那種寫法只在自己那個 wiki 內解析,寫錯不報錯,畫面上
看起來像普通文字或死連結,巡不到也修不了。

連結寫進頁面前先過 jsc-gitea 的 link-check.sh,結束碼 0 才寫。驗證一律走 API,
不看網頁狀態碼:私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把
好連結判成壞的。認證失敗回 7,與死連結的 1 分開,免得金鑰一過期就把還在的頁
整批判死。
2026-09-02 14:27:18 +08:00
admin 9c9d912292 Merge pull request 'release: wiki 目錄頁專用存取庫、HASH 完整 40 碼、閘門依 CLI 分流' (#48) from develop into master
Reviewed-on: #48
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 04:20:27 +00:00
admin f3e6933942 Merge pull request 'fix(migrate-wiki): 正推比對涵蓋兩代頁名規則' (#49) from fix/migrate-two-hash-generations into develop
Reviewed-on: #49
2026-09-02 04:18:42 +00:00
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
admin 803e7ed598 Merge pull request 'feat(wiki): 目錄頁專用存取庫、HASH 去除截短與 H 前綴' (#47) from feat/wiki-contents-repo/main into develop
Reviewed-on: #47
2026-09-02 03:27:43 +00:00
jiantw83 c40b561589 feat(wiki): 目錄頁專用存取庫、HASH 去除截短與 H 前綴
What:CONTENTS 成為第 15 種頁面類型,解析鏈為 JSC_WIKI_REPO_CONTENTS 到
JSC_WIKI_REPO,刻意不退回型別變數。hash-id 拿掉 8 碼截短與 H 前綴改寫,只留大寫
轉換,輸出完整 40 碼;空輸入改成用法錯誤。新增 page-name.sh、wiki-contents.sh、
migrate-wiki.sh 與 wiki-delete 子命令。

Why:H 前綴會命中 16 個首碼裡的 13 個,還丟掉第 8 碼,把有效熵壓到 28 位元,而且
全庫查不到任何理由紀錄。目錄頁的整列 upsert 原本 14 處只有一處寫成程式,同一段判斷
做 14 次,錯一次就少一筆紀錄。

How:頁名樣式仍收 H 加 7 碼的舊頁,遷移期間讀得到舊頁。wiki-contents.sh 建新頁時
剝掉範本的示範列,否則每個目錄頁第一次建立都會留一列佔位死連結。migrate-wiki.sh
預設只印對照表,--apply 先寫新頁、確認寫成、才刪舊頁;孤兒頁只列不猜,因為 SHA-1
不可逆,新頁名只能靠候選鍵正推。

Who:jsc-gitea
2026-09-02 11:02:07 +08:00
admin e0038de1fc Merge pull request '釋出 jsc-assist 的 marketplace 條目' (#46) from develop into master
Reviewed-on: #46
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 04:57:28 +00:00
admin a3ce17f98d Merge pull request '放行 jsc-assist 的 marketplace 條目到預設分支' (#45) from chore/marketplace-assist-registry/main into develop
Reviewed-on: #45
2026-09-01 04:55:16 +00:00
admin 127c7cc238 Merge pull request 'chore/marketplace-assist-registry/sync-copies' (#44) from chore/marketplace-assist-registry/sync-copies into chore/marketplace-assist-registry/main
Reviewed-on: #44
2026-09-01 04:53:31 +00:00
jiantw83 769cdcfc35 chore(marketplace): 把 jsc-assist 登錄進統一 marketplace
What:
- 兩份 marketplace 檔各加一個 jsc-assist 條目,來源網址指向 assist 存放庫。

Why:
- 準則要求每個 domain 存放庫都帶同一份 marketplace 檔,任何一個存放庫都能當註冊入口。副本之間只要有一份沒跟上,稽核就會報出不一致。
- 正本少了這個條目,各 CLI 的安裝指令就找不到 jsc-assist,這個 domain 等於發佈不出去。

How:
- 條目由 meta 的 sync-marketplace.sh 產生,同時寫進正本與每個 domain 存放庫的副本,寫完逐檔比對位元組。這一支存放庫的兩份副本就是那一輪的產物。
- 條目依名稱排序,縮排與非 ASCII 描述的處理都交給同一支腳本,不手改 JSON。
- 這一批是從最新的預設分支重新產生的。前一輪的分支基底早於監控頁型別那批改動,直接合併會把那些改動回退掉,所以整批重做而不是解衝突。

Who:
助理 domain 落地的註冊步驟在這個存放庫的同步。
2026-09-01 12:50:04 +08:00
admin b2f13b0ce8 Merge pull request '釋出 jsc-gitea 0.2.1:wiki 頁型別新增 MONITOR' (#43) from develop into master
Reviewed-on: #43
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 04:46:22 +00:00
admin 5bba1f9576 Merge pull request '釋出 jsc-gitea 0.2.1:wiki 頁型別新增 MONITOR' (#42) from feat/monitor-wiki-page-type/main into develop
Reviewed-on: #42
2026-09-01 04:44:19 +00:00
admin 44797aae97 Merge pull request 'feat/monitor-wiki-page-type/resolve-and-check' (#41) from feat/monitor-wiki-page-type/resolve-and-check into feat/monitor-wiki-page-type/main
Reviewed-on: #41
2026-09-01 04:27:47 +00:00
jiantw83 8467b89a09 feat(wiki): 頁型別新增 MONITOR
What:
- resolve_wiki_repo 的型別白名單與檔頭註解加上 MONITOR,check-wiki-rules.sh 的 TYPES 一併加入。
- README 的兩處型別列舉加上 MONITOR,環境變數表補一列 JSC_WIKI_REPO_MONITOR。
- wiki 技能的 allowed types 與行為清單的呼叫端補上這個型別與它的擁有者。
- 三份 manifest 的版本一起提升。

Why:
- 技能助理要把巡檢結果寫進 wiki,落點就是監控頁。型別不在白名單裡,wiki-repo 會直接回「unknown wiki type」而拒絕解析,助理連寫都寫不出去。
- 型別串散在五個檔案,只改一處會讓解析通過但檢核工具漏掉,或反過來。所以一次改齊。

How:
- MONITOR 放在型別串尾端,接在 TOOLING 之後。既有順序是依用途分群,不是字母序,所以不重排。
- 環境變數的規則完全沿用既有型別:先讀 JSC_WIKI_REPO_MONITOR,再退回 JSC_WIKI_REPO,不得跨型別代用。這一條由 resolve_wiki_repo 統一處理,新型別自動繼承,不必另寫分支。
- 雜湊來源的規則不寫在這個存放庫。README 已載明雜湊規則的唯一來源是技能準則的命名總表,這裡不複述。
- wiki 技能的 description 原本逐一列舉呼叫端,已經接近長度上限。這次改成括號標注頁型的濃縮寫法,加了一個呼叫端之後整行反而變短,句數維持在上限內。

Who:
技能助理落地帶出來的頁型別需求,四個存放庫同一批改。
2026-09-01 12:10:40 +08:00
admin 5bb3d4a700 Merge pull request '釋出 jsc-gitea 0.2.0:修正兩支技能的 frontmatter 語法' (#39) from develop into master
Reviewed-on: #39
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 01:02:56 +00:00
jiantw83 9a57a89f46 Merge pull request '收攏 html-export 與 wiki-to-issue 的 frontmatter 語法修正' (#38) from feat/cli-hook-rewire/main into develop 2026-09-01 00:58:41 +00:00
jiantw83 9cec36d92b Merge pull request '修正 html-export 與 wiki-to-issue 技能 frontmatter 的 YAML 純量語法' (#37) from feat/cli-hook-rewire/quote-description into feat/cli-hook-rewire/main 2026-09-01 00:56:09 +00:00
jiantw83 7c3e98fc9b fix(frontmatter): 修正 html-export 與 wiki-to-issue 技能 SKILL.md frontmatter 的 YAML 純量語法錯誤
What:
- 修正 skills/html-export/SKILL.md 與 skills/wiki-to-issue/SKILL.md frontmatter 裡 description 欄位的 YAML 語法錯誤。
- 兩支技能的 description 整串加上單引號,內部撇號改寫成兩個單引號,內容文字一個字都沒變。
- 同步更新 plugin.json、.claude-plugin/plugin.json、.codex-plugin/plugin.json 三個 manifest 版本號,從 0.1.9 進到 0.2.0(patch 滿 9,依準則進位到 minor)。

Why:
- 兩支 description 都內含「冒號加空白」,屬於未加引號的 YAML plain scalar,違反 YAML 語法規定。
- Antigravity 解析 frontmatter 時當場中斷,整支技能被靜默丟棄,沒有任何錯誤訊息;磁碟上 34 支技能,Antigravity 只認得 28 支。
- 準則要求 description 用英文撰寫,不能把「: 」改成全形冒號迴避語法問題,只能加引號修正。

How:
- 兩支技能的 description 值都加上單引號,內部撇號寫成兩個單引號跳脫,其餘字元不動。
- 用 git show HEAD: 分別取出兩支技能改前的原始值,把改後的單引號純量還原後逐一做字串相等比對,確認逐字相同、字元數一致。
- 執行 ste100-lint.sh、check-behaviors.sh、lint-frontmatter.sh 三支檢查腳本,退出碼皆為 0;git diff --numstat 顯示兩個檔案都只動了 frontmatter 那一行。

Who:
- 本次修到 gitea 技能組的 html-export 技能(把 Wiki 頁面或 Issue 匯出成單一 HTML 檔)與 wiki-to-issue 技能(把 Wiki 頁面轉建成 Issue)。
2026-08-31 19:03:39 +08:00
admin b729aa92f3 Merge pull request '釋出 jsc-gitea 0.1.9:新增五支技能的行為清單' (#36) from develop into master
Reviewed-on: #36
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-31 08:18:44 +00:00
jiantw83 9630dcd5f6 Merge pull request '收攏 5 支技能的行為清單,功能主幹併回 develop' (#35) from feat/skill-behaviors-and-version-block/main into develop 2026-08-31 08:10:35 +00:00
jiantw83 925dc59016 Merge pull request '盤點 5 支技能的實際行為,新增行為清單當驗證基準' (#34) from feat/skill-behaviors-and-version-block/behavior-list into feat/skill-behaviors-and-version-block/main 2026-08-31 08:09:20 +00:00
jiantw83 8a07f15fde chore(plugin): 同步三份 manifest 版本至 0.1.9
What:把 plugin.json、.claude-plugin/plugin.json、.codex-plugin/plugin.json 三份 manifest 的 version 從 0.1.8 改成 0.1.9,只動版本欄位。

Why:本次新增 references/behaviors.md,存放庫內容已經變動,版本號要跟著往前推。三份 manifest 若不同步,Claude 與 Codex 兩邊裝到的版本會對不上,版本檢查也會抓到落差。

How:三份檔案各改一行 version 字串,其餘欄位不動。plugin.json 的 skills 路徑、.claude-plugin 的 author 與 repository、.codex-plugin 的既有設定都維持原樣。jsc.requires 的相依版本這次沒有變動,不一併調整。

Who:jsc-gitea 外掛的版本標示,供 jsc-cli:deploy 安裝與 jsc-hooks/hooks/version-guard.sh 版本檢查使用。
2026-08-31 13:40:54 +08:00
jiantw83 bedbdc227f feat(references): 新增 jsc-gitea 技能行為清單
What:新增 references/behaviors.md。這一頁替 jsc-gitea 的五支技能各留一節,依序是 html-export、html-style、repo-sync、wiki、wiki-to-issue。每一節放一張五列表,欄位固定為觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象。

Why:技能驗證原本沒有共同的比對基準,每次都要回頭讀 SKILL.md 反推該有的行為。這一頁把行為寫成基準,驗證時直接比對。清單放在本存放庫而不是集中到 meta,技能改動與清單才會落在同一個 PR,內容不會漂移,也不用跨存放庫開兩條 PR 互卡。

How:逐支技能盤點,從 SKILL.md 與 skills 底下的腳本讀出實際行為。外部呼叫欄逐一列出用到的工具腳本、被呼叫的技能與 Gitea API,例如 tools/gitea-link.sh、tools/html-style.sh、tools/html-render.sh、tools/repo-sync.sh、tools/issue.sh、tools/hash-id。可驗證跡象欄一律寫看得到的產物,例如 HTML 檔落點、wiki 頁名、議題網址、設定檔內容。稽核時修正 html-style 一處,設定檔格式從空白分隔改成實際的 {種類}={版型},{風格}。表格欄位依 jsc-meta/tools/check-behaviors.sh 的程式層檢查對齊。

Who:jsc-gitea 的技能驗證參考基準,涵蓋 html-export、html-style、repo-sync、wiki、wiki-to-issue 五支技能。
2026-08-31 13:40:54 +08:00
admin 678b79ca39 Merge pull request 'chore(release): 放行技能組稽核修正到預設分支' (#33) from develop into master
Reviewed-on: #33
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-31 03:54:34 +00:00
admin 001da3988c Merge pull request 'fix/skill-check-compliance-and-flow' (#32) from fix/skill-check-compliance-and-flow into develop
Reviewed-on: #32
2026-08-31 03:24:33 +00:00
jiantw83andClaude Opus 5 82eed06ad1 chore(gitea): 補上寫入確認腳本的結束碼宣告
檔頭只寫了用法與規則,沒有列結束碼,呼叫端無法逐碼分流,腳本檢查因此不通過。
依實作補上兩碼:0 表示已確認,可以送出寫入;2 表示不要寫入,並列出走這一碼的五種情形。
一併說明用法錯誤與人工拒絕為何共用 2。只加註解,指令行為與輸出都沒有變。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:19:32 +08:00
jiantw83 72ba7b8801 chore(gitea): 技能依結束碼分流,可併行的步驟改成同時跑
技能以前只寫成功路徑。腳本回非 0 時,模型得自己猜下一步,猜錯就是靜靜
往下走。現在每一支腳本在檔頭宣告自己的結束碼,技能也逐碼寫明要停、要
問、還是要改參數再呼叫一次。兩支技能補上連線變數的解析步驟,讓缺值在
第一步就浮出來,而不是在中途撞出一行英文錯誤。

流程也拉平了。取內容、選範本、問輸出位置這幾件事彼此不相依,改成同一批
送出;存取庫批次同步從逐一處理改成各存取庫同時進行,一個 owner 底下有
上百個存取庫時差距最明顯。

相依的技能組下限寫進外掛設定,版本推進。
2026-08-31 11:13:32 +08:00
jiantw83 8da586c481 docs(gitea): 更正過期說明並補齊指令用法
說明文件有兩處與現況不符。雜湊規則在這裡複述了一份,跟唯一來源各說各
話,讀的人會照著舊的做;技能組異動報告的雜湊來源早就改過,敘述卻留在
原地。現在改成指向唯一來源,並刪掉重複的那一份。

新增的指令、頁面類型與全腳本共用的結束碼也一併寫進說明。查不到就只能
翻程式,那正是說明文件該擋下來的成本。domain 簡介補上議題轉換與 HTML
匯出,與實際提供的技能一致。
2026-08-31 11:12:20 +08:00
jiantw83 2b759542a8 feat(gitea): 新增種類推導指令與技能盤點頁型
範本種類以前由模型每次重推。底線怎麼切、標籤照什麼順序試,都是固定的
輸入輸出,卻放在技能內文裡由模型自己來,有人切錯底線,也有人跳過標籤
順序。現在把規則寫進腳本,輸出固定一行,還附上是哪個字首或哪個標籤產生
的依據。已經有標籤名單時可以直接帶進來,省掉一次 API 呼叫。

技能盤點頁是新的頁面類型。加進 wiki 存取庫的允許清單與規則驗證腳本,
這種頁才有地方可放,解析規則也才有人驗。
2026-08-31 11:11:55 +08:00
jiantw83 28a52c9f0c fix(gitea): 認證失敗不再被讀成頁面不存在
金鑰失效以前會偽裝成別的結果。指令把請求直接接進管線,管線的結束狀態
取自後段的解析程式,前段的失敗就被吃掉。wiki 頁清單因此看起來是空的,
PR 留言看起來像沒有任何審查意見。wiki 讀取更把每一種失敗都翻成
「頁面不存在」。

技能組寫 wiki 的語意是附加、不覆蓋,判斷依據是先把舊內容讀回來。呼叫端
一旦把認證失敗當成一張新頁,就會整份蓋上去,舊紀錄直接消失。

現在失敗成因分開回報:找不到、金鑰失效或權限不足、其他 API 失敗,各給
一個結束碼。每條管線先接進變數,先看結束碼,再解析內容。議題工具的同一
類缺陷一併修掉。wiki 技能也把「只有找不到才可以建新頁」寫成獨立規則,
涵蓋每一條「不存在就建立」的路徑。
2026-08-31 11:11:55 +08:00
admin dfb9557992 Merge pull request 'fix/gitea-write-confirm' (#31) from fix/gitea-write-confirm into develop
Reviewed-on: #31
2026-08-28 10:00:26 +00:00
Jeffery d4466dfba8 fix(gitea): 寫入前先確認 2026-08-28 17:55:51 +08:00
admin 054d56c306 Merge pull request 'develop' (#30) from develop into master
Reviewed-on: #30
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 01:58:11 +00:00
admin e464047774 Merge pull request 'feat/change-requests/main' (#29) from feat/change-requests/main into develop
Reviewed-on: #29
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 01:50:58 +00:00
admin e389a81d61 Merge pull request 'feat/change-requests/pr-comment-replies' (#28) from feat/change-requests/pr-comment-replies into feat/change-requests/main
Reviewed-on: #28
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 01:46:43 +00:00
jiantw83 3fe9b55840 feat(gitea): 支援回覆 PR 留言 2026-08-28 09:30:02 +08:00
admin 980c1e98ec Merge pull request 'release: v0.1.6 develop 到 master' (#27) from develop into master
Reviewed-on: #27
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 09:01:00 +00:00
admin 8483f8d1bb Merge pull request 'feat(gitea): wiki 支援 SKILLSET 技能組異動報告頁型' (#26) from feat/skillset-governance/main into develop
Reviewed-on: #26
2026-08-27 08:54:42 +00:00
admin a5b5d7362c Merge pull request 'feat(gitea): 支援 SKILLSET 頁面類型' (#25) from feat/skillset-governance/wiki-skillset-type into feat/skillset-governance/main
Reviewed-on: #25
2026-08-27 08:38:56 +00:00
jiantw83 1db01ca0f1 chore(manifest): 三份 manifest 版本升到 0.1.6
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 的 `version` 由 0.1.5 改為 0.1.6。

Why:本次 `wiki-repo` 多支援一種頁面類型,`wiki` 技能的允許頁型也跟著補齊,屬於行為變更,版本要跟著往上走,各 CLI 才知道要更新。

How:三份只改 `version` 一個欄位,其餘內容不動,三份保持同一版號。

Who:`jsc-gitea` 外掛的套件描述檔。
2026-08-27 16:34:17 +08:00
jiantw83 c3b46c982f docs(gitea): README 補上 SKILLSET 頁型與專用環境變數
What:`README.md` 兩處增修。`wiki-repo` 的用法說明把允許的 `TYPE` 清單補到十二種(加上 `CHECK`、`REPORT`、`SKILLSET`);環境變數表新增 `JSC_WIKI_REPO_SKILLSET` 一列,寫明對應 `SKILLSET_CONTENTS`、`SKILLSET_{HASH}`(技能組異動報告)與雜湊來源 `{owner}/{repo}`。

Why:README 的類型清單停在九種,`CHECK` 與 `REPORT` 支援了卻沒補上。照 README 設定環境變數的人會以為這幾種頁型沒有專用變數,只能全塞進共用的 `JSC_WIKI_REPO`。

How:`SKILLSET` 單獨列一列而不是併進 `JSC_WIKI_REPO_{TYPE}` 那一行,因為它要多寫雜湊來源;`JSC_WIKI_REPO_{TYPE}` 那一行只補清單,退回規則不動,兩列都保留「不做跨類型代用」這句。

Who:讀 `jsc-gitea` 說明設定 wiki 位置的操作者。
2026-08-27 16:34:17 +08:00
jiantw83 653d54476b test(check-wiki-rules): 頁型驗證涵蓋 SKILLSET,說明改為不綁類型數量
What:`tools/check-wiki-rules.sh` 的 `TYPES` 加入 `SKILLSET`,檔頭的「涵蓋全部十一種頁面類型」改成「涵蓋 `TYPES` 列的每一種頁面類型」,誘餌值那段註解的「其他八個類型」改成「其餘每一個類型」。

Why:驗證清單沒跟上,新頁型的三項判定(專用變數優先、退回共用變數、不得跨類型代用)就一次都沒驗到。註解裡寫死數字更麻煩:每加一種頁型就要記得改兩個數字,忘了改註解就開始騙人——事實上加 `CHECK` 與 `REPORT` 那次就已經漏了一個。

How:只加 `TYPES` 一個字串,三項判定與誘餌值都是照 `TYPES` 展開的,加進去就自動涵蓋,不必另外寫測項。註解一律改成指向 `TYPES` 這個變數,不再寫死數量,日後加頁型只要改一個地方。

Who:`jsc-gitea` 的 wiki 頁型解析驗證,以及日後新增頁面類型的人。
2026-08-27 16:34:17 +08:00
jiantw83 920c68087e feat(wiki): 支援 SKILLSET 頁面類型
What:`tools/gitea.sh` 的 `resolve_wiki_repo()` 允許清單加入 `SKILLSET`,`wiki-repo` 的用法註解同步。`skills/wiki/SKILL.md` 第 2 步的允許頁型補上 `SKILLSET`,同時補上先前漏列的 `CHECK` 與 `REPORT`;`description` 的呼叫端清單補上「`jsc-meta` 的 `SKILLSET` 頁」。

Why:`jsc-meta` 的四支異動技能收尾要把驗證結果寫進 `SKILLSET_{HASH}`,而所有 wiki 讀寫都走這一支技能與這一支腳本。頁型不在允許清單裡,`wiki-repo` 會直接 exit 2,報告一個字也寫不進去。`CHECK` 與 `REPORT` 是另一件事:腳本早就支援,技能的允許清單卻沒補,照技能寫的走會以為那兩種頁型不合法。

How:只加類型名,解析規則不動——`JSC_WIKI_REPO_SKILLSET` 優先,未設定才退回 `JSC_WIKI_REPO`,不做跨類型代用,兩個都沒有仍是 exit 3 交回技能問使用者。雜湊來源是被改動的 domain 存取庫 `{owner}/{repo}`,用的是同一支 `hash-id`,所以這裡不必為它多寫一條特例(`CHECK` 那種主機加帳號的例外才需要)。

Who:`jsc-gitea:wiki` 技能與 `jsc-meta` 四支異動技能的異動報告寫入路徑。
2026-08-27 16:34:17 +08:00
admin d239341dec Merge pull request 'release: v0.1.5 develop 到 master' (#24) from develop into master
Reviewed-on: #24
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 04:14:04 +00:00
jiantw83 8738f54b9d Merge pull request 'feat(gitea): 新增 PR 盯場輪詢腳本與 pr-get、pr-edit 兩個子指令' (#23) from feat/sdlc-flow-rules/main into develop 2026-08-27 03:39:36 +00:00
admin 6a8fd95a2c Merge pull request 'feat(gitea): PR 盯場輪詢與 PR 讀取編修子指令' (#22) from feat/sdlc-flow-rules/pr-watch-and-edit into feat/sdlc-flow-rules/main
Reviewed-on: #22
2026-08-27 03:26:03 +00:00
jiantw83 5892feea53 chore(manifest): 三份 manifest 版本升到 0.1.5
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 的 `version` 由 0.1.4 改為 0.1.5。

Why:本次新增 `pr-get`、`pr-edit` 與 `pr-watch.sh`,屬於行為變更,版本要跟著往上走,各 CLI 才知道要更新。

How:三份只改 `version` 一個欄位,其餘內容不動,三份保持同一版號。

Who:`jsc-gitea` 外掛的套件描述檔。
2026-08-27 11:20:27 +08:00
jiantw83 73cca6e6ea docs(gitea): README 與 AGENTS 補上盯 PR 與 PR 校準的用法
What:README 的指令清單補上 `pr-get`、`pr-edit` 與 `pr-watch.sh` 三項與各自的退出碼,環境變數表補 `JSC_HOME` 與 `JSC_PR_WATCH_INTERVAL`;AGENTS.md 新增一條規則,盯 PR 等合併一律用 `tools/pr-watch.sh`,不要自己寫輪詢迴圈。

Why:新指令沒寫進 README,呼叫端就只能翻腳本原始碼。`pr-watch.sh` 的退出碼 `10` 更要講清楚:它不是錯誤,是「有新留言,換你處理」的交棒訊號,看成錯誤就會把留言吞掉。

How:README 依既有版面把三項排進指令區塊,另補一段說明退出碼 `10` 的設計意圖。AGENTS.md 把新規則插進第 5 條,原第 5 條順延為第 6 條。

Who:讀 `jsc-gitea` 說明的人,以及依 AGENTS.md 行事的代理。
2026-08-27 11:20:27 +08:00
jiantw83 5471b97635 feat(pr-watch): 新增盯 PR 到合併的輪詢腳本
What:新增 `tools/pr-watch.sh`,盯一支 PR 直到它合併或關閉。退出碼 `0` 是已合併或已關閉、`10` 是有新留言要呼叫端接手、`2` 是參數錯誤或第一輪就連不上 Gitea、`3` 是查不到該 PR。輪詢間隔預設 60 秒,`JSC_PR_WATCH_INTERVAL` 可覆寫。

Why:等 PR 合併原本靠各技能自己寫輪詢迴圈,間隔與退場條件每支都不一樣,還常常自動逾時退場,PR 明明還開著就被當成收尾。改用一支共用腳本之後,等待行為只有一種,而且盯到 PR 收尾為止。

How:資料一律經同目錄的 `gitea.sh`(`pr-status`、`pr-comments`),不自行拼 API。已回報過的留言把最後一筆時間戳寫進狀態檔,預設 `$JSC_HOME/pr-watch/{owner}-{repo}-{編號}.seen`,同一則不重複回報;狀態檔還不存在時把既有留言整批回報一次,寧可重複也不漏掉開始盯之前的審查意見。PR 收尾那一輪若還有沒回報的留言,照樣印出來再退出,退出碼仍是 `0`。`pr-status` 對不存在的 PR 會印「? none none」並正常結束,所以 state 只認 `open` 與 `closed`、merged 只認 `true` 與 `false`,對不上就回 `3`,PR 編號打錯不會變成永遠輪詢一支不存在的 PR。

Who:`jsc-sdlc:implement` 開完工作包 PR 之後的等待合併,以及任何要盯 PR 的技能。
2026-08-27 11:20:27 +08:00
jiantw83 c3c810c97b feat(gitea): 新增 pr-get 與 pr-edit 兩個 PR 子指令
What:`tools/gitea.sh` 新增 `pr-get` 與 `pr-edit`。`pr-get` 印出一支 PR 的標題、base 分支與描述,前三行固定 `title`、`base`、`body` 三個標記,第 4 行起是描述原文;`pr-edit` 從檔案讀描述,一次更新標題與描述。

Why:分支已經有 PR 時,每次認可之後都要比對標題、描述與前置 PR 依賴三項,有差才更新。原本只有 `pr-create` 與 `pr-status`,讀不到既有 PR 的標題與描述,比不了就只能重開一支 PR 或整支蓋掉,兩種都會把審查中的討論打斷。

How:`pr-get` 把唯一會多行的描述放在輸出最後一段,呼叫端用 `head -n1`、`sed -n 2p`、`tail -n +4` 就取得到三個欄位,全程不必解析 JSON。`pr-edit` 的描述走檔案而不走參數,多行內容與全形標點才裝得下;描述檔不存在回 `2`,API 呼叫失敗回 `4`,成功印 `OK {owner}/{repo}#{編號}`。

Who:`jsc-git:pr` 的既有 PR 校準步驟,以及任何要先比對 PR 現況再決定改不改的呼叫端。
2026-08-27 11:20:27 +08:00
admin 9d6f7cdd37 Merge pull request 'release: develop 併入 master(CHECK、REPORT 頁類型)' (#21) from develop into master
Reviewed-on: #21
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 02:59:31 +00:00
admin f9537d9981 Merge pull request 'feat/gitea-check-report-wiki-types' (#20) from feat/gitea-check-report-wiki-types into develop
Reviewed-on: #20
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 02:51:14 +00:00
jiantw83 6ce2822147 feat(wiki): 支援 CHECK 與 REPORT 兩種頁面類型
What: gitea.sh 的 wiki-repo 白名單加入 CHECK 與 REPORT,check-wiki-rules.sh 的驗證涵蓋範圍從九種擴到十一種,wiki 技能的 description 補上兩個新呼叫者。
Why: 執行環境體檢(jsc-cli:doctor)與工作報表(jsc-log:report)都要寫 wiki,但頁面類型不在白名單裡就解析不出存取庫,兩支技能都寫不進去。
How: 只動白名單與驗證清單,解析規則本身不變:一律先讀 JSC_WIKI_REPO_{TYPE},再退回 JSC_WIKI_REPO,不得跨類型代用。
Who: 體檢頁與報表頁的 wiki 位置解析。
2026-08-26 10:45:34 +08:00
admin 81de5b1e1e Merge pull request 'gitea 0.1.3 發佈:wiki 轉議題、HTML 匯出與範本風格設定' (#19) from develop into master
Reviewed-on: #19
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 01:34:01 +00:00
admin 5b3aa3959b Merge pull request 'feat(gitea): wiki 轉議題、HTML 匯出與範本風格設定' (#18) from feat/wiki-to-issue-and-html-export into develop
Reviewed-on: #18
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 01:30:07 +00:00
jiantw83andClaude Opus 5 dbe800d7a7 chore(gitea): manifest 描述補上議題轉換與 HTML 匯出
What:三份 plugin manifest 的 description 補上這次新增的兩類能力。

Why:description 是各 CLI 顯示這個 plugin 用途的依據,少了新能力就找不到人用。

How:三份同步改成同一句;marketplace 正本的條目描述留給下一次 skill-check 統一處理,因為那份要同時改到十一個存取庫的副本。

Who:在 CLI 裡挑 plugin 的人。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:22:00 +08:00
jiantw83andClaude Opus 5 dc17907554 chore(gitea): markdown 渲染子命令與文件、manifest 同步
What:gitea.sh 新增 markdown 子命令與可切換的 Content-Type,README 補上新工具、新技能與 HTML 範本說明,三份 manifest 同步升版到 0.1.3。

Why:markdown 轉 HTML 要交給 Gitea 自己渲染,排版才跟 wiki、議題頁一致;但 /markdown 端點在 Gitea 1.27 回 200 卻是空內容,看起來像成功。

How:改走吃純文字的 /markdown/raw,req 送出的 Content-Type 改由 REQ_CONTENT_TYPE 決定,預設仍是 application/json。代價寫進註解:raw 端點不吃 wiki 情境,[[頁名]] 要由呼叫端先換成絕對網址。

Who:所有透過 jsc-gitea 產生文件的技能。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:19:25 +08:00
jiantw83andClaude Opus 5 a0c13f807a feat(gitea): 新增 HTML 匯出與範本風格設定
What:新增 html-export 與 html-style 兩支技能、tools/html-render.sh 與 tools/html-style.sh 兩支工具,以及六種版型乘五種風格的 HTML 範本。

Why:wiki 頁與議題要拿給 Gitea 以外的人看時,只能複製 markdown;不同類型的文件也該有各自的版面,不是每份都長一樣。

How:版型(report、slide、dashboard、spec、timeline、onepager)決定內容怎麼排,風格(minimal、corporate、dark、print、vivid)決定看起來長怎樣,兩者自由搭配。哪一種頁面套哪一組由設定決定:專案的 .jsc/html-styles 優先,其次 $JSC_HOME/html-styles.conf,對不到退 DEFAULT,再對不到才用內建的 report/minimal。產出是單一 HTML 檔,CSS 與腳本全部內嵌。

Who:需要把 wiki 頁或議題寄給客戶、主管或跨團隊同事的人。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:19:25 +08:00
jiantw83andClaude Opus 5 f8123e829b feat(gitea): 新增 wiki 轉議題技能
What:新增 wiki-to-issue 技能,把一頁 wiki 轉成同一個存取庫的議題;配套新增 tools/gitea-link.sh 解析連結、tools/issue.sh 讀寫議題。

Why:wiki 頁要變成可追蹤的工作時,原本得自己拼 API:組 JSON、查標籤 id、挑回應欄位,跳脫一錯就把對外的議題內容寫壞。

How:連結是唯一入口,gitea-link.sh 解不出 wiki 頁就中止,不猜存取庫也不猜頁名;標籤只從既有的挑;站台沒有專案看板 API 時據實回報請使用者手動拖,不假裝關聯成功。

Who:使用 jsc 技能組、需要把 wiki 內容交辦出去的人。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:19:08 +08:00
admin 8f94069ceb Merge pull request 'fix/skillset-audit-compliance-and-guard-fixes' (#17) from fix/skillset-audit-compliance-and-guard-fixes into develop
Reviewed-on: #17
2026-08-25 07:14:54 +00:00
jiantw83andClaude Opus 5 768ae6a9fe chore(gitea): 三份 manifest 同步升版並同步 marketplace 正本
What:三份 plugin manifest 版本同步 bump,兩份 marketplace 檔與 plugins/meta 正本對齊。

Why:準則要求技能異動必須同步升版;marketplace 副本必須與正本完全一致。

How:以 jsc-meta 的 tools/sync-skill-manifest.sh 升版,marketplace 檔由正本複製。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 3a2eb40bef docs(gitea): 同步文件與參考資料
What:更新 README、AGENTS.md、templates 與 references,讓文件敘述與實際行為一致。

Why:稽核發現多處文件與程式行為分歧,違反「每個意義只有單一真實來源」。

How:以實際程式行為為準改寫敘述,重複的規則收成單一來源並以一行指引指過去。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 36fc4de73c fix(gitea): 補齊稽核缺失並修掉護欄失效
What:依 jsc-meta:skill-check 的稽核結果修正技能與工具——補上每個步驟的可檢核完成條件、
把留在內文的標準輸入輸出流程下放 tools/、修正查表與退碼路由造成的誤判。

Why:稽核發現這些缺失會讓技能在實際執行時走錯分支或靜默通過。
完成條件缺漏是最常被違反的一項;退碼誤判與查表錯誤則會讓良性狀況被當成失敗。

How:逐項對照 references/guidelines.md 的審核檢查清單修正,新增的工具都有
documented exit codes,並以真實執行驗證每條路徑。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
admin 861f1a0ed2 Merge pull request '發佈 jsc-gitea 0.1.0:新增 pr-status 與 pr-comments' (#16) from develop into master
Reviewed-on: #16
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 04:12:13 +00:00
admin 19eb29fecf Merge pull request '發佈 jsc-gitea 0.0.9' (#15) from develop into master
Reviewed-on: #15
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 03:34:40 +00:00
42 changed files with 4168 additions and 145 deletions
+11 -3
View File
@@ -13,6 +13,14 @@
}, },
"description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)" "description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)"
}, },
{
"name": "jsc-assist",
"source": {
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/assist.git"
},
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_* wiki 頁)"
},
{ {
"name": "jsc-cli", "name": "jsc-cli",
"source": { "source": {
@@ -43,7 +51,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git" "url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
}, },
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄" "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
}, },
{ {
"name": "jsc-log", "name": "jsc-log",
@@ -75,7 +83,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git" "url": "https://gitea.jsc.idv.tw/plugins/review.git"
}, },
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組" "description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
}, },
{ {
"name": "jsc-sdlc", "name": "jsc-sdlc",
@@ -83,7 +91,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git" "url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
}, },
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)" "description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
} }
] ]
} }
+11 -3
View File
@@ -13,6 +13,14 @@
}, },
"description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)" "description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)"
}, },
{
"name": "jsc-assist",
"source": {
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/assist.git"
},
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_* wiki 頁)"
},
{ {
"name": "jsc-cli", "name": "jsc-cli",
"source": { "source": {
@@ -43,7 +51,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git" "url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
}, },
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄" "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
}, },
{ {
"name": "jsc-log", "name": "jsc-log",
@@ -75,7 +83,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git" "url": "https://gitea.jsc.idv.tw/plugins/review.git"
}, },
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組" "description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
}, },
{ {
"name": "jsc-sdlc", "name": "jsc-sdlc",
@@ -83,7 +91,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git" "url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
}, },
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)" "description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
} }
] ]
} }
+10 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "jsc-gitea", "name": "jsc-gitea",
"version": "0.1.0", "version": "0.2.5",
"description": "Gitea API 工具、Wiki 讀寫與存取庫批次同步", "description": "Gitea API 工具、Wiki 讀寫、議題轉換、HTML 匯出與存取庫批次同步",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
"name": "JSC" "name": "JSC"
@@ -13,5 +13,12 @@
"gitea", "gitea",
"skills", "skills",
"cross-tool" "cross-tool"
] ],
"jsc": {
"requires": {
"jsc-ask": ">=0.0.6",
"jsc-git": ">=0.0.9",
"jsc-meta": ">=0.2.2"
}
}
} }
+10 -3
View File
@@ -1,6 +1,13 @@
{ {
"name": "jsc-gitea", "name": "jsc-gitea",
"version": "0.1.0", "version": "0.2.5",
"description": "Gitea API 工具、Wiki 讀寫與存取庫批次同步", "description": "Gitea API 工具、Wiki 讀寫、議題轉換、HTML 匯出與存取庫批次同步",
"skills": "./skills" "skills": "./skills",
"jsc": {
"requires": {
"jsc-ask": ">=0.0.6",
"jsc-git": ">=0.0.9",
"jsc-meta": ">=0.2.2"
}
}
} }
+3 -2
View File
@@ -1,6 +1,6 @@
# jsc-gitea — 給 AI 助理的指引 # jsc-gitea — 給 AI 助理的指引
本 repo 是 jsc 技能組的 `gitea` domain(Gitea API 工具、Wiki 讀寫與存取庫批次同步),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。 本 repo 是 jsc 技能組的 `gitea` domain(Gitea API 工具、Wiki 讀寫、議題轉換、HTML 匯出與存取庫批次同步),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
## 規則 ## 規則
@@ -8,7 +8,8 @@
2. 技能位於 `skills/{name}/SKILL.md`;處理任務前先比對需求與各技能的 `description`,相符就載入並依其步驟執行。 2. 技能位於 `skills/{name}/SKILL.md`;處理任務前先比對需求與各技能的 `description`,相符就載入並依其步驟執行。
3. 技能準則的唯一來源:`plugins/meta` 存取庫的 `references/guidelines.md`。 3. 技能準則的唯一來源:`plugins/meta` 存取庫的 `references/guidelines.md`。
4. 所有 hook 只放在 `jsc-hooks`;gitea 操作一律經由 `jsc-gitea` 的 `tools/gitea.sh`;問使用者一律依 `jsc-ask:ask` 的決策樹規則。 4. 所有 hook 只放在 `jsc-hooks`;gitea 操作一律經由 `jsc-gitea` 的 `tools/gitea.sh`;問使用者一律依 `jsc-ask:ask` 的決策樹規則。
5. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。 5. 盯 PR 等合併用 `tools/pr-watch.sh`,不要自己寫輪詢迴圈。它回 10 就代表有新留言,把留言接回決策樹處理完,再重新盯一次。
6. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。
## 呼叫慣例 ## 呼叫慣例
+140 -11
View File
@@ -27,20 +27,131 @@ gitea.sh owners # 列出可讀取的 owner
gitea.sh repos <owner> # 列出 owner 的 repo 全名 gitea.sh repos <owner> # 列出 owner 的 repo 全名
gitea.sh default-branch <owner>/<repo> gitea.sh default-branch <owner>/<repo>
gitea.sh clone-url <owner>/<repo> gitea.sh clone-url <owner>/<repo>
gitea.sh hash-id <text> # 產生 8 碼大寫 SHA-1;首碼 0-9/A/B/C 時改成 Hxxxxxxx gitea.sh hash-id <text> # 產生完整 40 碼大寫 SHA-1
gitea.sh wiki-repo <TYPE> # 解析頁面類型的 wiki 位置(TYPE = QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR) gitea.sh wiki-repo <TYPE> # 解析頁面類型的 wiki 位置(TYPE = QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR / CHECK / REPORT / SKILLSET / TOOLING / MONITOR / CONTENTS)
# 所有 *_CONTENTS 頁一律走 CONTENTS,內容頁走自己的型別
gitea.sh wiki-list <owner>/<repo> gitea.sh wiki-list <owner>/<repo>
gitea.sh wiki-get <owner>/<repo> <page> # 不存在 exit 4 gitea.sh wiki-get <owner>/<repo> <page> # 不存在 exit 4
gitea.sh wiki-put <owner>/<repo> <page> <file> # 自動判斷新建或更新 gitea.sh wiki-put <owner>/<repo> <page> <file> # 自動判斷新建或更新;會先要求確認
gitea.sh wiki-url <owner>/<repo> <page> # 印出 wiki 頁絕對網址(取自 API 的 html_url);跨存取庫連結用 gitea.sh wiki-delete <owner>/<repo> <page> # 刪除 wiki 頁;與 wiki-put 走同一道確認,頁不存在 exit 4
gitea.sh pr-create <owner>/<repo> <head> <base> <title> <body-file> gitea.sh wiki-url <owner>/<repo> <page> # 印出 wiki 頁絕對網址(取自 API 的 html_url)
# 連結一律寫成 [文字](絕對網址),網址就取自這裡
gitea.sh pr-create <owner>/<repo> <head> <base> <title> <body-file> # 會先要求確認
gitea.sh pr-status <owner>/<repo> <pr-index> # 印出 {state} {merged} {mergeable} gitea.sh pr-status <owner>/<repo> <pr-index> # 印出 {state} {merged} {mergeable}
gitea.sh pr-comments <owner>/<repo> <pr-index> # 所有留言(issue/審查/行內),依時間排序 gitea.sh pr-get <owner>/<repo> <pr-index> # 印出 PR 的標題、base 分支與描述,供呼叫端比對有沒有差
# 前三行固定 title、base、body 三個標記,第 4 行起是描述原文
gitea.sh pr-of-branch <owner>/<repo> <branch> # 印出該分支目前開啟中的 PR(比對 head.ref)
# 第 1 行 number,接著 title、base、body 三個標記,第 5 行起是描述原文
# 結束碼 0=找到、2=用法錯誤、3=該分支沒有開啟中的 PR、4=API 失敗
# 「沒有 PR」用 3 不用 4:混用會把金鑰失效讀成沒開過 PR,接著開出重複的 PR
gitea.sh pr-edit <owner>/<repo> <pr-index> <title> <body-file> # 會先要求確認
# 更新 PR 的標題與描述;描述從檔案讀,裝得下多行
gitea.sh pr-comments <owner>/<repo> <pr-index> # 印出所有留言(issue 留言、審查評語、行內留言),第三欄帶 #id,依時間排序
gitea.sh comment-reply <owner>/<repo> <pr-index> <issue|review|inline> <comment-id> <body-file> # 會先要求確認
# 回覆本輪處理過的 PR 留言;inline 走 review comment reply,其餘補一則 PR 留言
gitea.sh pr-depend <owner>/<repo> <pr-index> <dep-owner>/<dep-repo> <dep-index> # 會先要求確認
# 把 PR 掛上前置 PR 依賴;依賴未關閉前 Gitea 會阻擋合併
gitea.sh repo-set <owner>/<repo> <description> [website] # 會先要求確認
gitea.sh markdown <file> # markdown 檔渲染成 HTML 片段(走 /markdown/raw)
gitea.sh api <METHOD> <path> [json-file] gitea.sh api <METHOD> <path> [json-file]
# 全腳本共用結束碼: 0=成功、1=api 子命令請求失敗、2=用法錯誤
# 3=wiki-repo 沒設定或 pr-of-branch 查無 PR、4=找不到(404)與 PR 系列 API 失敗
# 5=wiki 頁沒有 html_url、7=金鑰失效或權限不足(401/403)、8=其他 API 失敗
# 7 與 8 一定要跟 4 分開:認證失敗若被讀成「頁面不存在」,
# 附加寫入就會變成整頁覆蓋,舊紀錄直接消失
hash-id <text> # 與 gitea.sh hash-id 相同 hash-id <text> # 與 gitea.sh hash-id 相同
check-wiki-rules.sh # 驗證 wiki repo 解析與 hash fallback 規則 # 結束碼 0=成功、1=沒有 sha1sum 也沒有 shasum、2=沒給文字或給了空字串
page-name.sh regex | check <page> # 頁名樣式的唯一正本
# 合法頁名 {型別}_(CONTENTS|8 碼|H 加 7 碼|40 碼大寫十六進位)
# 8 碼與 H 加 7 碼留給尚未遷移的舊頁;舊演算法多數情況會加 H,兩種都要收
# 前綴只收十四種內容型別。CONTENTS 只解存取庫,沒有 CONTENTS_CONTENTS 這一頁
# 結束碼 0=合法、1=不合法、2=用法錯誤
wiki-contents.sh upsert <TYPE> <key-col> <key> <entry-file> [template-file]
# 目錄頁的區塊 upsert:解 CONTENTS 存取庫、讀舊頁、換掉「## {key}」那一塊或附加到頁尾、整頁寫回
# 目錄頁一筆一個 H2 區塊:標題就是內容頁頁名,欄位是底下一層條列「- {欄位名}:{值}」
# entry-file 放整個 H2 區塊;舊頁還是表格時先整頁轉成區塊,再做 upsert
# key-col 只給轉檔用:舊表格裡持有身分那一欄的序號,1 起算,頁面已是條列就忽略
# 轉檔的 H2 標題只看那一格:有連結取網址最後一段路徑(百分號編碼先解碼),沒連結取純文字
# 連結文字常常是工作包或計畫名稱不是頁名;拿它當標題會跟呼叫端的鍵對不上,同一筆長出第二個區塊
# 轉檔那一次有給範本,就連 H1 與「>」引言一起換成範本那一份:只搬表格不動散文,舊引言會一直講「一列一筆」
# 頁面已是條列就不動引言,那時只是更新自己那一筆;沒給範本也保留舊引言,沒有正本可換
# 用範本建新頁時剝掉示範區塊,只留 H1 與引言,正式頁上不留佔位的死紀錄
# 結束碼 0=已更新或已新增、1=組不出頁面內容或寫入失敗、2=用法錯誤、3=CONTENTS 存取庫未設定
# 4=頁不存在且沒給範本、7=金鑰失效或權限不足、8=其他 API 失敗
# 只有 4 才准建新頁;7 與 8 一律中止,不得當成「頁面不存在」
wiki-contents.sh format <key-col> <key> <entry-file> <old-file> <new-file> [template-file|--fresh]
# 同一份轉檔與 upsert 判斷,只讀寫檔案、不碰 API,供離線驗證用
# 第六個參數給 --fresh 是「old-file 就是範本」,給路徑則等同 upsert 的範本檔
migrate-wiki.sh [--apply] [--key <候選鍵>]... # 把舊頁搬到新規則:目錄頁換存取庫、內容頁換頁名
# 只做正推配對,配不上的一律列成孤兒,不猜;不帶 --apply 只印對照表
# 寫目的地之前先讀:只有 4 才准寫,0 列成需人工確認且不覆蓋,7 與 8 中止
# 連結改寫只動被搬的頁;沒被搬的引用方另列一節,交給人改
# 結束碼 0=全部搬完、1=有頁搬移失敗、2=用法錯誤、3=有需人工處理的項目
repo-sync.sh <owner>/<repo> [target-dir] # 同步單一存取庫;印出 cloned、updated、dirty {分支} 或 failed {原因}
# 基準分支的優先序只在這支腳本裡;dirty 會把解析好的分支帶出來當 PR 的 base
check-wiki-rules.sh # 驗證 wiki repo 解析、hash-id 與頁名樣式規則
check-contents-format.sh # 驗證目錄頁的區塊轉檔與 upsert 規則;全走 format 子命令,不打 API
link-check.sh <網址>... # 連結寫進文件之前先驗證連得到;也吃標準輸入,一行一個
# 每個網址一行「{OK|DEAD|SKIP}<TAB>{網址}<TAB>{說明}」
# wiki 頁與議題轉成 API 查,其他 Gitea 網址帶金鑰 HEAD,外部網址不帶金鑰 HEAD
# Gitea 一律走 API:私有存取庫的網頁網址對未登入請求一律回 404
# 結束碼 0=全部連得到、1=有連不到、2=沒給網址、3=有 Gitea 網址但 GITEA_HOST 未設定
# 7=Gitea 認證失敗(401/403)
# 7 一定要與 1 分開:金鑰失效與「頁不存在」難分辨,混用會把還在的頁整批判成死連結
pr-watch.sh <owner>/<repo> <pr-index> [state-file]
# 盯著一支 PR,直到它合併或關閉,不自動逾時退場
# 退出碼 0 = 已合併或關閉、10 = 有新留言要接手、2 = 參數錯誤、3 = 查不到該 PR
# 已回報過的留言記在狀態檔,預設 $JSC_HOME/pr-watch/{owner}-{repo}-{index}.seen
``` ```
`pr-watch.sh` 的退出碼 10 是設計重點:腳本只負責偵測,決策交回呼叫端。收到 10 就把印出來的留言丟進決策樹,處理完再重新盯一次。
議題與 HTML 產出:
```
gitea-link.sh parse <url> # 解析 wiki 或議題連結;不是這兩種就 exit 3(呼叫端據此中止)
issue.sh labels|label-ids|projects <owner>/<repo>
issue.sh title|body|labels-of <owner>/<repo> <index>
issue.sh show <owner>/<repo> <index> # 一次拿齊標題、標籤與正文,只打一次 API
# 四段固定格式:title、labels、body 標記,第 4 行起是正文
issue.sh create <owner>/<repo> <title> <body-file> [--labels <ids>] [--milestone <id>] # 會先要求確認
html-style.sh get|set|unset|list|layouts|styles # 種類對版型與風格的設定
html-style.sh key wiki <頁名> # 推導種類:取第一個底線前的字首
html-style.sh key issue <owner>/<repo> <編號> [--labels <名稱>[,<名稱>]]
# 推導種類:依序試每個標籤,第一個設定過的勝出,都沒有就回 ISSUE:DEFAULT
# 輸出一行「{種類}<TAB>{依據}」;結束碼 1=取不到標籤
html-render.sh --markdown <檔案> --title <標題> --out <輸出檔> [--layout] [--style] [--subtitle] [--source-url]
```
## 參考資料
- `references/wiki-links.md`:連結規則。規則 A:一律寫成 `[文字](絕對網址)`,網址取自 `wiki-url`,只有這一種寫法。規則 B:連結寫進文件之前先過 `link-check.sh`,結束碼 0 才寫入。
## HTML 範本
版型(`templates/html/layout/*.html`)決定內容怎麼排,風格(`templates/html/style/*.css`)決定看起來長怎樣。兩者自由搭配,六乘五共三十種。
| 版型 | 內容排法 |
| --- | --- |
| `report` | 左側目錄加章節內文,長文件用 |
| `slide` | 一個 `##` 一張投影片,鍵盤左右鍵翻頁 |
| `dashboard` | 每個 `##` 一張卡片並排 |
| `spec` | 表格表頭固定、程式碼區塊放大,API 文件用 |
| `timeline` | 每個 `##` 一個節點串成一條線 |
| `onepager` | 窄欄單頁,印出來剛好一頁 |
| 風格 | 視覺 |
| --- | --- |
| `minimal` | 白底細線、無襯線,資訊密度優先 |
| `corporate` | 深藍主色、表頭反白,正式對外 |
| `dark` | 深底亮字 |
| `print` | 襯線字、A4 邊界,列印或轉 PDF |
| `vivid` | 高彩度、圓角卡片、漸層標題 |
哪一種頁面套哪一組,由 `html-style.sh` 的設定決定:專案的 `./.jsc/html-styles` 優先,其次 `$JSC_HOME/html-styles.conf`,種類對不到就退 `DEFAULT`,再對不到才用內建的 `report`/`minimal`。設定的 key 是 `WIKI:{頁名前綴}`、`ISSUE:{標籤名}` 或 `DEFAULT`。
自訂範本:版型放進 `templates/html/layout/`,風格放進 `templates/html/style/`,檔案第一行寫一句繁中說明——那句話就是技能問使用者時顯示的選項說明。版型檔可用的佔位有 `{{TITLE}}`、`{{SUBTITLE}}`、`{{CONTENT}}`、`{{BASE}}`、`{{BASE_JS}}`、`{{STYLE}}`、`{{SOURCE}}`、`{{GENERATED}}`、`{{LAYOUT}}`、`{{STYLE_NAME}}`。
## Skills 目錄 ## Skills 目錄
呼叫方式:Claude / Antigravity `/jsc-gitea:{name}`;Codex `${name}`;Copilot / Kiro 描述需求自動觸發。 呼叫方式:Claude / Antigravity `/jsc-gitea:{name}`;Codex `${name}`;Copilot / Kiro 描述需求自動觸發。
@@ -49,11 +160,23 @@ check-wiki-rules.sh # 驗證 wiki repo 解析與 ha
### `wiki` ### `wiki`
Gitea wiki 頁讀寫的統一入口:依頁面類型(QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR)解析 wiki 所在的 `{owner}/{repo}`,先讀對應的 `JSC_WIKI_REPO_{TYPE}`,再退回 `JSC_WIKI_REPO`,不同類型不可互相代用。頁面內容以圖表優先(mermaid 圖、markdown 表格),純文字為最後手段。 Gitea wiki 頁讀寫的統一入口:依頁面類型(QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR / CHECK / REPORT / SKILLSET / TOOLING / MONITOR / CONTENTS)解析 wiki 所在的 `{owner}/{repo}`,先讀對應的 `JSC_WIKI_REPO_{TYPE}`,再退回 `JSC_WIKI_REPO`,不同類型不可互相代用。所有 `*_CONTENTS` 頁一律走 `CONTENTS` 這個型別,內容頁走自己的型別;目錄頁的區塊 upsert 交給 `tools/wiki-contents.sh`,一筆紀錄一個 H2 區塊,標題就是內容頁頁名,欄位是底下一層條列。內容頁的內容以圖表優先(mermaid 圖、markdown 表格),純文字每節最多三句;目錄頁不放表格。
### `repo-sync` ### `repo-sync`
存取庫批次同步:列出 owner → 使用者選擇 → 逐 repo(sub agent)clone 或切 develop/master 更新;有變更就開分支 commit、push、PR。 存取庫批次同步:列出 owner → 使用者選擇 → 各存取庫併行(一個 repo 一個 sub agent)呼叫 `tools/repo-sync.sh` clone 或更新;回報 `dirty {分支}` 的存取庫交給 `jsc-git:pr`,base 直接用腳本帶出來的那個分支;PR 依 `jsc-meta/references/pr-report.md` 集中成表。
### `html-export`
把一頁 wiki 或一筆議題輸出成單一 HTML 檔:解析連結 → 判斷種類 → 查該種類的版型與風格 → 用 Gitea 自己的 markdown 渲染出圖。CSS 與腳本全部內嵌,檔案拿到哪裡都打得開。**沒有連結就直接中止**,不猜存取庫、不猜頁名、不猜議題編號。
### `html-style`
設定「哪一種 wiki 頁或議題,出 HTML 時用哪一種版型與風格」:六種版型與五種風格全部列給使用者選,再寫進專案的 `.jsc/html-styles` 或全域的 `$JSC_HOME/html-styles.conf`。`html-export` 讀的就是這份設定。
### `wiki-to-issue`
把一頁 wiki 轉成同一個存取庫的議題:讀頁面 → sub agent 起草標題與正文(開頭附來源連結)→ 從既有標籤挑合適的 → 關聯專案看板 → 建立議題。標籤只從存取庫既有的挑,不自己發明;站台沒有看板 API 時據實回報請使用者手動拖,不假裝關聯成功。**沒有連結就直接中止**。
<!-- JSC-SKILLS:END --> <!-- JSC-SKILLS:END -->
@@ -63,12 +186,18 @@ Gitea wiki 頁讀寫的統一入口:依頁面類型(QUESTION / PLAN / ANALYZ
| --- | --- | --- | | --- | --- | --- |
| `GITEA_HOST` | Gitea 站台(可省略 scheme,預設 https) | 詢問使用者 | | `GITEA_HOST` | Gitea 站台(可省略 scheme,預設 https) | 詢問使用者 |
| `GITEA_TOKEN` | Gitea API token;缺少或遇 401/403 時自動退回 tea CLI 登入 token | 詢問使用者 | | `GITEA_TOKEN` | Gitea API token;缺少或遇 401/403 時自動退回 tea CLI 登入 token | 詢問使用者 |
| `JSC_WIKI_REPO_{TYPE}` | 各類型 wiki 頁的 `{owner}/{repo}`;TYPE = QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR | 退回 `JSC_WIKI_REPO`,不做跨類型代用 | | `JSC_WIKI_REPO_{TYPE}` | 各類型內容頁的 `{owner}/{repo}`;TYPE = QUESTION / PLAN / ANALYZE / DELIVER / MAINTAIN / REPO / LOG / LEARN / ERROR / CHECK / REPORT | 退回 `JSC_WIKI_REPO`,不做跨類型代用 |
| `JSC_WIKI_REPO_SKILLSET` | `SKILLSET_{HASH}`(技能組異動報告)的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`,不做跨類型代用 |
| `JSC_WIKI_REPO_TOOLING` | `TOOLING_{HASH}`(技能盤點)的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`,不做跨類型代用 |
| `JSC_WIKI_REPO_MONITOR` | `MONITOR_{HASH}`(助理巡檢監控)的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`,不做跨類型代用 |
| `JSC_WIKI_REPO_CONTENTS` | 全部目錄頁(各型別的 `*_CONTENTS`)共用的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`,不退回各型別的變數 |
| `JSC_WIKI_REPO` | 共用預設的 wiki `{owner}/{repo}` | 詢問使用者 | | `JSC_WIKI_REPO` | 共用預設的 wiki `{owner}/{repo}` | 詢問使用者 |
| `JSC_HOME` | `pr-watch.sh` 狀態檔的根目錄 | 預設 `~/.jsc` |
| `JSC_PR_WATCH_INTERVAL` | `pr-watch.sh` 的輪詢間隔秒數(正整數) | 預設 60 |
## Hash 規則 ## Hash 規則
`{HASH}` 由 `tools/hash-id` 產生。先算 `SHA-1` 前 8 碼並轉大寫。若首碼是數字或 `A`、`B`、`C`,就改成 `H` 加上原本的前 7 碼,維持 8 碼長度。 `{HASH}` 一律由 `tools/hash-id` 產生:完整 40 碼大寫十六進位,不截短、不加前綴。頁名合不合規則交給 `tools/page-name.sh check` 判,不要另外寫一份樣式。各頁型的雜湊來源,唯一來源是 `jsc-meta` 的 [`references/guidelines.md`](https://gitea.jsc.idv.tw/plugins/meta/src/branch/master/references/guidelines.md)「Wiki 頁命名總表」,這裡不再複述一份。
## 相關 domain ## 相關 domain
+10 -3
View File
@@ -1,6 +1,13 @@
{ {
"name": "jsc-gitea", "name": "jsc-gitea",
"version": "0.1.0", "version": "0.2.5",
"description": "Gitea API 工具、Wiki 讀寫與存取庫批次同步", "description": "Gitea API 工具、Wiki 讀寫、議題轉換、HTML 匯出與存取庫批次同步",
"skills": "./skills/" "skills": "./skills/",
"jsc": {
"requires": {
"jsc-ask": ">=0.0.6",
"jsc-git": ">=0.0.9",
"jsc-meta": ">=0.2.2"
}
}
} }
+53
View File
@@ -0,0 +1,53 @@
# jsc-gitea 技能行為清單
本頁記錄 jsc-gitea 每支技能的行為基準,供技能驗證比對。技能異動時,在同一個 PR 內一起更新這一頁。
## html-export
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 使用者給一條 Gitea wiki 頁連結或議題連結,要把那一頁變成一份離得開 Gitea 的 HTML 檔。請求裡沒有連結就直接停手。不猜存放庫、不猜頁名、不猜議題編號,也不回頭去讀工作目錄的 remote。要改某一類頁面用哪一組範本,走 jsc-gitea:html-style。 |
| 關鍵步驟 | 用 tools/gitea-link.sh parse 解析連結、確認 GITEA_HOST 有值、同一批平行跑三條線、開 sub agent 整理 markdown、用 tools/html-render.sh 產出檔案,最後呼叫 jsc-hooks/tools/report-status.sh skill-end jsc-gitea:html-export 寫下這一輪的結果。三條線分別是:A 取內容,wiki 頁走 jsc-gitea:wiki 的 wiki-get 與 wiki-url,議題走 tools/issue.sh show 一次取回標題、標籤、內文;B 取範本,tools/html-style.sh key 算出 kind key,再用 get 讀出版型、風格、來源三欄;C 問輸出路徑,預設提 ./.jsc/html/{名稱}.html。sub agent 只做兩件事:把舊頁殘留的 wiki 內部連結換成 [文字](絕對網址)、拿掉個資。收尾那一筆走每一條出口,連停在連結閘門那一條也要寫;status 五選一,檔案產出得完整是 ok,主機沒值就停是 blocked,讀頁或渲染中途壞掉是 failed,讀不到標籤而改用沒帶標籤的範本是 degraded,請求裡沒有連結或使用者中止是 aborted。腳本不在這台機器就安靜跳過,回報失敗不得改變這支技能的結果。 |
| 外部呼叫 | tools/gitea-link.sh、tools/html-style.sh、tools/html-render.sh、tools/issue.sh、jsc-gitea:wiki、jsc-ask:ask、jsc-hooks/tools/report-status.sh skill-end、Gitea 的 wiki API、Gitea 的議題 API、Gitea 的 markdown 渲染 API。 |
| 完成條件 | 檔案已經寫出來。回報裡有檔案路徑、版型名稱、風格名稱,以及這一組是從哪裡來的。來源是 default 或 builtin 時,回報要多一行告訴使用者可以用 jsc-gitea:html-style 設定。渲染失敗就不留半成品檔,直接停手回報。這一輪還要留下一筆 skill-end 事件,或是腳本不在而略過,兩者都算收好;略過不影響這支技能的結束碼。 |
| 可驗證跡象 | 使用者確認過的輸出路徑多一個 HTML 檔,預設落在 ./.jsc/html/ 底下。這個檔把 CSS 與 JS 內嵌,不外連任何資源,開起來就是完整的一頁。$JSC_HOME/usage/events.jsonl 會多一筆 {kind:skill,phase:end} 事件,name 是 jsc-gitea:html-export,status 與 exit 就是這一輪的結果。不寫 wiki 頁、不建議題、不開 PR,來源頁面本身也不動。 |
## html-style
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 某一類 wiki 頁或議題匯出時要換版型、換風格。或是 jsc-gitea:html-export 回報來源是 builtin 或 default,要補上這一類自己的設定。只是要出一份檔案,走 jsc-gitea:html-export。 |
| 關鍵步驟 | 用 tools/html-style.sh list 列出目前設定、依決策樹敲定一個 kind key、用 layouts 列出全部六種版型讓使用者挑、用 styles 列出全部五種風格讓使用者挑、問這次寫專案還是寫全機、用 set 寫進設定檔、再用 get 讀回來核對來源欄,最後呼叫 jsc-hooks/tools/report-status.sh skill-end jsc-gitea:html-style 寫下這一輪的結果。kind key 只有三種形狀:WIKI:{頁名前綴}、ISSUE:{標籤名}、DEFAULT。版型與風格一律整份列出,不先篩短清單。收尾那一筆走每一條出口,連停在範本目錄不見那一條也要寫;status 五選一,寫進去又讀回來對得上是 ok,範本目錄不見而列不出名字、整支停在問問題之前是 blocked,set 回 1、2 或 4 沒寫成是 failed,set 回 0 但讀回來的來源欄與這次選的範圍不同是 degraded,使用者在四個問題任何一關停手是 aborted。腳本不在這台機器就安靜跳過,回報失敗不得改變這支技能的結果。 |
| 外部呼叫 | tools/html-style.sh 的 list、get、layouts、styles、set、unset 子命令、jsc-ask:ask、jsc-hooks/tools/report-status.sh skill-end。不打任何 Gitea API。 |
| 完成條件 | set 回結束碼 0,並印出它寫的那個檔案。get 讀回來的來源欄與這次選的範圍一致:選 --project 就顯示 project,選 --global 就顯示 global。寫進去的版型名與風格名,都要是腳本列過的名字。這一輪還要留下一筆 skill-end 事件,或是腳本不在而略過,兩者都算收好;略過不影響這支技能的結束碼。 |
| 可驗證跡象 | 設定檔多一列 {種類}={版型},{風格},例如 WIKI:PLAN=report,corporate。選 --project 改的是工作目錄的 ./.jsc/html-styles,這個檔會跟著存放庫一起提交。選 --global 改的是 $JSC_HOME/html-styles.conf,JSC_HOME 預設 ~/.jsc。兩個檔都握有同一個 key 時,專案檔贏。$JSC_HOME/usage/events.jsonl 會多一筆 {kind:skill,phase:end} 事件,name 是 jsc-gitea:html-style,status 與 exit 就是這一輪的結果。不產 HTML 檔、不動 Gitea。 |
## repo-sync
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 要把某一個 Gitea 擁有者底下讀得到的存放庫,一次全部拉到工作目錄。開新工作環境、或整批更新既有存放庫時用。只同步一個存放庫時不用這支。 |
| 關鍵步驟 | 用 tools/gitea.sh owners 列出讀得到的擁有者、請使用者挑一個、用 tools/gitea.sh repos 列出該擁有者底下的存放庫、同一批平行開 sub agent 一個存放庫一個、每個 sub agent 跑 tools/repo-sync.sh 並依它那一行輸出分流、把每個存放庫的結果彙整回報,最後呼叫 jsc-hooks/tools/report-status.sh skill-end jsc-gitea:repo-sync 寫下這一輪的結果。分流有四種:cloned 與 updated 就算完成,dirty {分支} 把那個分支當 base 交給 jsc-git:pr,failed {原因} 記下原因並停掉這個存放庫。複製或拉取的判斷、基準分支的優先序,都由腳本決定,不自己下 git clone、git checkout、git pull。收尾那一筆走每一條出口,連停在列不出擁有者那一條也要寫;status 五選一,每個存放庫都同步完是 ok,主機或金鑰沒值、或這把金鑰一個擁有者都讀不到而整輪沒動到任何存放庫是 blocked,列清單回 7 或 8、或每個存放庫都 failed 是 failed,有的成功有的 failed 是 degraded,使用者沒挑擁有者或中途停手是 aborted。detail 只放筆數,逐個存放庫的清單留在回報裡。腳本不在這台機器就安靜跳過,回報失敗不得改變這支技能的結果。 |
| 外部呼叫 | tools/gitea.sh 的 owners、repos、clone-url、tools/repo-sync.sh、jsc-git:pr、jsc-ask:ask、jsc-hooks/tools/report-status.sh skill-end、Gitea 的擁有者與存放庫清單 API,以及腳本內部的 git clone、git fetch、git pull。 |
| 完成條件 | 清單上的每一個存放庫都拿到一種結果:cloned、updated、一列 PR 表格,或失敗原因。一個存放庫失敗不取消其他存放庫。有多條 PR 時,全部併進同一張表。這一輪還要留下一筆 skill-end 事件,或是腳本不在而略過,兩者都算收好;略過不影響這支技能的結束碼。 |
| 可驗證跡象 | 工作目錄底下多出或更新了各存放庫的目錄。新的是 git clone 的結果,既有的已經快轉到基準分支。原本有未提交變更的存放庫,會多一條推上去的分支,以及一條開在 Gitea 上的 PR,PR 網址寫在回報表格裡。$JSC_HOME/usage/events.jsonl 會多一筆 {kind:skill,phase:end} 事件,name 是 jsc-gitea:repo-sync,status 與 exit 就是這一輪的結果。不寫 wiki 頁、不建議題。 |
## wiki
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 技能組裡任何一次 wiki 頁的讀、寫、刪或搬移,都經過這一支。呼叫端有 jsc-ask、jsc-sdlc、jsc-log、jsc-hooks 的 ERROR 頁、jsc-cli 的 CHECK 頁、jsc-meta 的 SKILLSET 與 TOOLING 頁、jsc-assist 的 MONITOR 頁。要在目錄頁登記一筆紀錄,也走這一支。存放庫裡的程式碼檔案不歸這一支管。 |
| 關鍵步驟 | 確認 GITEA_HOST 有值、用 tools/gitea.sh wiki-repo {TYPE} 解出 {owner}/{repo}、用 tools/hash-id 算頁名要用的 HASH、用 tools/page-name.sh check 驗過頁名、再依動作跑 wiki-list、wiki-get、wiki-put、wiki-delete 或 wiki-url,每一次呼叫都照結束碼表分流。所有 *_CONTENTS 頁一律走型別 CONTENTS,內容頁走自己的型別;存放庫的解法是先讀 JSC_WIKI_REPO_{TYPE}、再讀 JSC_WIKI_REPO、兩個都沒有才問使用者,而且不借用別的頁型的存放庫。頁面裡的連結一律寫成 [文字](絕對網址),網址取自 wiki-url,不自行組路徑;wiki-put 之前先把這一頁要放的每一條連結交給 tools/link-check.sh,結束碼 0 才寫,1 就不寫並回報 DEAD 那幾筆,7 停下來回報金鑰問題。內容頁的內容以圖表優先,目錄頁不放表格:一筆紀錄一個 H2 區塊,標題就是那一筆對應的內容頁頁名,欄位是標題底下一層條列「- {欄位名}:{值}」。目錄頁的區塊 upsert 交給 tools/wiki-contents.sh,參數是 upsert {TYPE} {key-col} {key} {區塊檔} [範本檔]:它先 wiki-get 讀回舊內容,舊頁還是 markdown 表格就整頁轉成區塊,再找「## {key}」,命中換掉整塊、沒命中附加到頁尾,最後整頁寫回;key-col 只給轉檔認舊表格的身分欄用,頁面已是條列就忽略;轉檔的 H2 標題只看身分欄那一格,有連結就取網址最後一段路徑、百分號編碼先解碼,沒連結才取格子純文字,取到什麼就用什麼,不拿頁名樣式去驗;轉檔那一次呼叫端有給範本,就把第一個「## 」之前的 H1 與「>」引言換成範本那一段,範本的示範區塊不得混進來,因為轉檔只搬表格不動散文,舊引言會一直講「每個存取庫一列」這種只對表格成立的話,頁面已是條列或沒給範本則引言原樣不動。只有結束碼 4 才准用範本建新頁,7 與 8 一律中止;建新頁時範本的示範區塊要剝掉。舊頁搬到新規則走 tools/migrate-wiki.sh,不帶 --apply 只印對照表,寫每一個目的地之前也一樣先 wiki-get 讀一次。收尾一律呼叫 jsc-hooks/tools/report-status.sh skill-end jsc-gitea:wiki 寫下這一輪的結果,走每一條出口,連停在主機閘門那一條也要寫;這支被幾乎每支技能呼叫,只回報這一次 wiki 動作的成敗,不回報呼叫端自己的結果。status 五選一,讀到頁面或寫入落地是 ok,主機沒值、或 wiki-repo 回 3 而使用者沒給 {owner}/{repo}、整輪沒讀也沒寫是 blocked,金鑰失效那個結束碼 7 算 failed 不算頁面不存在、結束碼 8 與 link-check 回 1 擋下寫入也是 failed,內容頁寫成功但目錄頁沒更新(wiki-contents.sh 回 3,或回 1 組不出頁面內容、寫入沒落地)是 degraded、搬移只搬掉一部分也是 degraded,人工確認被否決或頁型不在允許清單而停在 wiki-repo 回 2 是 aborted。detail 只放頁名,不放頁面內容。腳本不在這台機器就安靜跳過,回報失敗不得改變回給呼叫端的結果。 |
| 外部呼叫 | tools/gitea.sh 的 wiki-repo、wiki-list、wiki-get、wiki-put、wiki-delete、wiki-url、tools/hash-id、tools/page-name.sh、tools/link-check.sh、tools/wiki-contents.sh 的 upsert 與 format、tools/migrate-wiki.sh、tools/write-confirm.sh、jsc-ask:ask、jsc-hooks/tools/report-status.sh skill-end、Gitea 的 wiki API 與議題 API。 |
| 完成條件 | 讀取動作拿到頁面內容,或拿到一個講得清楚的結束碼。這一輪還要留下一筆 skill-end 事件,或是腳本不在而略過,兩者都算收好;略過不影響回給呼叫端的結束碼。寫入動作先讓 tools/link-check.sh 回結束碼 0,再通過人工確認、wiki-put 回結束碼 0,而且送出去的是舊內容加上這次的異動,不是整頁覆蓋。目錄頁的異動只動自己那一個 H2 區塊:別人那幾筆一字不變,頁面上不留 markdown 表格,也不出現兩個同名的 H2 標題;只有從表格轉成區塊那一次,且呼叫端有給範本,引言才會換成範本那一份,其餘情況引言與別人那幾筆一起一字不動。連結檢查回 1 就不寫入,回 7 連同整個動作一起中止。wiki-put 的結束碼 7 與 8 一律中止整個動作,不建頁、不寫入、不用原參數重試。搬移動作要嘛全部搬完回 0,要嘛把失敗頁、孤兒頁、目的地已有內容的頁、指向被搬頁卻沒被搬的引用方逐條列出來,這四種一律交給人判斷。 |
| 可驗證跡象 | 目標 wiki 存放庫多一頁或改一頁。內容頁的頁名是 {型別}_{40 碼大寫十六進位},目錄頁是 {型別}_CONTENTS 且落在 JSC_WIKI_REPO_CONTENTS 指的那個存放庫。頁面內容是 UTF-8 繁體中文;內容頁以 mermaid 圖與 markdown 表格為主,散文每節最多三句。目錄頁只有三段:H1 頁名、「>」引言、一筆一個 H2 區塊,區塊之間空一行,H2 與第一條條列之間也空一行,H2 標題就是內容頁頁名而且不帶連結。頁面裡每一條連結都是 [文字](絕對網址),沒有 wiki 內部連結語法,而且每一條在寫入前都被 tools/link-check.sh 判成 OK。wiki-contents.sh 印出一行 updated 或 added 加上 {owner}/{repo}/{頁名}。寫入與刪除前 tools/write-confirm.sh 會各留下一次人工確認。$JSC_HOME/usage/events.jsonl 會多一筆 {kind:skill,phase:end} 事件,name 是 jsc-gitea:wiki,status 與 exit 就是這一次 wiki 動作的結果。只做讀取的呼叫沒有 wiki 寫入跡象,只有回報內容與那一筆事件。 |
## wiki-to-issue
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 一頁 wiki 要變成一條追得動的議題,而且使用者已經給了那一頁的連結。請求裡沒有 wiki 連結就直接停手,不猜存放庫、不猜頁名。連結指向的是議題也停手。從零起草的議題不走這一支,已經存在的議題要同步也不走這一支。 |
| 關鍵步驟 | 同一批檢查連結與主機、平行跑三條線、依決策樹挑標籤、處理看板、最後建議題。連結用 tools/gitea-link.sh parse 解析,要印出 kind=wiki 才算過;GITEA_HOST 要有值。三條線是:A 讀頁面並開 sub agent 起草標題與內文;B 用 tools/issue.sh labels 取這個存放庫既有的標籤清單;C 用 tools/issue.sh projects 取看板清單。內文開頭放一行「來源:{絕對網址}」,連結一律寫成 [文字](絕對網址),舊頁殘留的 wiki 內部連結一併換掉,個資拿掉。標籤只能從既有清單裡挑,確認後用 label-ids 換成 id,缺的標籤不補建。看板 API 不存在時,把腳本印出的看板網址交給使用者自己拖。最後呼叫 jsc-hooks/tools/report-status.sh skill-end jsc-gitea:wiki-to-issue 寫下這一輪的結果,走每一條出口,連停在連結閘門那一條也要寫;status 五選一,議題建好且看板也掛上是 ok,主機沒值就停、整輪沒讀頁也沒起草是 blocked,讀頁回 4、7、8 或取標籤回 1 或 create 回 1 是 failed,議題建好但看板沒掛上(projects 回 3 或掛看板被拒)是 degraded,請求裡沒有 wiki 連結、parse 回 3、連結指向議題,或使用者否決 create 的人工確認是 aborted。detail 放議題編號,不放內文。腳本不在這台機器就安靜跳過,回報失敗不得改變這支技能的結果。 |
| 外部呼叫 | tools/gitea-link.sh、tools/issue.sh 的 labels、projects、label-ids、create、jsc-gitea:wiki、jsc-ask:ask、tools/write-confirm.sh、jsc-hooks/tools/report-status.sh skill-end、Gitea 的 wiki API 與議題 API。 |
| 完成條件 | 議題已經建立,回報裡有議題網址、實際套上的標籤,以及看板狀態。建不成就明講沒有建成,並交出草稿檔的路徑,讓草稿不會白寫。標題、內文、標籤、看板都在呼叫 create 之前先跟使用者確認過。這一輪還要留下一筆 skill-end 事件,或是腳本不在而略過,兩者都算收好;略過不影響這支技能的結束碼。 |
| 可驗證跡象 | 目標存放庫的議題追蹤器多一條議題,create 會印出 index= 與 url= 兩行。$JSC_HOME/usage/events.jsonl 會多一筆 {kind:skill,phase:end} 事件,name 是 jsc-gitea:wiki-to-issue,status 與 exit 就是這一輪的結果。議題內文第一行是「來源:」加上 wiki 頁的絕對網址,內文裡每一條連結都是 [文字](絕對網址),沒有殘留的 wiki 內部連結語法。標籤就是這次確認過的那一組。站台有看板 API 時,看板上多一張卡;沒有時,回報裡留一行待辦說明誰要去補。來源 wiki 頁本身不動。 |
+62
View File
@@ -0,0 +1,62 @@
# Wiki 頁與文件的連結
寫 wiki 頁、議題或存放庫文件都適用這兩條規則。連結寫錯時畫面上看不出異常,所以規則寫在這裡,不靠當下判斷。
## 規則 A:文字加連結一律寫成 `[文字](絕對網址)`
只有這一種寫法。`[[頁名]]` 與 `[[顯示文字|頁名]]` 全面取消,不再分「同存取庫」與「跨存取庫」兩種寫法。
| 情境 | 寫法 |
| --- | --- |
| 目錄頁指向內容頁(例:`PLAN_CONTENTS` → `PLAN_{HASH}`):連結寫在該筆 H2 區塊的條列裡,H2 標題本身只放頁名,不放連結 | `- 計畫頁:[PLAN_{HASH}](https://…/wiki/PLAN_…)` |
| 內容頁指向目錄頁,或指向別的型別 | `[顯示文字](絕對網址)` |
| 目錄頁之間、同型別的內容頁之間 | `[顯示文字](絕對網址)` |
| 議題、PR、存放庫檔案 | `[顯示文字](絕對網址)` |
網址一律取自 `tools/gitea.sh wiki-url {owner}/{repo} {page}`,它讀 API 回應的 `html_url`。不要自己組路徑:頁名有大小寫與編碼規則,手組的路徑看起來像對的,點下去是死的。
## 為什麼取消 `[[...]]`
三個理由,每一個都足以單獨取消它。
1. `[[...]]` 只在目前這個 wiki 內解析。跨存取庫沒有這種語法,連結會落在自己這個 wiki 的同名頁上。
2. 寫錯不會報錯。畫面上是一段普通文字或一條死連結,巡不到也修不了。
3. 目錄頁住在 CONTENTS 專用存取庫,內容頁住在自己型別的存取庫。兩種寫法並存,就得逐處判斷兩端各自解到哪一個存取庫。統一成一種,這個判斷整個消失。
另外還有一項成本:Gitea 的 markdown 渲染端點不吃 wiki 情境,`[[...]]` 會原樣輸出成字面括號。匯出成 HTML 或轉成議題時,每一條都得先換成絕對網址。一律寫絕對網址就沒有這道轉換。
搬移舊頁時 `tools/migrate-wiki.sh` 仍會改寫舊頁裡殘留的 `[[...]]`,那是清理既有內容,不是允許新寫。
## 規則 B:連結先驗證連得到,才寫進文件
寫入前,把每一個要放進頁面的連結交給 `tools/link-check.sh`。結束碼 0 才 `wiki-put`。
```
link-check.sh {網址}...
printf '%s\n' {網址}... | link-check.sh
```
每個網址印一行,三欄以 TAB 分隔:`{OK|DEAD|SKIP}<TAB>{網址}<TAB>{說明}`。
驗證方式依網址種類分流:
| 網址種類 | 驗證方式 |
| --- | --- |
| Gitea wiki 頁(`{GITEA_HOST}/{owner}/{repo}/wiki/{頁名}`) | 轉成 API `/repos/{owner}/{repo}/wiki/page/{頁名}` |
| Gitea 議題(`…/issues/{編號}`) | API `/repos/{owner}/{repo}/issues/{編號}` |
| 其他 Gitea 網址 | HTTP HEAD,帶金鑰 |
| 非 Gitea 的外部網址 | HTTP HEAD,不帶金鑰 |
結束碼:
| 碼 | 意義 | 呼叫端該做的事 |
| --- | --- | --- |
| 0 | 全部連得到 | 才可以寫入 |
| 1 | 至少一筆連不到 | 不得寫入,回報 DEAD 那幾筆 |
| 2 | 用法錯誤:一個網址都沒給 | 補上參數再呼叫 |
| 3 | 清單裡有 Gitea 網址,但 `GITEA_HOST` 未設定 | 先設定再呼叫,不得跳過驗證 |
| 7 | Gitea 認證失敗(401、403) | 停下來回報金鑰問題 |
Gitea 連結一律走 API,不看網頁狀態碼。私有存取庫的網頁網址對未登入請求一律回 404,用網頁狀態碼判斷會把好連結判成壞連結,接著整批砍掉還在的頁。
第 7 碼與第 1 碼分開的理由一樣:金鑰失效時,Gitea 對私有存取庫的回應與「頁不存在」難以分辨。兩者混用,一次金鑰過期就把整批還在的頁判成死連結,接著這些頁會被當成壞連結刪掉或改寫。
+42
View File
@@ -0,0 +1,42 @@
---
name: html-export
description: 'Export one Gitea wiki page or issue as a single self-contained HTML file. Parse the link with tools/gitea-link.sh, resolve that kind''s layout and style through tools/html-style.sh (kind, then DEFAULT, then the built-in report/minimal), then render with tools/html-render.sh, which puts the markdown through Gitea''s own renderer and inlines every asset. A request without a wiki or issue link stops the skill immediately: never guess the repository, the page or the issue number. Use when a page or issue has to leave Gitea as a document; not for choosing which template a kind uses - that is jsc-gitea:html-style.'
---
# html-export — a wiki page or issue becomes one HTML file
The link is the only input. The output is one file that opens anywhere, with no external asset.
## Steps
1. **Link gate.** Run `tools/gitea-link.sh parse {url}`. Exit 3, or no link in the request: **stop and report it**. Exit 2 is a usage error — fix the arguments and call again. Never fall back to the working directory's remote or to a page name the user mentioned in passing. This gate runs first because it stops the whole skill more often than any other check, and it costs no API call. Completion condition: `kind` is `wiki` or `issue`, and `repo` plus `page` or `index` are known.
2. **Host gate.** Confirm `GITEA_HOST` holds a value in the current shell; ask for it per the `jsc-ask:ask` rules when it does not. `GITEA_TOKEN` needs no inventory — `tools/gitea.sh` resolves it, retries once with the tea CLI login token, and exits 7 when neither works. Completion condition: `GITEA_HOST` holds a value.
3. **Run these three tracks at the same time.** They are independent, so start them in one batch rather than one after another; only the issue branch of track B waits, and only for track A's `labels` line.
- **Track A — content.** Wiki page: `jsc-gitea:wiki` `wiki-get {repo} {page}` for the markdown, plus `wiki-url {repo} {page}` for the source URL; route its exit codes by that skill's table (4 = no such page, 7 = the key is invalid, 8 = other API failure), and every one of them stops this skill with the page name in the report. Issue: `tools/issue.sh show {repo} {index}`, which returns title, labels and body from **one** API call — never call `title`, `body` and `labels-of` separately on the same issue. Exit 1 means the issue could not be read: stop and report the issue number; exit 2 is a usage error, so fix the arguments and call again.
- **Track B — template.** Wiki page: `tools/html-style.sh key wiki {page}`. Issue: `tools/html-style.sh key issue {repo} {index} --labels {the names from track A's labels line}`, which spends no extra API call; the labels line arrives before the body, so this track starts well before track A finishes. The script owns both derivation rules — the page-name prefix, and trying each label in order until one is configured. It prints `{key}<TAB>{reason}`; keep the reason, it is how the report says which prefix or label produced the key. Exit 1 means the labels could not be read: **report that first**, then continue with `ISSUE:DEFAULT` and say in the report that the template was picked without labels. Exit 2 is a usage error — fix the arguments and call again. Then run `tools/html-style.sh get {key}`, which always prints `layout<TAB>style<TAB>source`. **Read the third column and report it**: `project` or `global` means the user configured this kind; `default` means it fell back to the DEFAULT row; `builtin` means nothing is configured at all and `report`/`minimal` was used. For `default` and `builtin`, tell the user in one line that `jsc-gitea:html-style` can set this kind's own template.
- **Track C — destination.** Ask per the `jsc-ask:ask` rules where the file goes, proposing `./.jsc/html/{page-or-issue}.html`. State the impact scope: a path inside a repository gets committed unless it is ignored.
Completion condition: the markdown and the document title are in hand, exactly one kind key is chosen with the reason that produced it, the layout, style and source are reported, and the user has confirmed one output path.
4. **Prepare the markdown — this step MUST run as a sub agent.** Links are written as `[text](absolute URL)`, so pages that follow the current rule need no conversion. An older page can still carry a wiki-internal link: turn it into an absolute URL from `wiki-url`, because the renderer does not resolve it and it would ship as literal brackets. Strip personal data — an exported file travels further than the page it came from. Leave everything else exactly as written; this step never rewrites the content. Completion condition: every link in the file is `[text](absolute URL)`, and the diff against the source is limited to link conversion and personal-data removal.
5. Render: `tools/html-render.sh --markdown {file} --title {title} --layout {layout} --style {style} --source-url {absolute URL} --out {path}`. Route every exit code: 0 → the path it printed is the finished file; 1 → Gitea's renderer or the write failed, so report it and stop, with no half-rendered file left behind; 2 → a usage error or a missing markdown file, so fix the arguments and call again; 4 → the layout or style template file is gone, so report which pair was asked for and send the user to `jsc-gitea:html-style` rather than editing the configuration by hand. Completion condition: the file exists, and the report names its path, the layout, the style and where that pair came from.
6. **Record how the run ended.** This is the last thing this skill does, and it runs on every path out of the skill — including the ones that stop at step 1. Call
`jsc-hooks/tools/report-status.sh skill-end jsc-gitea:html-export {status} {exit code} [detail]`
`{exit code}` is the exit code of whatever decided the outcome, and `0` when nothing failed. `{detail}` is one short line, no more than 200 characters. **If the script is not on this machine, skip this step in silence and finish the run as it stood** — missing infrastructure is not a failure, and a reporting call may never change what this skill returns or reports.
| status | When this skill uses it |
| --- | --- |
| `ok` | The file was rendered and the report names its path, layout, style and source |
| `blocked` | The host gate of step 2 stopped the run: `GITEA_HOST` holds no value and the user gave none, so nothing was read and nothing was rendered |
| `failed` | The work started and broke: `wiki-get` returned 7 or 8, `issue.sh show` returned 1, or `html-render.sh` returned 1, 2 or 4. Nothing usable came out |
| `degraded` | The file was rendered, but part of the run did not hold — track B could not read the labels (exit 1) so the template was picked without them, and the export used a template the configuration did not choose |
| `aborted` | The premise did not hold, so the skill stopped on its own: the request carried no wiki or issue link, or `gitea-link.sh parse` returned 3. Also used when the user stops the run at step 3's destination question |
Completion condition: exactly one `skill-end` line was recorded for this run, or the script was absent and the run finished without it.
## Rules
- One link, one file. Batch export is a loop the caller runs, not something this skill decides on its own.
- The rendered file inlines CSS and scripts on purpose: it is usually sent to someone outside Gitea, and an external asset breaks on their machine.
- The layout and style are never chosen by inspecting the content. The configuration decides, and `jsc-gitea:html-style` owns the configuration.
+38
View File
@@ -0,0 +1,38 @@
---
name: html-style
description: Set which HTML layout and style a kind of Gitea wiki page or issue gets when jsc-gitea:html-export renders it. Offer all six layouts from tools/html-style.sh layouts and all five styles from tools/html-style.sh styles as decision-tree options per jsc-ask, then write the pair with tools/html-style.sh set - either to the project file .jsc/html-styles or to the global $JSC_HOME/html-styles.conf. Use when a kind of page or issue should come out looking different, or when the export reported source builtin or default; not for rendering a page, which is jsc-gitea:html-export.
---
# html-style — which template a kind of page gets
One kind of page, one layout, one style. `jsc-gitea:html-export` reads what this skill writes.
## Steps
1. **Settle the kind key.** Show the current configuration with `tools/html-style.sh list` first, then ask per `jsc-ask:ask` rules which kind this run sets. The three shapes are fixed: `WIKI:{page-name prefix}` (`WIKI:PLAN`, `WIKI:ANALYZE`, `WIKI:LOG` …), `ISSUE:{label name}` (`ISSUE:bug`), and `DEFAULT` for everything that matches nothing else. Every option states its impact scope — `DEFAULT` changes every kind that has no row of its own. Completion condition: exactly one key is agreed, and its current value from `tools/html-style.sh get {key}` has been read back with its source column.
2. **Pick the layout — offer all six.** Run `tools/html-style.sh layouts`; it prints each name with its Traditional Chinese description, taken from the template file itself. `list`, `get`, `layouts` and `styles` exit 2 on a usage error — fix the arguments and call again. An empty listing means the template directory is missing, which stops this skill: report the path rather than offering a name the export cannot use. Present all six as options per `jsc-ask:ask` rules, each with what it does to the content (`report` builds a table of contents beside the text, `slide` turns every `##` into a keyboard-flipped page, `dashboard` turns them into cards, `spec` freezes table headers, `timeline` strings them along a line, `onepager` narrows everything into one printable page). Completion condition: the user has picked one layout name that the script listed.
3. **Pick the style — offer all five.** Run `tools/html-style.sh styles` and present every one it prints (`minimal`, `corporate`, `dark`, `print`, `vivid`) with its description. Never trim the list to a shortlist: the point of this skill is that the user sees the whole set. Completion condition: the user has picked one style name that the script listed.
4. **Pick the scope.** Ask per `jsc-ask:ask` rules: `--project` writes `./.jsc/html-styles`, which only applies inside this working directory and is committed with the repository; `--global` writes `$JSC_HOME/html-styles.conf`, which follows the user across every project on this machine. State that the project file wins whenever both hold the same key. Completion condition: the user has picked one scope.
5. Write it: `tools/html-style.sh set {key} {layout} {style} [--project|--global]`. Route every exit code: 0 → the file it printed now holds the pair; 1 → the settings file's directory could not be created, so report the path and stop, since nothing was written; 2 → a usage error, such as a missing name or a scope flag that is neither `--project` nor `--global`, so fix the arguments and call again; 4 → the layout or style name has no template file, so go back to step 2 or step 3 rather than editing the settings file by hand. Completion condition: the script exits 0 and prints the file it wrote.
6. Read it back with `tools/html-style.sh get {key}` and report the resolved layout, style and source. Completion condition: the source column shows `project` or `global`, matching the scope chosen in step 4.
7. **Record how the run ended.** This is the last thing this skill does, and it runs on every path out of the skill, the ones that stop at step 2 included. Call
`jsc-hooks/tools/report-status.sh skill-end jsc-gitea:html-style {status} {exit code} [detail]`
`{exit code}` is the exit code of whatever decided the outcome, and `0` when nothing failed. `{detail}` is one short line, no more than 200 characters. **If the script is not on this machine, skip this step in silence and finish the run as it stood** — missing infrastructure is not a failure, and a reporting call may never change what this skill returns or reports.
| status | When this skill uses it |
| --- | --- |
| `ok` | `set` exited 0 and step 6 read the pair back with the source column matching the scope that was chosen |
| `blocked` | The template directory is missing, so `layouts` or `styles` listed nothing. There is no name to offer and no pair to write, so the run stops before any question and the settings file is untouched |
| `failed` | The write itself broke: `set` returned 1 because the settings directory could not be created, 2 on a malformed call, or 4 because the layout or style has no template file. Nothing was written |
| `degraded` | `set` exited 0, but step 6 read back a different source than the scope chosen in step 4 — usually a project file holding the same key and winning over a global write. The pair is on disk, yet the export will still resolve to another one |
| `aborted` | The user stopped at one of the four questions — the kind key, the layout, the style or the scope — so nothing was written |
Completion condition: exactly one `skill-end` line was recorded for this run, or the script was absent and the run finished without it.
## Rules
- Only names the script listed may be written. A key holding a template that does not exist fails at export time, long after the mistake was made.
- Removing a row is `tools/html-style.sh unset {key} [--project|--global]`; after that the kind falls back to `DEFAULT`, then to the built-in `report`/`minimal`.
- New layouts live in `templates/html/layout/{name}.html` and new styles in `templates/html/style/{name}.css`, each starting with a one-line Traditional Chinese comment — that comment is what the option list shows.
+25 -10
View File
@@ -1,18 +1,33 @@
--- ---
name: repo-sync name: repo-sync
description: Batch-sync all readable repos of a chosen Gitea owner into the working directory. List owners, let the user pick, then clone or update each repo; local changes become a branch, commit, push, and PR to develop or master. Use for workspace bootstrap or bulk refresh; not for a single repo. description: Batch-sync all readable repos of a chosen Gitea owner into the working directory. List owners, let the user pick, then clone or update each repo through tools/repo-sync.sh; a repo reported dirty goes to jsc-git:pr against the base branch that same script reports. Use for workspace bootstrap or bulk refresh; not for a single repo.
--- ---
# repo-sync — batch-sync repositories # repo-sync — batch-sync repositories
## Steps ## Steps
1. Before calling `tools/gitea.sh`, resolve `GITEA_HOST` and `GITEA_TOKEN` from the current shell environment (also check `tea login list` for a usable login token when `GITEA_TOKEN` is unset). If `GITEA_HOST` is unresolvable, or `GITEA_TOKEN` is unset and no tea login token exists either, ask the user for the missing value per the `jsc-ask:ask` rules before proceeding to any `tools/gitea.sh` call. 1. Run `tools/gitea.sh owners` to list every `{owner}` the user can read. The script reads `GITEA_HOST` and `GITEA_TOKEN` from the inherited environment and retries once with the tea CLI login token, so take no separate inventory first — a missing value surfaces here, before any work is done. Route on the result: at least one `{owner}` printed → next step; the script stops with `GITEA_HOST is required` or `GITEA_TOKEN is required` → ask the user for that one value per the `jsc-ask:ask` rules and run the command again, guessing no host; exit 7 → report that the key is invalid or lacks permission, and stop; exit 8 → report the HTTP status in the message, and stop; exit 0 with no output → report that this key can read no owner, and stop. Done when at least one `{owner}` is printed, or the run stopped with one of those reasons.
2. Run `tools/gitea.sh owners` to list every `{owner}` the user can read. 2. Ask the user which `{owner}` to sync, per the `jsc-ask:ask` rules. Every option states the owner's repo count and impact scope. Done when the user has named exactly one `{owner}` from that list.
3. Ask the user which `{owner}` to sync, per the `jsc-ask:ask` rules. Every option states the owner's repo count and impact scope. 3. Run `tools/gitea.sh repos {owner}` to list every readable `{repo}` under that owner. Exit 7 and exit 8 stop the run with the same report as step 1; an empty list means this owner has no readable repo and there is nothing to sync. Done when the command has printed the full `{owner}/{repo}` list for the chosen owner, or the run stopped.
4. Run `tools/gitea.sh repos {owner}` to list every readable `{repo}` under that owner. 4. Sync every `{repo}` from step 3 **in parallel — one sub agent per repo, all launched in the same batch**, never one after another. Each repo has its own directory and its own remote, so nothing makes them wait for each other, and a hundred-repo owner otherwise costs a hundred sequential clones. Each sub agent does this:
5. Sync each `{repo}` one by one. This step **MUST run as a sub agent** (one sub agent per repo): 1. Run `tools/repo-sync.sh {owner}/{repo}`. The script owns the clone-versus-pull decision and the base-branch precedence, so run no `git clone`, `git checkout` or `git pull` by hand, and derive no branch name yourself. Route on its single line of output: `cloned` or `updated` (exit 0) → this repo is done; `dirty {branch}` (exit 0) → go to substep 2; `failed {reason}` (exit 1) → record that reason and stop this repo. Done when exactly one of those four outcomes is recorded for this repo.
1. Missing locally → `git clone` into the working directory (clone URL from `tools/gitea.sh clone-url`). 2. `dirty {branch}` → call `jsc-git:pr` with `{branch}` from that same output line as the base, passed through verbatim. It commits, branches, pushes and opens the PR itself, so add none of those steps. Done when `jsc-git:pr` returns the PR URL and reports it with the table format in `jsc-meta/references/pr-report.md`.
2. Present locally → switch to `develop`, else `master` (or the result of `tools/gitea.sh default-branch`), then `git pull`.
3. Local file changes → create a branch from develop or master, commit via `jsc-git:commit`, push, then open a PR back to develop or master via `jsc-git:pr`. Done when every repo's sub agent has returned one of those outcomes; one repo failing never cancels the others.
6. Report the sync result for every repo: cloned, updated, PR created, or the failure reason. 5. Report the sync result for every repo: cloned, updated, PR table row, or the failure reason. Done when every `{repo}` from step 3 carries one of those four results, and all PR rows share one table when more than one PR exists.
6. **Record how the run ended.** This is the last thing this skill does, and it runs on every path out of the skill, the ones that stop at step 1 included. Call
`jsc-hooks/tools/report-status.sh skill-end jsc-gitea:repo-sync {status} {exit code} [detail]`
`{exit code}` is the exit code of whatever decided the outcome, and `0` when nothing failed. `{detail}` is one short line, no more than 200 characters — the repo counts fit there, the per-repo list does not. **If the script is not on this machine, skip this step in silence and finish the run as it stood** — missing infrastructure is not a failure, and a reporting call may never change what this skill returns or reports.
| status | When this skill uses it |
| --- | --- |
| `ok` | Every repo from step 3 came back `cloned`, `updated`, or dirty with its PR opened |
| `blocked` | Nothing could be listed, so no repo was touched: `GITEA_HOST` or `GITEA_TOKEN` was required and the user gave none, or `owners` exited 0 with no owner this key can read |
| `failed` | The listing broke mid-run — `owners` or `repos` returned 7 or 8 — or every repo in step 4 came back `failed`. No repo reached the working directory |
| `degraded` | Some repos synced and some did not: at least one `failed {reason}` next to at least one `cloned`, `updated` or PR row. One repo failing never cancels the others, so the run finishes with part of the workspace missing |
| `aborted` | The user named no owner at step 2, or stopped the run before step 4 started |
Completion condition: exactly one `skill-end` line was recorded for this run, or the script was absent and the run finished without it.
+47
View File
@@ -0,0 +1,47 @@
---
name: wiki-to-issue
description: 'Turn one Gitea wiki page into an issue in the same repository. Parse the link with tools/gitea-link.sh, read the page through jsc-gitea:wiki, draft title and body as a sub agent, then create the issue with tools/issue.sh - labels picked from the repository''s existing set per the jsc-ask decision tree, project board attached or reported as manual when the site has no board API. A request without a wiki link stops the skill immediately: never guess the repository or the page. Use when a wiki page has to become trackable work; not for issues drafted from scratch, and not for syncing an issue that already exists.'
---
# wiki-to-issue — a wiki page becomes an issue
The wiki link is the only input. Everything else — repository, page name, host — comes out of that link.
## Steps
1. **Input gate — the link and the host, checked in the same batch.** Neither depends on the other, so run both before anything else and report every failure found, not just the first.
- **The link.** Run `tools/gitea-link.sh parse {url}` on the link the user gave. Exit 3, no link in the request, or `kind=issue` (this skill reads wiki pages, not issues) all mean the same thing: **stop and report which one it was**. Exit 2 is a usage error — fix the arguments and call again. Never ask for a repository name instead, and never fall back to the working directory's remote — a page written into the wrong repository's issue tracker is public and hard to take back.
- **The host.** Confirm `GITEA_HOST` holds a value in the current shell; ask for it per the `jsc-ask:ask` rules when it does not. `GITEA_TOKEN` needs no inventory — `tools/gitea.sh` resolves it, retries once with the tea CLI login token, and exits 7 when neither works.
Completion condition: the parse printed `kind=wiki` with `repo`, `page` and `host` known, **and** `GITEA_HOST` holds a value — both, or the run has stopped with the reason named.
2. **Run these three tracks at the same time.** The label list and the board list depend on the repository only, not on the page, so they start in the same batch as the read rather than queueing behind the draft.
- **Track A — read and draft.** Read the page with `jsc-gitea:wiki` (`wiki-get {repo} {page}`) and take its absolute URL from `wiki-url` in the same pass. Route the exit codes by that skill's table: 4 means the page does not exist, 7 means the key is invalid or lacks permission, 8 is any other API failure — all three stop this skill with the page name in the report. **Drafting MUST run as a sub agent.** Title: the page's first heading, or the page name when it has none. Body: the page content in Traditional Chinese, opening with a 「來源:{絕對網址}」 line so the issue points back at the wiki. Links are written as `[文字](絕對網址)`; a wiki-internal link left over from an older page becomes an absolute URL from `wiki-url`, because that form resolves only inside a wiki and an issue is not one. Drop personal data — an issue is read by more people than a wiki page.
- **Track B — label list.** Run `tools/issue.sh labels {repo}`. Exit 1 means the label list could not be read: stop and report it, because the alternative is inventing labels. Exit 2 is a usage error — fix the arguments and call again.
- **Track C — board list.** Run `tools/issue.sh projects {repo}`. Exit 3 means this Gitea has no board API — keep the board URL the script printed for step 4. Exit 1 means the call failed for another reason: report it and treat the board link as outstanding. Exit 2 is a usage error — fix the arguments and call again.
Completion condition: the title and body file exist with the source line and every link in the body written as `[文字](絕對網址)`, the repository's label list is in hand or the run has stopped, and the board list is either in hand or recorded as unavailable.
3. **Labels come from what the repository already has.** Propose the fitting ones from track B's list with a reason each, and confirm per `jsc-ask:ask` rules — every option states its impact scope (a label drives filters and board rules, so a wrong one routes the work to the wrong queue). Turn the confirmed names into ids with `tools/issue.sh label-ids {repo} {names}`. Exit 4 means a name is not in the repository: go back to the list and pick again, never create the label to make the command pass. Exit 1 means the call failed — report it and stop. An empty label list, or nothing fitting: ask whether to create the issue with no label, and record that answer. **Never invent a label that the repository does not have.** Completion condition: the user has confirmed a label set — possibly empty — and its ids are resolved.
4. **Project board.** Track C returned a board list: let the user pick one per `jsc-ask:ask` rules, attach it, and report the failure verbatim if the attach call is refused. Track C exited 3: say plainly that this Gitea has no board API, and hand the user the board URL the script printed so they can drag the issue in themselves. Completion condition: the issue is either attached to a board, or the report states in one line that the board link is still outstanding and who has to do it.
5. Create the issue: `tools/issue.sh create {repo} {title} {body-file} [--labels {ids}]`. The script asks for confirmation before it writes, so expect that prompt and hand the user the title, the labels and the board it is about to apply. Exit 0: report the `index=` and `url=` it prints. Exit 1 means no issue was created — report that plainly, and hand back the path of the drafted body file so the draft is not lost. Exit 2 is a usage error, usually a body file that is not there — fix the arguments and call again. Completion condition: the issue URL is reported to the user together with the labels applied and the board status from step 4, or the report states that no issue was created and where the draft is.
6. **Record how the run ended.** This is the last thing this skill does, and it runs on every path out of the skill, the ones that stop at step 1 included. Call
`jsc-hooks/tools/report-status.sh skill-end jsc-gitea:wiki-to-issue {status} {exit code} [detail]`
`{exit code}` is the exit code of whatever decided the outcome, and `0` when nothing failed. `{detail}` is one short line, no more than 200 characters — the issue index fits there, the issue body does not. **If the script is not on this machine, skip this step in silence and finish the run as it stood** — missing infrastructure is not a failure, and a reporting call may never change what this skill returns or reports.
| status | When this skill uses it |
| --- | --- |
| `ok` | `issue.sh create` exited 0, and the report carries the issue URL, the labels applied and a board that is attached |
| `blocked` | The host gate stopped the run: `GITEA_HOST` holds no value and the user gave none, so the page was never read and no issue was drafted |
| `failed` | The work started and broke: `wiki-get` returned 4, 7 or 8, `issue.sh labels` returned 1 so no label could be picked without inventing one, or `issue.sh create` returned 1 and no issue exists. Report the draft path in `{detail}` when the create failed |
| `degraded` | The issue was created, but part of it stays outstanding — track C exited 3 because this Gitea has no board API, or the attach call was refused, so the report hands the board link back to the user to drag in by hand. The issue is real, its place on the board is not |
| `aborted` | The premise did not hold, so the skill stopped on its own: the request carried no wiki link, `gitea-link.sh parse` returned 3, or the link parsed as `kind=issue`. Also used when the user refuses the confirmation `issue.sh create` asks for, so nothing was written |
Completion condition: exactly one `skill-end` line was recorded for this run, or the script was absent and the run finished without it.
## Rules
- One wiki page, one issue. Splitting a page into several issues is analysis work, not conversion — hand that to `jsc-sdlc:analyze`.
- The issue body stays Traditional Chinese per the STE100 rule, and keeps the source line at the top.
- Creating an issue is an outward-facing action: the title, body, labels and board pick are confirmed with the user before the create call, never after.
+86 -23
View File
@@ -1,6 +1,6 @@
--- ---
name: wiki name: wiki
description: Read or write a Gitea wiki page through tools/gitea.sh and tools/hash-id. Resolve the wiki repo per page type with JSC_WIKI_REPO_{TYPE} first, then JSC_WIKI_REPO, and ask only when neither is set. Page content is chart-first - prefer mermaid diagrams and markdown tables over plain prose. Used by jsc-ask, jsc-sdlc, and jsc-log for wiki pages, including ERROR pages, not for repo code files. description: Read or write a Gitea wiki page through tools/gitea.sh, tools/hash-id and tools/page-name.sh. Resolve the wiki repo per page type with JSC_WIKI_REPO_{TYPE} first, then JSC_WIKI_REPO, and ask only when neither is set - every *_CONTENTS page resolves through type CONTENTS and is upserted by tools/wiki-contents.sh as one H2 block per record, and {HASH} is the full 40-char uppercase SHA-1 from tools/hash-id. A contents page carries no markdown table - one H2 block per record, headed by that record's content-page name with one bullet per field - while a content page is chart-first, preferring mermaid diagrams and markdown tables over plain prose, and every link is written as [text](absolute URL) that tools/link-check.sh passed before the write. Callers are jsc-ask, jsc-sdlc, jsc-log, jsc-hooks (ERROR), jsc-cli (CHECK), jsc-meta (SKILLSET, TOOLING) and jsc-assist (MONITOR). Use for any wiki page in the skill set; not for repo code files.
--- ---
# wiki — read and write Gitea wiki pages # wiki — read and write Gitea wiki pages
@@ -9,41 +9,104 @@ Every wiki operation in the jsc skill set goes through this skill. One entry poi
## Resolve the wiki location ## Resolve the wiki location
Different page types can live in different `{owner}/{repo}` repos, classified by page-name prefix: `QUESTION`, `PLAN`, `ANALYZE`, `DELIVER`, `MAINTAIN`, `REPO`, `LOG`, `LEARN`, `ERROR`. Different page types can live in different `{owner}/{repo}` repos, classified by the page-name prefix.
1. Before asking the user, inspect the current shell environment for the needed repo variables and Gitea connection variables: `JSC_WIKI_REPO_{TYPE}`, `JSC_WIKI_REPO`, `GITEA_HOST`, and `GITEA_TOKEN` (also check `tea login list` for a usable login token when `GITEA_TOKEN` is unset). Use inherited shell values first; only ask when the needed repo or connection value cannot be resolved after that check. `GITEA_HOST` and `GITEA_TOKEN` are both covered by this ask-if-unresolvable rule, the same as the wiki-repo variables below. 1. **Host gate.** Confirm `GITEA_HOST` holds a value in the current shell. When it is missing, ask for it per the `jsc-ask:ask` rules before any `tools/gitea.sh` call that reaches the API; otherwise the first thing the user sees is the script's `GITEA_HOST is required` line instead of a decision-tree question. `GITEA_TOKEN` needs no inventory here — the script resolves it, retries once with the tea CLI login token, and exits 7 when neither works. Done when `GITEA_HOST` holds a value.
2. Run `tools/gitea.sh wiki-repo {TYPE}` (TYPE = the page-name prefix). Allowed types are `QUESTION`, `PLAN`, `ANALYZE`, `DELIVER`, `MAINTAIN`, `REPO`, `LOG`, `LEARN`, and `ERROR`. Resolution order is `JSC_WIKI_REPO_{TYPE}` first, then `JSC_WIKI_REPO`. Never borrow another type's repo. 2. Run `tools/gitea.sh wiki-repo {TYPE}`. Allowed types are `QUESTION`, `PLAN`, `ANALYZE`, `DELIVER`, `MAINTAIN`, `REPO`, `LOG`, `LEARN`, `ERROR`, `CHECK`, `REPORT`, `SKILLSET`, `TOOLING`, `MONITOR`, and `CONTENTS`. **Every `*_CONTENTS` page resolves through `CONTENTS` — all contents pages live in one dedicated repo, so never pass a contents page its own prefix. A content page (`*_{HASH}`) resolves through its own type.** The script reads `JSC_WIKI_REPO_{TYPE}` first and `JSC_WIKI_REPO` second, straight from the inherited environment, so take no separate inventory of those two variables. Never borrow another type's repo. Done when the command has printed exactly one `{owner}/{repo}`, or exited 3 and sent this page type to step 3, or exited 2 on a type outside the list above and stopped the run.
3. On exit 3 (neither is set after env inspection), ask the user for that page type's `{owner}/{repo}` per the `jsc-ask:ask` rules, and suggest setting `JSC_WIKI_REPO_{TYPE}` (can differ per type) or `JSC_WIKI_REPO` (shared default). 3. On exit 3 (neither variable is set), ask the user for that page type's `{owner}/{repo}` per the `jsc-ask:ask` rules, and suggest setting `JSC_WIKI_REPO_{TYPE}` (can differ per type, and `JSC_WIKI_REPO_CONTENTS` holds every contents page) or `JSC_WIKI_REPO` (shared default). Done when the user has supplied one `{owner}/{repo}` for that page type.
## Operations ## Operations
| Action | Command | | Action | Command |
| --- | --- | | --- | --- |
| list pages | `tools/gitea.sh wiki-list {owner}/{repo}` | | list pages | `tools/gitea.sh wiki-list {owner}/{repo}` |
| read page | `tools/gitea.sh wiki-get {owner}/{repo} {page}` (exit 4 when missing) | | read page | `tools/gitea.sh wiki-get {owner}/{repo} {page}` |
| write page | write the content to a temp file first, then `tools/gitea.sh wiki-put {owner}/{repo} {page} {file}` (creates or updates automatically) | | write page | write the content to a temp file first, then `tools/gitea.sh wiki-put {owner}/{repo} {page} {file}` (asks for confirmation first, then creates or updates) |
| page URL | `tools/gitea.sh wiki-url {owner}/{repo} {page}` — the page's absolute URL, taken from the API's `html_url` (exit 4 when the page is missing) | | delete page | `tools/gitea.sh wiki-delete {owner}/{repo} {page}` — asks for the same confirmation as a write. Only a migration or an explicit user request may call it |
| page URL | `tools/gitea.sh wiki-url {owner}/{repo} {page}` — the page's absolute URL, taken from the API's `html_url` |
| update a contents page | `tools/wiki-contents.sh upsert {TYPE} {key-col} {key} {entry-file} [template-file]` — resolves the CONTENTS repo, replaces the `## {key}` block, appends when the page holds no such heading, writes the whole page back. `{entry-file}` is that record's whole H2 block. `{key-col}` only matters while the old page is still a markdown table: it is the 1-based position of the column that carries the record's identity, and the script converts such a page to H2 blocks before the upsert, taking each heading from that cell's link URL — its last path segment, percent-decoded — or from the cell's plain text when the cell holds no link. On that conversion, a supplied `{template-file}` also replaces the page's H1 and `>` intro with the template's own, because a conversion moves the table alone and the old intro keeps describing rows; an already-converted page keeps its intro untouched, and so does a conversion run without a template |
| check a page name | `tools/page-name.sh check {page}` — the single source of the page-name pattern; `tools/page-name.sh regex` prints it |
| move pages to the current rules | `tools/migrate-wiki.sh [--apply] [--key {key}]...` — prints the mapping table and the orphan list; writes only with `--apply` |
| check links before a write | `tools/link-check.sh {url}...` — prints `{OK\|DEAD\|SKIP}<TAB>{url}<TAB>{reason}` per URL; exit 0 means every link is reachable |
## Linking between wiki pages Every link in a page is written as `[text](absolute URL)`, and the URL comes from `wiki-url` — one form for every target, inside this wiki or not. Every link goes through `tools/link-check.sh` before the page is written. Full rules, and why `[[...]]` was dropped: `references/wiki-links.md`.
Two rules, and getting either wrong produces a link that silently points at a page that does not exist. ## Exit codes
**Direction**: Gitea uses the GitHub/Gollum convention — `[[display text|page name]]`, **display text on the LEFT, page name on the RIGHT**. This is the opposite of MediaWiki. Gitea's own source says so (`modules/markup/html_link.go`): *"MediaWiki uses [[link|text]], while GitHub uses [[text|link]] … we prefer GitHub syntax"*. So `[[PLAN_H1234567|我的計畫]]` renders as the text `PLAN_H1234567` linking to a page named 我的計畫 — broken. Write `[[我的計畫|PLAN_H1234567]]`. When display text and page name are the same, use the no-pipe form `[[PLAN_H1234567]]`, which cannot be got wrong. Route every `tools/gitea.sh` call in this skill on its exit code. A code with no branch below stops the run and gets reported as it is.
**Scope**: `[[...]]` and relative markdown links both resolve **only inside the current wiki**. There is no cross-repo wiki-link syntax. | Code | Meaning | What this skill does |
| Link | Same wiki? | Use |
| --- | --- | --- | | --- | --- | --- |
| Same page type (e.g. `PLAN_CONTENTS` → `PLAN_{HASH}`) | Always — one type, one repo | `[[display\|page]]` or `[[page]]` | | 0 | success | use the output |
| Different page type (e.g. `LOG_{HASH}` → `PLAN_{HASH}`) | **Only when both types resolve to the same repo** | Absolute URL from `wiki-url`: `[display](https://…/wiki/PLAN_…)` | | 2 | usage error, or a page type outside the allowed list | fix the arguments, then call again; never repeat the same call unchanged |
| 3 | `wiki-repo`: neither `JSC_WIKI_REPO_{TYPE}` nor `JSC_WIKI_REPO` is set | go to step 3 and ask |
| 4 | HTTP 404: `wiki-get` and `wiki-url` found no such page, or `wiki-delete` found nothing to delete | for a read the caller expects to succeed, stop and report the page name; this is the **only** code that opens the create path of rule 4 — write the page from the template instead of appending. For `wiki-delete` it means the page is already gone: report it and move on, do not retry |
| 5 | `wiki-url`: the page exists but the API returned no `html_url` | stop and report it. There is no second link form to fall back on, and a hand-built path is not a substitute — never fabricate the URL |
| 7 | HTTP 401 or 403 after the tea-token retry: the key is invalid or lacks permission | **stop the whole operation and report the key problem.** Never read this as an empty or missing page, and never take the create path of rule 4: writing a fresh page over one you could not read destroys the record that is still there |
| 8 | any other API failure, HTTP status in the message | stop and report that status; call again only after the cause is fixed |
Because each type resolves its own `JSC_WIKI_REPO_{TYPE}`, a cross-type link **must** use the absolute URL — it stays correct whether or not the two types happen to share a repo, so never branch on that. Get the URL from `wiki-url`, never hand-assemble the path. ### Helper scripts
Every code below gets its own branch. Nothing here is retried unchanged.
| Script | Code | Meaning | What this skill does |
| --- | --- | --- | --- |
| `tools/hash-id` | 0 | the full 40-char uppercase hash | use it as `{HASH}` |
| | 1 | this machine has neither `sha1sum` nor `shasum` | stop, report that one of them has to be installed, compute no hash by hand |
| | 2 | no text given, or the text was empty | fix the key you passed, then call again; never fall back to a hand-made page name |
| `tools/page-name.sh` | 0 | the page name follows the pattern | continue with that page name |
| | 1 | the page name breaks the pattern | stop and report the name; build the correct one instead of writing to a wrong page |
| | 2 | usage error | fix the arguments, then call again |
| `tools/wiki-contents.sh` | 0 | the block was updated or added | report which of the two, and the page |
| | 1 | the page content could not be built, or the write failed | stop and report; fix the page or the entry before calling again. A missing `## {key}` is not this code — that path appends |
| | 2 | usage error, or an unknown page type | fix the arguments, then call again |
| | 3 | the CONTENTS repo is not configured | go to step 3 and ask for `JSC_WIKI_REPO_CONTENTS` |
| | 4 | the page is not there and no template was given | supply the template for that page type, then call again |
| | 7 | the key is invalid or lacks permission | stop the whole operation and report the key problem; create no page |
| | 8 | any other API failure | stop and report the status |
| `tools/link-check.sh` | 0 | every link is reachable | write the page; this is the only code that opens `wiki-put` |
| | 1 | at least one link is dead | do not write. Report the `DEAD` rows verbatim, fix or drop those links, then check again |
| | 2 | usage error: no URL was given | fix the arguments, then call again; never skip the check because the list looked empty |
| | 3 | the list holds a Gitea URL but `GITEA_HOST` is not set | set `GITEA_HOST` and call again. Never write the page unchecked |
| | 7 | HTTP 401 or 403: the key is invalid or lacks permission | stop the whole operation and report the key problem. Those pages are not dead — treating them as dead deletes or rewrites links to pages that are still there |
| `tools/migrate-wiki.sh` | 0 | every page moved, or the preview found nothing to move | report the mapping table |
| | 1 | at least one page failed to move | report the failure list; the old pages of the failed entries stay in place |
| | 2 | usage error, including an unconfigured CONTENTS repo | fix the arguments or set `JSC_WIKI_REPO_CONTENTS`, then call again |
| | 3 | something needs manual handling: an orphan page, a page that links to a moved page without being moved itself, or a destination page that already holds content | report those lists and hand them to the user; guess no key, and rewrite no link the script left alone |
## Close the run
**Record how the run ended.** This is the last thing this skill does, and it runs on every path out of the skill, the ones that stop at the host gate included. Call
`jsc-hooks/tools/report-status.sh skill-end jsc-gitea:wiki {status} {exit code} [detail]`
`{exit code}` is the exit code of whatever decided the outcome — the `gitea.sh`, `link-check.sh` or `wiki-contents.sh` call that ruled the run — and `0` when nothing failed. `{detail}` is one short line, no more than 200 characters; put the page name there, never the page content. **If the script is not on this machine, skip this step in silence and finish the run as it stood** — missing infrastructure is not a failure, and a reporting call may never change what this skill returns to its caller.
This skill is called by almost every other one, so its status is what the caller reads back. Report the status of this wiki operation only, never the caller's own outcome.
| status | When this skill uses it |
| --- | --- |
| `ok` | The read returned the page, or the write landed: `link-check.sh` exited 0, the confirmation was given, and `wiki-put` exited 0 |
| `blocked` | The location could not be resolved, so nothing was read and nothing was written: `GITEA_HOST` holds no value and the user gave none, or `wiki-repo` exited 3 and the user supplied no `{owner}/{repo}` for that page type |
| `failed` | The operation ran and broke. **Exit 7 belongs here**: the key is invalid or lacks permission, so the whole operation stopped, and that is a failure, never an absent page. Exit 8, a `wiki-put` that did not land, a `link-check.sh` exit 1 that refused the write, and a `hash-id` or `page-name.sh` rejection all sit here too |
| `degraded` | The content page landed and the contents page did not — `wiki-put` on `{TYPE}_{HASH}` exited 0, then `wiki-contents.sh upsert` exited 3 with no CONTENTS repo configured, or exited 1 because the page content could not be built or the write did not land. The record exists but nothing indexes it, so the next reader will not find it. A migration that moved some pages and left orphans or occupied destinations behind sits here as well |
| `aborted` | The premise did not hold or the user stopped it: `write-confirm.sh` was refused before a write or a delete, or the caller asked for a page type outside the allowed list and the run stopped at `wiki-repo` exit 2 |
Completion condition: exactly one `skill-end` line was recorded for this run, or the script was absent and the run finished without it.
## Rules ## Rules
1. Page names must follow the wiki naming table in the skill guidelines (see `jsc-meta/references/guidelines.md`). 1. Page names must follow the wiki naming table in the skill guidelines (see `jsc-meta/references/guidelines.md`). Check any page name you build with `tools/page-name.sh check {page}` before it reaches an API call.
2. Use `tools/hash-id` for `{HASH}` values. It returns the first 8 uppercase SHA-1 hex chars, or `H` plus the first 7 chars when the raw hash starts with `0-9`, `A`, `B`, or `C`. 2. Use `tools/hash-id` for `{HASH}` values. It returns the full 40 uppercase SHA-1 hex chars — no truncation, no prefix. Never compute a hash by hand: a hand-made page name lands the content on a page nobody else reads.
3. To update a contents page (`*_CONTENTS`): `wiki-get` it first, apply the template to append or modify, then `wiki-put` the whole page back. Never overwrite entries owned by others. 3. **A contents page (`*_CONTENTS`) is one H2 block per record, never a table.** The H2 heading is that record's key, written as the content page's own name (`{TYPE}_{HASH}`) — no link, no URL, no prefix, no date. Every field is one bullet under it, `- {field}:{value}`, full-width colon, one bullet per field including the key's own. Update it with `tools/wiki-contents.sh upsert {TYPE} {key-col} {key} {entry-file} [template-file]`, where `{entry-file}` holds that whole H2 block. The script reads the page, converts a page still holding a markdown table into H2 blocks first, replaces the block whose heading equals `{key}`, appends the block when no heading matches, and writes the whole page back through the same confirmation. `{key-col}` is used only by that conversion: it is the 1-based position of the old table's identity column, and it is ignored once the page is already in block form. That conversion takes the heading from the identity cell's markdown link — the URL's last path segment, percent-decoded, because the link text is often a work-package or plan name rather than the page name — and from the cell's plain text, backticks stripped, only when the cell holds no link; it never checks the result against the page-name pattern, since old timestamped names, new 40-char names, and plain `{owner}/{repo}` identities all appear online. **That conversion is also the one moment the intro may be rewritten:** when `{template-file}` is given, everything before the first `## ` — the H1, the `>` intro, the blank lines between them — is replaced by the template's own preamble, taken the same way and carrying none of the template's demo blocks. Why: the conversion moves the table and leaves the prose, so an intro still saying "one row per repository" outlives the rows it describes, and the template holds the canonical wording. A page already in block form keeps its intro exactly as its owner wrote it — that call only updates its own record — and a conversion run without a template keeps the old intro, there being no canonical copy to install. Never overwrite entries owned by others. Whether the page may be created from the template instead is decided by rule 4, and by nothing else.
4. Write all wiki content in UTF-8 Traditional Chinese, per the STE100 output rule. 4. **Only exit 4 means the page is not there yet — this rule binds every "create it if it does not exist" path, without exception.** It is not limited to contents pages: a content page (`*_{HASH}`), a work log, an error page, a report, any page at all, follows the same branch.
5. Prefer visual forms for page content: use mermaid diagrams (flowchart, sequence, gantt, pie) and markdown tables wherever the information allows. Plain running text is the last resort, kept short. - **Correct branch.** Read the page with `wiki-get`. Exit 0 means the page exists, so append or modify the content that came back and `wiki-put` the whole page. Exit 4 (HTTP 404) is the one and only code that permits creating a new page from the template.
6. Authentication fallback is built into `tools/gitea.sh`: on a missing GITEA_TOKEN or a 401/403 response it retries with the tea CLI login token automatically. But before calling it, resolve `GITEA_HOST` and `GITEA_TOKEN` per rule 1: if `GITEA_HOST` is unresolvable, or `GITEA_TOKEN` is unset and no tea login token exists either, ask the user for the missing value per the `jsc-ask:ask` rules — same decision-tree pattern as the missing-wiki-repo case above — before calling `tools/gitea.sh`. Only report a genuine failure when the user has no answer to give or Gitea itself rejects the request (e.g. a 401/403 even after the tea fallback). - **Exit 7 and exit 8 abort.** Exit 7 (HTTP 401 or 403) and exit 8 (any other API failure) both mean the old content is unknown, never that the page is missing. Stop the operation and report the exit code with its cause. Create no page, write nothing, and do not retry the same call unchanged.
- **Why.** Wiki writes in this skill set are append-not-overwrite, and that semantics rests entirely on reading the old page back first. Reading a 401 as a 404 makes the caller believe it holds a brand-new page and `wiki-put` a fresh template over a live one, and the whole earlier record is gone — the write carries no merge and no backup.
- `tools/wiki-contents.sh` implements exactly this branch for contents pages and reports the same codes.
5. Write all wiki content in UTF-8 Traditional Chinese, per the STE100 output rule.
6. **Chart-first applies to content pages (`*_{HASH}`) only.** On a content page, prefer visual forms: use mermaid diagrams (flowchart, sequence, gantt, pie) and markdown tables wherever the information allows. Prose is capped at 3 sentences per section, and a sentence stays only when neither a mermaid diagram nor a markdown table can carry the same information. **A contents page (`*_CONTENTS`) always takes the H2-heading-plus-bullets form of rule 3 instead** — no table, and no diagram, whatever the field count. Why: the heading is the key the upsert matches on, so the layout is fixed by the tool, not by which form reads better.
7. `tools/gitea.sh` retries once with the tea CLI login token when `GITEA_TOKEN` is missing or the response is 401/403. Report a failure only after that retry also fails.
8. **Rule A — every link is written as `[text](absolute URL)`.** That is the only form. `[[page]]` and `[[display|page]]` are gone, and there is no longer a same-repo case that keeps them. The URL always comes from `tools/gitea.sh wiki-url {owner}/{repo} {page}`, never from a path built by hand. Why: `[[...]]` resolves only inside the current wiki, so a cross-repo link silently lands on a same-named page in this one — and it fails as plain text or a dead link, with nothing to catch it. Contents pages and content pages already live in different repos, so keeping two forms would mean judging, link by link, which repo each end resolves to.
9. **Rule B — check every link before the write.** Run `tools/link-check.sh {url}...` over every link that is going into the page. Exit 0 is the only code that opens `wiki-put`. Exit 1 means at least one link is dead: write nothing, and hand the caller the `DEAD` rows. Exit 7 means the key failed, not that the pages are gone — stop and report the key problem. Checking after the write is not the same thing: the dead link is already published, and the next reader follows it.
10. `wiki-delete` removes a page for good. Call it only from `tools/migrate-wiki.sh --apply`, or when the user has asked for that exact page to go. In a migration the delete comes last: write the new page, read it back, then delete the old one.
+131
View File
@@ -0,0 +1,131 @@
/* 共用排版:所有版型與風格都吃這一份,顏色與字型一律走 CSS 變數,由風格檔決定。 */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 16px;
line-height: 1.75;
-webkit-text-size-adjust: 100%;
}
.page-head {
padding: 2.5rem 0 1.5rem;
border-bottom: 2px solid var(--accent);
}
.page-head h1 {
margin: 0 0 .35rem;
font-family: var(--font-head);
font-size: 2rem;
line-height: 1.3;
color: var(--head-fg);
}
.subtitle { margin: 0 0 .5rem; color: var(--muted); font-size: 1.05rem; }
.subtitle:empty { display: none; }
.meta {
margin: 0;
color: var(--muted);
font-size: .85rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.meta a.source { color: var(--accent); text-decoration: none; word-break: break-all; }
.meta a.source:hover { text-decoration: underline; }
.content { padding: 1.5rem 0 3rem; }
.content h1, .content h2, .content h3, .content h4 {
font-family: var(--font-head);
color: var(--head-fg);
line-height: 1.35;
margin: 2rem 0 .75rem;
}
.content h1 { font-size: 1.7rem; }
.content h2 { font-size: 1.4rem; }
.content h3 { font-size: 1.15rem; }
.content h4 { font-size: 1rem; }
.content p { margin: 0 0 1rem; }
.content ul, .content ol { margin: 0 0 1rem; padding-left: 1.5rem; }
.content li { margin: .25rem 0; }
.content li input[type="checkbox"] { margin-right: .4rem; }
.content a { color: var(--accent); text-decoration: none; }
.content a:hover { text-decoration: underline; }
.content blockquote {
margin: 1rem 0;
padding: .6rem 1rem;
border-left: 4px solid var(--accent);
background: var(--card);
color: var(--muted);
}
.content code {
font-family: var(--font-mono);
font-size: .9em;
background: var(--code-bg);
padding: .15em .4em;
border-radius: 4px;
}
.content pre {
margin: 0 0 1rem;
padding: 1rem;
overflow-x: auto;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.content pre code { background: none; padding: 0; }
/* 表格一律可橫向捲動,寬表格不會把整頁撐開 */
.table-wrap { overflow-x: auto; margin: 0 0 1.25rem; }
.content table {
border-collapse: collapse;
width: 100%;
font-size: .95rem;
}
.content th, .content td {
border: 1px solid var(--border);
padding: .5rem .7rem;
text-align: left;
vertical-align: top;
}
.content th { background: var(--table-head-bg); color: var(--head-fg); font-weight: 600; }
.content tbody tr:nth-child(even) { background: var(--stripe); }
.content img { max-width: 100%; height: auto; }
.content hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
.page-foot {
padding: 1.25rem 0 2rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: .8rem;
display: flex;
flex-wrap: wrap;
gap: .75rem;
justify-content: space-between;
}
.badge {
display: inline-block;
padding: .1rem .5rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: .75rem;
color: var(--muted);
}
+63
View File
@@ -0,0 +1,63 @@
// 共用行為:表格加捲動外框、依 h2 切段、產生目錄。各版型自己決定要用哪幾個。
(function (w) {
'use strict';
// 寬表格包一層可橫捲的外框,整頁就不會被撐開。
function wrapTables(root) {
root.querySelectorAll('table').forEach(function (t) {
if (t.parentElement && t.parentElement.classList.contains('table-wrap')) return;
var box = document.createElement('div');
box.className = 'table-wrap';
t.parentNode.insertBefore(box, t);
box.appendChild(t);
});
}
// 依 h2 把內容切成一段一段。h2 之前的內容自成第一段(前言)。
function splitBySection(root) {
var nodes = Array.prototype.slice.call(root.childNodes);
var sections = [];
var current = null;
function open(headingText) {
current = document.createElement('section');
current.className = 'jsc-section';
current.dataset.title = headingText || '';
sections.push(current);
}
nodes.forEach(function (node) {
if (node.nodeType === 1 && node.tagName === 'H2') {
open(node.textContent.trim());
} else if (!current) {
if (node.nodeType === 3 && !node.textContent.trim()) return;
open('');
}
current.appendChild(node);
});
root.innerHTML = '';
sections.forEach(function (s) { root.appendChild(s); });
return sections;
}
// 依 h2、h3 產生目錄,塞進指定容器。標題沒有 id 就補一個。
function buildToc(root, target) {
var heads = root.querySelectorAll('h2, h3');
if (!heads.length) { target.remove(); return; }
var list = document.createElement('ul');
heads.forEach(function (h, i) {
if (!h.id) h.id = 'sec-' + (i + 1);
var li = document.createElement('li');
li.className = 'toc-' + h.tagName.toLowerCase();
var a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
target.appendChild(list);
}
w.jsc = { wrapTables: wrapTables, splitBySection: splitBySection, buildToc: buildToc };
})(window);
+52
View File
@@ -0,0 +1,52 @@
<!-- 看板:每個 h2 一張卡片並排,適合進度、狀態、維護清單 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
.shell { max-width: 1280px; margin: 0 auto; padding: 0 1.25rem; }
.content { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.25rem; align-items: start; }
.jsc-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
overflow: hidden;
}
.jsc-section h2 { margin-top: 0; font-size: 1.15rem; }
.jsc-section table { font-size: .88rem; background: var(--bg); }
.jsc-section pre { background: var(--bg); }
/* 只有一段內容時不要孤零零一張窄卡片 */
.content.single { grid-template-columns: minmax(0, 1fr); }
@media print { .content { display: block; } .jsc-section { page-break-inside: avoid; margin-bottom: 1rem; } }
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<main class="content" id="content">{{CONTENT}}</main>
<footer class="page-foot">
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span>本頁由 jsc-gitea:html-export 產生</span>
</footer>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
var cards = jsc.splitBySection(content);
if (cards.length < 2) content.classList.add('single');
})();
</script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!-- 單頁摘要:窄欄、重點框,適合議題摘要與會議結論,印出來剛好一頁 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
.shell { max-width: 760px; margin: 0 auto; padding: 0 1.25rem; }
.page-head { padding-top: 2rem; }
.content { font-size: .97rem; }
.content h2 { font-size: 1.15rem; margin-top: 1.4rem; }
.content h3 { font-size: 1rem; }
.content ul { padding-left: 1.2rem; }
/* 待辦清單獨立成框,一眼看得到還有什麼沒做 */
.content li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.2rem; }
.content blockquote { font-size: .95rem; }
.jsc-section + .jsc-section { border-top: 1px dashed var(--border); padding-top: .75rem; }
@media print { body { font-size: 13px; } .page-foot { display: none; } }
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<main class="content" id="content">{{CONTENT}}</main>
<footer class="page-foot">
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span>本頁由 jsc-gitea:html-export 產生</span>
</footer>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
jsc.splitBySection(content);
})();
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!-- 報告書:左側目錄加章節內文,適合分析頁、交付頁這類長文件 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
.shell { max-width: 1080px; margin: 0 auto; padding: 0 1.25rem; }
.body-grid { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 2.5rem; }
.toc {
position: sticky;
top: 1.5rem;
align-self: start;
max-height: calc(100vh - 3rem);
overflow-y: auto;
padding: 1.25rem 0;
font-size: .9rem;
}
.toc h2 { margin: 0 0 .5rem; font-size: .8rem; letter-spacing: .12em; color: var(--muted); text-transform: uppercase; }
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc li { margin: .2rem 0; }
.toc a { color: var(--fg); text-decoration: none; display: block; padding: .15rem .5rem; border-left: 2px solid var(--border); }
.toc a:hover { color: var(--accent); border-left-color: var(--accent); }
.toc .toc-h3 a { padding-left: 1.25rem; color: var(--muted); }
@media (max-width: 860px) {
.body-grid { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.toc { position: static; max-height: none; border-bottom: 1px solid var(--border); }
}
@media print { .toc { display: none; } .body-grid { display: block; } }
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<div class="body-grid">
<nav class="toc" id="toc"><h2>目錄</h2></nav>
<main class="content" id="content">{{CONTENT}}</main>
</div>
<footer class="page-foot">
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span>本頁由 jsc-gitea:html-export 產生</span>
</footer>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
jsc.buildToc(content, document.getElementById('toc'));
})();
</script>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!-- 投影片:一個 h2 一張,鍵盤左右鍵翻頁,適合簡報與匯報 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
body { display: flex; flex-direction: column; min-height: 100vh; }
.shell { flex: 1; display: flex; flex-direction: column; max-width: 1200px; width: 100%; margin: 0 auto; padding: 0 1.5rem; }
.page-head { padding: 1.5rem 0 1rem; }
.page-head h1 { font-size: 1.5rem; }
.content { flex: 1; padding: 0; }
.jsc-section { display: none; padding: 1.5rem 0; }
.jsc-section.is-active { display: block; animation: fade .18s ease-out; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.jsc-section h2 { margin-top: 0; font-size: 2rem; }
.jsc-section p, .jsc-section li { font-size: 1.15rem; }
.deck-bar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem 0 1.25rem;
background: var(--bg);
border-top: 1px solid var(--border);
}
.deck-bar button {
font: inherit;
color: var(--fg);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .35rem .9rem;
cursor: pointer;
}
.deck-bar button:hover { border-color: var(--accent); color: var(--accent); }
.deck-pos { color: var(--muted); font-size: .9rem; margin-left: auto; }
@media print {
.jsc-section, .jsc-section.is-active { display: block; page-break-after: always; }
.deck-bar { display: none; }
}
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<main class="content" id="content">{{CONTENT}}</main>
<div class="deck-bar">
<button type="button" id="prev">← 上一頁</button>
<button type="button" id="next">下一頁 →</button>
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span class="deck-pos" id="pos"></span>
</div>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
var slides = jsc.splitBySection(content);
var at = 0;
var pos = document.getElementById('pos');
function show(i) {
if (!slides.length) return;
at = Math.max(0, Math.min(slides.length - 1, i));
slides.forEach(function (s, n) { s.classList.toggle('is-active', n === at); });
pos.textContent = (at + 1) + ' / ' + slides.length;
window.scrollTo({ top: 0 });
}
document.getElementById('prev').addEventListener('click', function () { show(at - 1); });
document.getElementById('next').addEventListener('click', function () { show(at + 1); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'PageUp') show(at - 1);
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') show(at + 1);
});
show(0);
})();
</script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!-- 規格書:表格與程式碼區塊放大,表頭固定,適合 API 文件與交付規格 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
.shell { max-width: 1120px; margin: 0 auto; padding: 0 1.25rem; }
.body-grid { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 2rem; }
.toc { position: sticky; top: 1.5rem; align-self: start; padding: 1.25rem 0; font-size: .88rem; }
.toc h2 { margin: 0 0 .5rem; font-size: .78rem; letter-spacing: .12em; color: var(--muted); text-transform: uppercase; }
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc a { color: var(--fg); text-decoration: none; display: block; padding: .15rem .4rem; }
.toc a:hover { color: var(--accent); }
.toc .toc-h3 a { padding-left: 1.1rem; color: var(--muted); }
.jsc-section { border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; margin-bottom: 1.5rem; }
.jsc-section:last-child { border-bottom: 0; }
.content .table-wrap { max-height: 70vh; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); }
.content .table-wrap table { border: 0; }
.content .table-wrap th { position: sticky; top: 0; z-index: 1; }
.content code { font-size: .92em; }
.content pre { font-size: .95rem; line-height: 1.6; }
/* 欄位表第一欄是欄位名,等寬字比較好對 */
.content td:first-child code { white-space: nowrap; }
@media (max-width: 860px) { .body-grid { grid-template-columns: minmax(0, 1fr); } .toc { position: static; } }
@media print { .toc { display: none; } .body-grid { display: block; } .content .table-wrap { max-height: none; } }
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<div class="body-grid">
<nav class="toc" id="toc"><h2>章節</h2></nav>
<main class="content" id="content">{{CONTENT}}</main>
</div>
<footer class="page-foot">
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span>本頁由 jsc-gitea:html-export 產生</span>
</footer>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
jsc.buildToc(content, document.getElementById('toc'));
jsc.splitBySection(content);
})();
</script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!-- 時間軸:每個 h2 一個節點串成一條線,適合工作日誌與里程碑 -->
<!doctype html>
<html lang="zh-Hant" data-layout="{{LAYOUT}}" data-style="{{STYLE_NAME}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>{{BASE}}</style>
<style>{{STYLE}}</style>
<style>
.shell { max-width: 900px; margin: 0 auto; padding: 0 1.25rem; }
.content { position: relative; padding-left: 2rem; }
.content::before {
content: "";
position: absolute;
left: .45rem;
top: .75rem;
bottom: .75rem;
width: 2px;
background: var(--border);
}
.jsc-section { position: relative; padding: 0 0 1.75rem; }
.jsc-section::before {
content: "";
position: absolute;
left: -1.9rem;
top: .95rem;
width: .85rem;
height: .85rem;
border-radius: 50%;
background: var(--accent);
border: 3px solid var(--bg);
}
.jsc-section h2 { margin-top: .5rem; font-size: 1.2rem; }
.jsc-section:last-child { padding-bottom: .5rem; }
@media print { .jsc-section { page-break-inside: avoid; } }
</style>
</head>
<body>
<div class="shell">
<header class="page-head">
<h1>{{TITLE}}</h1>
<p class="subtitle">{{SUBTITLE}}</p>
<p class="meta">{{SOURCE}}<span class="generated">產生時間:{{GENERATED}}</span></p>
</header>
<main class="content" id="content">{{CONTENT}}</main>
<footer class="page-foot">
<span class="badge">{{LAYOUT}}/{{STYLE_NAME}}</span>
<span>本頁由 jsc-gitea:html-export 產生</span>
</footer>
</div>
<script>{{BASE_JS}}</script>
<script>
(function () {
var content = document.getElementById('content');
jsc.wrapTables(content);
jsc.splitBySection(content);
})();
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
/* 商務:深藍主色、表頭反白、正式對外用 */
:root {
--bg: #ffffff;
--fg: #22272e;
--head-fg: #0b3358;
--muted: #5c6b7a;
--accent: #0b5fa5;
--border: #c9d6e2;
--card: #eef4fa;
--code-bg: #eef2f6;
--table-head-bg: #0b3358;
--stripe: #f4f8fc;
--radius: 6px;
--shadow: 0 1px 2px rgba(11, 51, 88, .12);
--font: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
--font-head: var(--font);
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.page-head { border-bottom-width: 3px; }
.content th { color: #ffffff; letter-spacing: .03em; }
.content h2 { border-left: 5px solid var(--accent); padding-left: .6rem; }
.content table { box-shadow: var(--shadow); }
+21
View File
@@ -0,0 +1,21 @@
/* 深色:深底亮字,長時間閱讀與投影機環境 */
:root {
--bg: #11161c;
--fg: #d7dee6;
--head-fg: #f2f6fa;
--muted: #8b98a6;
--accent: #56a8f5;
--border: #2b3540;
--card: #1a2129;
--code-bg: #1c242d;
--table-head-bg: #1f2932;
--stripe: #161d24;
--radius: 6px;
--shadow: 0 1px 3px rgba(0, 0, 0, .5);
--font: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
--font-head: var(--font);
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.content pre { border-color: #2b3540; }
.content h2 { border-bottom: 1px solid var(--border); padding-bottom: .3rem; }
+21
View File
@@ -0,0 +1,21 @@
/* 極簡:白底、細線、無襯線,資訊密度優先 */
:root {
--bg: #ffffff;
--fg: #1f2328;
--head-fg: #0d1117;
--muted: #6a737d;
--accent: #2f6f9f;
--border: #d8dee4;
--card: #f6f8fa;
--code-bg: #f2f4f7;
--table-head-bg: #f6f8fa;
--stripe: #fbfcfd;
--radius: 4px;
--shadow: none;
--font: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
--font-head: var(--font);
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.page-head { border-bottom-width: 1px; }
.content h2 { border-bottom: 1px solid var(--border); padding-bottom: .3rem; }
+30
View File
@@ -0,0 +1,30 @@
/* 印刷:襯線字、A4 邊界、去掉陰影,列印或轉 PDF 用 */
:root {
--bg: #ffffff;
--fg: #1b1b1b;
--head-fg: #000000;
--muted: #55555f;
--accent: #4a4a4a;
--border: #b8b8b8;
--card: #f4f4f2;
--code-bg: #f2f2f0;
--table-head-bg: #ececeb;
--stripe: #f9f9f8;
--radius: 0;
--shadow: none;
--font: "Noto Serif TC", "Songti TC", "PMingLiU", Georgia, serif;
--font-head: var(--font);
--font-mono: "JetBrains Mono", Consolas, monospace;
}
@page { size: A4; margin: 20mm 18mm; }
body { font-size: 15px; line-height: 1.85; }
.page-head { border-bottom: 1px solid var(--border); }
.content h2 { page-break-after: avoid; }
.content table, .content pre, .content blockquote { page-break-inside: avoid; }
@media print {
.meta a.source { color: var(--fg); }
.page-foot { border-top: 1px solid var(--border); }
}
+34
View File
@@ -0,0 +1,34 @@
/* 明亮:高彩度、圓角卡片、漸層標題,簡報與對內宣達用 */
:root {
--bg: #fdfbff;
--fg: #241f2e;
--head-fg: #4c1d95;
--muted: #6d6480;
--accent: #7c3aed;
--border: #e2d9f5;
--card: #f6f1ff;
--code-bg: #f1ecfd;
--table-head-bg: #ede4ff;
--stripe: #faf7ff;
--radius: 12px;
--shadow: 0 2px 10px rgba(124, 58, 237, .12);
--font: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
--font-head: var(--font);
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.page-head {
border-bottom: 0;
background: linear-gradient(135deg, #7c3aed 0%, #e0378f 100%);
color: #ffffff;
border-radius: var(--radius);
padding: 2rem 1.5rem;
box-shadow: var(--shadow);
}
.page-head h1, .page-head .subtitle, .page-head .meta { color: #ffffff; }
.page-head .meta a.source { color: #ffffff; text-decoration: underline; }
.content h2 { border-left: 6px solid var(--accent); padding-left: .6rem; }
.content table { border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
.content blockquote { border-radius: var(--radius); }
+518
View File
@@ -0,0 +1,518 @@
#!/usr/bin/env sh
# check-contents-format.sh — 驗證目錄頁的區塊轉檔與 upsert 規則。
# 全部走 wiki-contents.sh 的 format 子命令,只讀寫暫存檔,不打任何 API。
# 涵蓋五種舊頁狀態:純表格、已是條列且鍵命中、已是條列且鍵未命中、表格與區塊混合、
# 用範本建新頁。另外驗三件事:表格取不出鍵那一列要擋下來不猜標題、轉檔後重跑同一筆
# 結果一字不變、區塊檔沒帶標題也照樣補上。
# 轉檔取 H2 標題另外驗四種身分欄:連結文字不是頁名、連結文字剛好等於頁名、純文字沒有
# 連結、網址帶百分號編碼。四種都要取到真正的頁名,呼叫端那一筆才會是 updated。
# 引言另外驗五種情形:轉檔且有範本要換成範本那一份、轉檔但沒範本保留舊的、已是條列
# 加了範本也不准動引言、--fresh 建新頁照舊、拿轉檔結果重跑引言不再變動。
# 全部通過印 OK 並 exit 0;任一項不符印出 want 與 got 並 exit 1。
# 結束碼: 0=全部通過,stdout 印 OK
# 1=有一項不符,stderr 印出 {項目}: want=… got=… 之後立刻停住,不續跑其餘項目。
# 只有 0 與 1 兩種;這支不吃參數,也沒有用法錯誤那條路。
set -eu
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
contents="$dir/wiki-contents.sh"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
printf '%s\n' "$1" >&2
exit 1
}
expect_eq() { # got want label
[ "$1" = "$2" ] || fail "$(printf '%s: want=\n%s\ngot=\n%s' "$3" "$2" "$1")"
}
# run <label> <keycol> <key> <fresh|keep> [範本檔] — 讀 $work/old 與 $work/entry,
# 結果留在 $work/new,動作留在 $work/action。
run() {
rc=0
if [ "$4" = fresh ]; then
sh "$contents" format "$2" "$3" "$work/entry" "$work/old" "$work/new" --fresh > "$work/action" || rc=$?
elif [ -n "${5-}" ]; then
sh "$contents" format "$2" "$3" "$work/entry" "$work/old" "$work/new" "$5" > "$work/action" || rc=$?
else
sh "$contents" format "$2" "$3" "$work/entry" "$work/old" "$work/new" > "$work/action" || rc=$?
fi
[ "$rc" -eq 0 ] || fail "$1: want exit=0 got exit=$rc"
}
# ---- 1. 純表格舊頁:整頁轉成區塊,再換掉鍵相同那一筆 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 由 `jsc-log:worklog` 維護。一個區塊代表一個日誌頁。
| 日誌頁 | 存取庫 | 條目數 |
| --- | --- | --- |
| [LOG_AAA](https://example.test/wiki/LOG_AAA) | plugins/meta | 3 |
| [LOG_BBB](https://example.test/wiki/LOG_BBB) | plugins/ask | 5 |
EOF
cat > "$work/entry" <<'EOF'
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9
EOF
run 'table page' 1 LOG_BBB keep
expect_eq "$(cat "$work/action")" updated 'table page action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 由 `jsc-log:worklog` 維護。一個區塊代表一個日誌頁。
## LOG_AAA
- 日誌頁:[LOG_AAA](https://example.test/wiki/LOG_AAA)
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9' 'table page'
# ---- 2. 已是條列,鍵命中:只換那一塊,別人那一筆一字不動 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 引言。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 存取庫:plugins/ask
- 條目數:5
EOF
cat > "$work/entry" <<'EOF'
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:7
EOF
run 'list hit' 1 LOG_AAA keep
expect_eq "$(cat "$work/action")" updated 'list hit action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 引言。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:7
## LOG_BBB
- 存取庫:plugins/ask
- 條目數:5' 'list hit'
# ---- 3. 已是條列,鍵未命中:附加到頁尾,前面的區塊照舊 ----
cat > "$work/entry" <<'EOF'
## LOG_CCC
- 存取庫:plugins/git
- 條目數:1
EOF
run 'list miss' 1 LOG_CCC keep
expect_eq "$(cat "$work/action")" added 'list miss action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 引言。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 存取庫:plugins/ask
- 條目數:5
## LOG_CCC
- 存取庫:plugins/git
- 條目數:1' 'list miss'
# ---- 4. 表格與區塊混合:表格轉出來的區塊接在既有區塊後面 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 引言。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
| 日誌頁 | 存取庫 |
| --- | --- |
| [LOG_BBB](https://example.test/wiki/LOG_BBB) | plugins/ask |
EOF
cat > "$work/entry" <<'EOF'
## LOG_CCC
- 日誌頁:[LOG_CCC](https://example.test/wiki/LOG_CCC)
- 存取庫:plugins/git
EOF
run 'mixed page' 1 LOG_CCC keep
expect_eq "$(cat "$work/action")" added 'mixed page action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 引言。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
## LOG_CCC
- 日誌頁:[LOG_CCC](https://example.test/wiki/LOG_CCC)
- 存取庫:plugins/git' 'mixed page'
# ---- 5. 用範本建新頁:示範區塊與示範表格都要剝掉,只留 H1 與引言 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 由 `jsc-log:worklog` 維護。一個區塊代表一個日誌頁。
## {日誌頁頁名}
- 存取庫:{owner}/{repo}
- 條目數:{數字}
EOF
cat > "$work/entry" <<'EOF'
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
EOF
run 'fresh page' 1 LOG_AAA fresh
expect_eq "$(cat "$work/action")" added 'fresh page action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 由 `jsc-log:worklog` 維護。一個區塊代表一個日誌頁。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3' 'fresh page'
# ---- 6. 表格那一列取不出鍵:擋下來回 1,不猜標題 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 引言。
| 日誌頁 | 存取庫 |
| --- | --- |
| | plugins/ask |
EOF
cat > "$work/entry" <<'EOF'
## LOG_AAA
- 存取庫:plugins/meta
EOF
if sh "$contents" format 1 LOG_AAA "$work/entry" "$work/old" "$work/new" >/dev/null 2>&1; then
code=0
else
code=$?
fi
[ "$code" -eq 1 ] || fail "keyless table row: want exit=1 got exit=$code"
# ---- 7. 轉檔後重跑同一筆:結果一字不變,程式碼圍欄不被當成表格拆掉 ----
cat > "$work/old" <<'EOF'
# 監控目錄
> 引言。
```mermaid
flowchart LR
A --> B
```
| 監控頁 | 主機 |
| --- | --- |
| `MONITOR_AAA` | myhost |
EOF
cat > "$work/entry" <<'EOF'
## MONITOR_AAA
- 監控頁:[MONITOR_AAA](https://example.test/wiki/MONITOR_AAA)
- 主機:myhost
EOF
run 'converted once' 1 MONITOR_AAA keep
expect_eq "$(cat "$work/action")" updated 'converted once action'
expect_eq "$(cat "$work/new")" '# 監控目錄
> 引言。
```mermaid
flowchart LR
A --> B
```
## MONITOR_AAA
- 監控頁:[MONITOR_AAA](https://example.test/wiki/MONITOR_AAA)
- 主機:myhost' 'converted once'
cp "$work/new" "$work/old"
run 'converted twice' 1 MONITOR_AAA keep
expect_eq "$(cat "$work/action")" updated 'converted twice action'
expect_eq "$(cat "$work/new")" "$(cat "$work/old")" 'converted twice'
# ---- 8. 區塊檔沒帶「## 」標題:照樣補上,鍵就是標題 ----
cat > "$work/old" <<'EOF'
# 監控目錄
> 引言。
EOF
cat > "$work/entry" <<'EOF'
- 主機:myhost
EOF
run 'headless entry' 1 MONITOR_BBB keep
expect_eq "$(cat "$work/action")" added 'headless entry action'
expect_eq "$(cat "$work/new")" '# 監控目錄
> 引言。
## MONITOR_BBB
- 主機:myhost' 'headless entry'
# ---- 9. 連結文字不是頁名:標題取網址最後一段,這一筆算 updated 不是 added ----
# 頁名一律走變數帶進來。這支腳本的每一行「## 開頭」都會被註解掃描器當成註解讀,
# 頁名直接寫在那種行上就會被判成註解夾帶頁面編號。
page='ANALYZE_20260821_100552_104F0709'
cat > "$work/old" <<EOF
# 分析目錄
> 引言。
| 計畫名稱 | 分析頁 | HASH |
| --- | --- | --- |
| 某計畫 | [假 CLI 核心程式庫](https://example.test/knowledges/ANALYZE/wiki/$page) | 104F0709 |
EOF
printf '## %s\n\n- 計畫名稱:某計畫\n' "$page" > "$work/entry"
run 'link text differs' 2 "$page" keep
expect_eq "$(cat "$work/action")" updated 'link text differs action'
expect_eq "$(cat "$work/new")" "$(printf '# 分析目錄\n\n> 引言。\n\n## %s\n\n- 計畫名稱:某計畫' "$page")" 'link text differs'
# ---- 10. 轉檔後重跑同一筆:仍是 updated,不多出區塊 ----
cp "$work/new" "$work/old"
run 'link text differs twice' 2 "$page" keep
expect_eq "$(cat "$work/action")" updated 'link text differs twice action'
expect_eq "$(cat "$work/new")" "$(cat "$work/old")" 'link text differs twice'
# ---- 11. 連結文字剛好等於頁名:結果與連結文字不是頁名時一致 ----
page='PLAN_104F0709'
cat > "$work/old" <<EOF
# 計畫目錄
> 引言。
| 計畫頁 | 狀態 |
| --- | --- |
| [$page](https://example.test/knowledges/PLAN/wiki/$page) | 進行中 |
EOF
printf '## %s\n\n- 狀態:已完成\n' "$page" > "$work/entry"
run 'link text equals page' 1 "$page" keep
expect_eq "$(cat "$work/action")" updated 'link text equals page action'
expect_eq "$(cat "$work/new")" "$(printf '# 計畫目錄\n\n> 引言。\n\n## %s\n\n- 狀態:已完成' "$page")" 'link text equals page'
# ---- 12. 身分欄是純文字沒有連結:標題就是那段文字 ----
page='plugins/meta'
cat > "$work/old" <<EOF
# 維護目錄
> 引言。
| 存取庫 | 維護期限 |
| --- | --- |
| \`$page\` | 2026-12-31 |
EOF
printf '## %s\n\n- 維護期限:2027-06-30\n' "$page" > "$work/entry"
run 'plain identity cell' 1 "$page" keep
expect_eq "$(cat "$work/action")" updated 'plain identity cell action'
expect_eq "$(cat "$work/new")" "$(printf '# 維護目錄\n\n> 引言。\n\n## %s\n\n- 維護期限:2027-06-30' "$page")" 'plain identity cell'
# ---- 13. 網址帶百分號編碼:解碼後取最後一段 ----
page='ANALYZE_中文'
cat > "$work/old" <<'EOF'
# 分析目錄
> 引言。
| 分析頁 | HASH |
| --- | --- |
| [某工作包](https://example.test/knowledges/ANALYZE/wiki/ANALYZE_%E4%B8%AD%E6%96%87) | 104F0709 |
EOF
printf '## %s\n\n- HASH:104F0709\n' "$page" > "$work/entry"
run 'percent encoded url' 1 "$page" keep
expect_eq "$(cat "$work/action")" updated 'percent encoded url action'
expect_eq "$(cat "$work/new")" "$(printf '# 分析目錄\n\n> 引言。\n\n## %s\n\n- HASH:104F0709' "$page")" 'percent encoded url'
# ---- 14. 轉檔且有範本:引言換成範本那一份,紀錄區塊一筆不少、順序照舊 ----
# 範本的示範區塊不得混進來,所以範本檔也放一個示範區塊。
cat > "$work/tpl" <<'EOF'
# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一個區塊;頁面上不留 markdown 表格。
>
> 版面:H1 頁名、這段引言,然後一筆紀錄一個 H2 區塊。
## {日誌頁頁名}
- 存取庫:{owner}/{repo}
- 條目數:{數字}
EOF
cat > "$work/old" <<'EOF'
# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一列;`{HASH}` 為 SHA-1 前 8 碼。
| 日誌頁 | 存取庫 | 條目數 |
| --- | --- | --- |
| [LOG_AAA](https://example.test/wiki/LOG_AAA) | plugins/meta | 3 |
| [LOG_BBB](https://example.test/wiki/LOG_BBB) | plugins/ask | 5 |
EOF
cat > "$work/entry" <<'EOF'
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9
EOF
run 'converting with template' 1 LOG_BBB keep "$work/tpl"
expect_eq "$(cat "$work/action")" updated 'converting with template action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一個區塊;頁面上不留 markdown 表格。
>
> 版面:H1 頁名、這段引言,然後一筆紀錄一個 H2 區塊。
## LOG_AAA
- 日誌頁:[LOG_AAA](https://example.test/wiki/LOG_AAA)
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9' 'converting with template'
expect_eq "$(grep -c '^## ' "$work/new")" 2 'converting with template block count'
# ---- 15. 轉檔但沒給範本:沒有正本可換,引言保留舊的 ----
run 'converting without template' 1 LOG_BBB keep
expect_eq "$(cat "$work/action")" updated 'converting without template action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一列;`{HASH}` 為 SHA-1 前 8 碼。
## LOG_AAA
- 日誌頁:[LOG_AAA](https://example.test/wiki/LOG_AAA)
- 存取庫:plugins/meta
- 條目數:3
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9' 'converting without template'
# ---- 16. 已是條列還給了範本:不必轉檔,引言一字不動 ----
# 這一筆只是更新自己那一塊,沒有理由改掉別人寫的散文。
cat > "$work/old" <<'EOF'
# 日誌目錄
> 這段引言是頁面主人自己寫的,不准被範本蓋掉。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
EOF
cat > "$work/entry" <<'EOF'
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:7
EOF
run 'list page with template' 1 LOG_AAA keep "$work/tpl"
expect_eq "$(cat "$work/action")" updated 'list page with template action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 這段引言是頁面主人自己寫的,不准被範本蓋掉。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:7' 'list page with template'
# ---- 17. --fresh 建新頁:整份用範本,示範區塊剝掉,行為與加了範本參數之前一樣 ----
cp "$work/tpl" "$work/old"
cat > "$work/entry" <<'EOF'
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3
EOF
run 'fresh page with template intro' 1 LOG_AAA fresh
expect_eq "$(cat "$work/action")" added 'fresh page with template intro action'
expect_eq "$(cat "$work/new")" '# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一個區塊;頁面上不留 markdown 表格。
>
> 版面:H1 頁名、這段引言,然後一筆紀錄一個 H2 區塊。
## LOG_AAA
- 存取庫:plugins/meta
- 條目數:3' 'fresh page with template intro'
# ---- 18. 拿轉檔結果再跑同一筆:已經沒有表格,引言不再變動 ----
cat > "$work/old" <<'EOF'
# 日誌目錄
> 由 `jsc-log:worklog` 維護。每個存取庫一列;`{HASH}` 為 SHA-1 前 8 碼。
| 日誌頁 | 存取庫 | 條目數 |
| --- | --- | --- |
| [LOG_AAA](https://example.test/wiki/LOG_AAA) | plugins/meta | 3 |
| [LOG_BBB](https://example.test/wiki/LOG_BBB) | plugins/ask | 5 |
EOF
cat > "$work/entry" <<'EOF'
## LOG_BBB
- 日誌頁:[LOG_BBB](https://example.test/wiki/LOG_BBB)
- 存取庫:plugins/ask
- 條目數:9
EOF
run 'template intro once' 1 LOG_BBB keep "$work/tpl"
cp "$work/new" "$work/old"
run 'template intro twice' 1 LOG_BBB keep "$work/tpl"
expect_eq "$(cat "$work/action")" updated 'template intro twice action'
expect_eq "$(cat "$work/new")" "$(cat "$work/old")" 'template intro twice'
printf '%s\n' 'OK'
+96 -35
View File
@@ -1,10 +1,18 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# check-wiki-rules — 驗證 wiki repo 解析與 hash-id 規則。 # check-wiki-rules — 驗證 wiki repo 解析、hash-id 與頁名樣式規則。
# 涵蓋 TYPES 列的每一種頁面類型,每種三項:專用變數優先、退回共用變數、不得跨類型代用。
# 全部通過印 OK 並 exit 0;任一項不符印出差異並 exit 1。
# 結束碼: 0=全部通過,stdout 印 OK
# 1=有一項不符,stderr 印出 {項目}: want=… got=… 之後立刻停住,不續跑其餘項目。
# 只有 0 與 1 兩種;這支不吃參數,也沒有用法錯誤那條路。
set -eu set -eu
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
gitea="$dir/gitea.sh" gitea="$dir/gitea.sh"
hash_id="$dir/hash-id" hash_id="$dir/hash-id"
page_name="$dir/page-name.sh"
TYPES='QUESTION PLAN ANALYZE DELIVER MAINTAIN REPO LOG LEARN ERROR CHECK REPORT SKILLSET TOOLING MONITOR CONTENTS'
fail() { fail() {
printf '%s\n' "$1" >&2 printf '%s\n' "$1" >&2
@@ -18,7 +26,7 @@ expect_eq() {
[ "$got" = "$want" ] || fail "$label: want=$want got=$got" [ "$got" = "$want" ] || fail "$label: want=$want got=$got"
} }
check_repo() { check_repo() { # type label want [VAR=值...]
type=$1 type=$1
label=$2 label=$2
want=$3 want=$3
@@ -27,6 +35,19 @@ check_repo() {
expect_eq "$got" "$want" "$label" expect_eq "$got" "$want" "$label"
} }
check_no_repo() { # type label [VAR=值...]:期望 exit 3 且不印出任何 {owner}/{repo}
type=$1
label=$2
shift 2
if got=$(env -i PATH="${PATH:-/usr/bin:/bin}" "$@" "$gitea" wiki-repo "$type" 2>/dev/null); then
code=0
else
code=$?
fi
[ "$code" -eq 3 ] || fail "$label: want exit=3 got exit=$code output=$got"
[ -z "$got" ] || fail "$label: want no output got=$got"
}
check_hash() { check_hash() {
input=$1 input=$1
want=$2 want=$2
@@ -34,47 +55,87 @@ check_hash() {
expect_eq "$got" "$want" "hash-id $input" expect_eq "$got" "$want" "hash-id $input"
} }
check_repo REPO 'REPO specific wins' 'records/REPO' \ # 其餘每一個類型的誘餌值。跨類型代用一旦發生,回傳的就會是 decoy/{別的類型}。
JSC_WIKI_REPO_REPO='records/REPO' \ decoys() { # $1=要排除的類型
JSC_WIKI_REPO_ANALYZE='knowledges/ANALYZE' \ for t in $TYPES; do
JSC_WIKI_REPO='shared/wiki' [ "$t" = "$1" ] || printf 'JSC_WIKI_REPO_%s=decoy/%s ' "$t" "$t"
done
}
check_repo REPO 'REPO falls back to shared only' 'shared/wiki' \ for ty in $TYPES; do
JSC_WIKI_REPO_ANALYZE='knowledges/ANALYZE' \ # 1. 自己的變數贏過共用變數,也贏過其他類型的誘餌。
JSC_WIKI_REPO='shared/wiki' check_repo "$ty" "$ty specific wins" "own/$ty" \
$(decoys "$ty") "JSC_WIKI_REPO_$ty=own/$ty" JSC_WIKI_REPO='shared/wiki'
check_repo ANALYZE 'ANALYZE specific wins' 'knowledges/ANALYZE' \ # 2. 自己的變數未設定時,只退回共用變數。
JSC_WIKI_REPO_REPO='records/REPO' \ check_repo "$ty" "$ty falls back to shared only" 'shared/wiki' \
JSC_WIKI_REPO_ANALYZE='knowledges/ANALYZE' \ $(decoys "$ty") JSC_WIKI_REPO='shared/wiki'
JSC_WIKI_REPO='shared/wiki'
check_repo ANALYZE 'ANALYZE falls back to shared only' 'shared/wiki' \ # 3. 自己的變數與共用變數都沒有時,exit 3,不借用別的類型。
JSC_WIKI_REPO_REPO='records/REPO' \ check_no_repo "$ty" "$ty never borrows another type" $(decoys "$ty")
JSC_WIKI_REPO='shared/wiki' done
check_repo LEARN 'LEARN specific wins' 'lessons/LEARN' \ # 未知類型 exit 2,與「設定不足」的 exit 3 分開。
JSC_WIKI_REPO_LEARN='lessons/LEARN' \ if got=$(env -i PATH="${PATH:-/usr/bin:/bin}" JSC_WIKI_REPO='shared/wiki' \
JSC_WIKI_REPO='shared/wiki' "$gitea" wiki-repo NOSUCH 2>/dev/null); then
code=0
else
code=$?
fi
[ "$code" -eq 2 ] || fail "unknown type: want exit=2 got exit=$code"
[ -z "$got" ] || fail "unknown type: want no output got=$got"
check_repo LEARN 'LEARN falls back to shared only' 'shared/wiki' \ # hash-id:完整 40 碼大寫十六進位,不截短、不加前綴。
JSC_WIKI_REPO_LOG='records/LOG' \ check_hash 'case-2' '5172CB7A273D9C64ADBAAC70F321F2EDCD49C495'
JSC_WIKI_REPO='shared/wiki' check_hash 'case-11' 'A9A6662D94D9CC4BE593F3846DABE9AA147333E8'
check_hash 'case-1' 'B6EC7FD12D959BFFB08AC7F8B279B5255D55AA4C'
check_hash 'case-12' 'CCA8D4284394EBBA49F6493004ECAAE54FD74E0F'
check_hash 'case-3' 'D3E5AA27999009B42908E5BB7C1636BC14087D9C'
check_hash 'case-7' 'D794C0028687E7F4F3AEFF703276964E618F2EE3'
check_repo DELIVER 'DELIVER specific wins' 'handover/DELIVER' \ # hash-id 空輸入回 2。靜靜回傳空字串的 SHA-1 會生出一個看起來合法的頁名,
JSC_WIKI_REPO_DELIVER='handover/DELIVER' \ # 內容就寫到沒有人讀的那一頁。
JSC_WIKI_REPO='shared/wiki' if got=$(printf '' | "$hash_id" 2>/dev/null); then
code=0
else
code=$?
fi
[ "$code" -eq 2 ] || fail "hash-id empty input: want exit=2 got exit=$code output=$got"
[ -z "$got" ] || fail "hash-id empty input: want no output got=$got"
check_repo DELIVER 'DELIVER falls back to shared only' 'shared/wiki' \ # page-name.sh:四種合法尾段各一筆,不合法的一批。
JSC_WIKI_REPO_ANALYZE='knowledges/ANALYZE' \ check_page_ok() {
JSC_WIKI_REPO='shared/wiki' sh "$page_name" check "$1" >/dev/null 2>&1 || fail "page-name $1: want exit=0"
}
check_page_bad() {
if sh "$page_name" check "$1" >/dev/null 2>&1; then
code=0
else
code=$?
fi
[ "$code" -eq 1 ] || fail "page-name $1: want exit=1 got exit=$code"
}
check_repo ERROR 'ERROR uses its own repo' 'errors/wiki' \ check_page_ok 'CHECK_D3E5AA27999009B42908E5BB7C1636BC14087D9C'
JSC_WIKI_REPO_ERROR='errors/wiki' \ check_page_ok 'LOG_FB8DF0B5'
JSC_WIKI_REPO='shared/wiki' check_page_ok 'PLAN_CONTENTS'
check_hash 'case-2' 'H5172CB7' # H 開頭的舊頁名。舊演算法十六個首碼裡有十三個會改寫成 H 加前 7 碼,
check_hash 'case-11' 'HA9A6662' # 既有舊頁名多數長這樣。判成不合法,先驗頁名的技能就讀不到它們。
check_hash 'case-1' 'HB6EC7FD' check_page_ok 'LOG_H1A2B3C4'
check_hash 'case-12' 'HCCA8D42' check_page_ok 'CHECK_H87EC88F'
check_page_ok 'LOG_H87095D0'
# CONTENTS 只解存取庫,自己沒有頁。放行就會建出規格上不存在的那一頁。
check_page_bad 'CONTENTS_CONTENTS'
check_page_bad 'CONTENTS_D3E5AA27999009B42908E5BB7C1636BC14087D9C'
check_page_bad 'NOSUCH_CONTENTS'
check_page_bad 'PLAN_d3e5aa27'
check_page_bad 'PLAN_H87EC88'
check_page_bad 'PLAN'
# regex 子命令要印得出東西,頁名樣式的正本才只有這一份。
[ -n "$(sh "$page_name" regex)" ] || fail 'page-name regex: want a pattern'
printf '%s\n' 'OK' printf '%s\n' 'OK'
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env sh
# gitea-link.sh — 解析 Gitea 的 wiki 頁或議題連結(供 jsc-gitea:wiki-to-issue、html-export 使用)。
#
# 為什麼要有這支腳本:兩支技能都以「一條連結」為唯一入口,沒有連結就中斷。連結長什麼樣、
# 哪一段是存取庫、哪一段是頁名,是固定的字串規則,交給模型每次自己拆,拆錯就寫到別的存取庫去。
#
# 用法:
# gitea-link.sh parse <url> 解析連結,印出可直接 eval 的欄位
#
# parse 輸出(每行一個 key=value,值已加單引號):
# kind='wiki' repo='owner/repo' page='PAGE_NAME' host='https://gitea.example'
# kind='issue' repo='owner/repo' index='12' host='https://gitea.example'
#
# 認得的形式:
# https://host/{owner}/{repo}/wiki/{page} (page 可含 %XX 編碼,會還原)
# https://host/{owner}/{repo}/wiki/{page}/_edit (尾巴的 _edit、_new 會去掉)
# https://host/{owner}/{repo}/issues/{index}
# https://host/{owner}/{repo}/issues/{index}#issuecomment-123
#
# 結束碼: 0=解析成功 2=用法錯誤 3=不是認得的 wiki 或議題連結
#
# 陷阱:
# - 結束碼 3 由呼叫端當成「沒有連結」處理,直接中斷流程,不要退回去猜存取庫或頁名。
# - 只解析連結本身,不打網路,也不檢查頁面存不存在;那是 wiki-get 與 issue.sh 的事。
set -eu
usage() {
echo 'usage: gitea-link.sh parse <url>' >&2
exit 2
}
[ "$#" -eq 2 ] || usage
[ "$1" = parse ] || usage
url="$2"
[ -n "$url" ] || usage
python3 - "$url" <<'PY'
import re, sys
from urllib.parse import urlsplit, unquote
url = sys.argv[1].strip()
if not re.match(r'^https?://', url):
print('[jsc][連結解析][ERR]:連結要以 http:// 或 https:// 開頭。', file=sys.stderr)
sys.exit(3)
u = urlsplit(url)
host = f'{u.scheme}://{u.netloc}'
parts = [p for p in u.path.split('/') if p]
if len(parts) < 4:
print('[jsc][連結解析][ERR]:認不出存取庫與頁面,需要 /{owner}/{repo}/wiki/{page} 或 /{owner}/{repo}/issues/{index}。', file=sys.stderr)
sys.exit(3)
owner, repo, kind = parts[0], parts[1], parts[2]
rest = parts[3:]
def out(**kv):
for k, v in kv.items():
print(f"{k}='{str(v)}'")
if kind == 'wiki':
while rest and rest[-1] in ('_edit', '_new', '_pages'):
rest.pop()
if not rest:
print('[jsc][連結解析][ERR]:wiki 連結少了頁名。', file=sys.stderr)
sys.exit(3)
page = unquote('/'.join(rest))
out(kind='wiki', repo=f'{owner}/{repo}', page=page, host=host)
elif kind == 'issues':
idx = rest[0].split('#')[0]
if not idx.isdigit():
print('[jsc][連結解析][ERR]:議題編號不是數字。', file=sys.stderr)
sys.exit(3)
out(kind='issue', repo=f'{owner}/{repo}', index=idx, host=host)
else:
print(f'[jsc][連結解析][ERR]:只認得 wiki 與 issues 連結,收到「{kind}」。', file=sys.stderr)
sys.exit(3)
PY
+284 -39
View File
@@ -5,32 +5,63 @@
# gitea.sh repos <owner> # 列出 owner 底下可讀取的 repo(全名) # gitea.sh repos <owner> # 列出 owner 底下可讀取的 repo(全名)
# gitea.sh default-branch <owner>/<repo> # 印出預設分支 # gitea.sh default-branch <owner>/<repo> # 印出預設分支
# gitea.sh clone-url <owner>/<repo> # 印出 clone URL # gitea.sh clone-url <owner>/<repo> # 印出 clone URL
# gitea.sh hash-id <text> # 產生 8 碼大寫 SHA-1 hash;首碼為 0-9/A/B/C 時改成 Hxxxxxxx # gitea.sh hash-id <text> # 產生完整 40 碼大寫 SHA-1 hash
# gitea.sh wiki-repo <TYPE> # 解析頁面類型的 wiki 位置: # gitea.sh wiki-repo <TYPE> # 解析頁面類型的 wiki 位置:
# TYPE = QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR(即頁名前綴) # TYPE = QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING|MONITOR|CONTENTS(即頁名前綴)
# 依序取 JSC_WIKI_REPO_{TYPE} > JSC_WIKI_REPO;不得跨類型代用;都未設定 exit 3 # 依序取 JSC_WIKI_REPO_{TYPE} > JSC_WIKI_REPO;不得跨類型代用;都未設定 exit 3
# gitea.sh wiki-list <owner>/<repo> # 列出 wiki 頁名 # gitea.sh wiki-list <owner>/<repo> # 列出 wiki 頁名
# gitea.sh wiki-get <owner>/<repo> <page> # 印出 wiki 頁 markdown;不存在時 exit 4 # gitea.sh wiki-get <owner>/<repo> <page> # 印出 wiki 頁 markdown;不存在時 exit 4
# gitea.sh wiki-put <owner>/<repo> <page> <file> # 建立或更新 wiki 頁(內容取自檔案) # gitea.sh wiki-put <owner>/<repo> <page> <file> # 建立或更新 wiki 頁(內容取自檔案)
# gitea.sh wiki-url <owner>/<repo> <page> # 印出 wiki 頁絕對網址(取自 API 的 html_url);跨存取庫連結用 # gitea.sh wiki-delete <owner>/<repo> <page> # 刪除 wiki 頁;與 wiki-put 走同一道人工確認
# gitea.sh wiki-url <owner>/<repo> <page> # 印出 wiki 頁絕對網址(取自 API 的 html_url)
# 連結一律寫成 [文字](絕對網址),網址就取自這裡,不自行組路徑
# gitea.sh pr-create <owner>/<repo> <head> <base> <title> <body-file> # 建立 PR,印出 PR URL # gitea.sh pr-create <owner>/<repo> <head> <base> <title> <body-file> # 建立 PR,印出 PR URL
# gitea.sh pr-status <owner>/<repo> <pr-index> # 印出「{state} {merged} {mergeable}」 # gitea.sh pr-status <owner>/<repo> <pr-index> # 印出「{state} {merged} {mergeable}」
# gitea.sh pr-comments <owner>/<repo> <pr-index> # 印出所有留言(issue 留言、審查評語、行內留言),依時間排序 # gitea.sh pr-get <owner>/<repo> <pr-index> # 印出 PR 的標題、base 分支與描述,供比對用
# gitea.sh pr-of-branch <owner>/<repo> <branch> # 印出該分支目前開啟中的 PR(比對 head.ref)
# 輸出格式固定四段,描述放最後,因為只有它會多行:
# 第 1 行 number<TAB>{PR 編號}
# 第 2 行 title<TAB>{標題}
# 第 3 行 base<TAB>{base 分支}
# 第 4 行 body (單獨一個字,當描述的起始標記)
# 第 5 行起 描述原文,一直到檔尾
# 後三段與 pr-get 完全一致,只在最前面多一行 number。呼叫端取編號用 head -n1 | cut -f2-,
# 取描述用 tail -n +5,全程不必解析 JSON,也不必再打一次 pr-get。
# 結束碼: 0=找到 2=用法錯誤 3=該分支沒有開啟中的 PR 4=API 失敗
# 「沒有 PR」必須是 3,不能借用通用的 API 失敗碼:兩者混用會把金鑰失效讀成
# 「這個分支還沒開 PR」,呼叫端接著就開出第二支重複的 PR。
# gitea.sh pr-edit <owner>/<repo> <pr-index> <title> <body-file> # 更新 PR 的標題與描述
# gitea.sh pr-comments <owner>/<repo> <pr-index> # 印出所有留言(issue 留言、審查評語、行內留言),第三欄帶 #id
# gitea.sh comment-reply <owner>/<repo> <pr-index> <issue|review|inline> <comment-id> <body-file>
# 回覆本輪處理過的 PR 留言;inline 走 review comment reply,其餘補一則 PR 留言
# gitea.sh pr-depend <owner>/<repo> <pr-index> <dep-owner>/<dep-repo> <dep-index> # gitea.sh pr-depend <owner>/<repo> <pr-index> <dep-owner>/<dep-repo> <dep-index>
# 把 PR 掛上前置 PR 依賴;依賴未關閉前 Gitea 會阻擋合併 # 把 PR 掛上前置 PR 依賴;依賴未關閉前 Gitea 會阻擋合併
# gitea.sh repo-set <owner>/<repo> <description> [website] # 設定 repo 描述與網頁 # gitea.sh repo-set <owner>/<repo> <description> [website] # 設定 repo 描述與網頁
# gitea.sh markdown <file> # markdown 檔渲染成 HTML 片段(走 /markdown/raw)
# gitea.sh api <METHOD> <path> [json-file] # 原始 API 呼叫(path 以 /repos/... 起始) # gitea.sh api <METHOD> <path> [json-file] # 原始 API 呼叫(path 以 /repos/... 起始)
# 環境變數: GITEA_HOST(例 https://gitea.jsc.idv.tw)、GITEA_TOKEN # 環境變數: GITEA_HOST(例 https://gitea.jsc.idv.tw)、GITEA_TOKEN
# GITEA_TOKEN 未設定,或請求遇 401/403 時,自動退回 tea CLI 的登入 token # GITEA_TOKEN 未設定,或請求遇 401/403 時,自動退回 tea CLI 的登入 token
# (~/.config/tea/config.yml,優先取 url 與 GITEA_HOST 同主機的登入,其次 default: true);兩者皆無才失敗。 # (~/.config/tea/config.yml,優先取 url 與 GITEA_HOST 同主機的登入,其次 default: true);兩者皆無才失敗。
# 結束碼: 0=成功 1=api 子命令的請求失敗 2=用法錯誤或不認得的指令
# 3=wiki-repo 的該類型沒有設定存取庫、pr-of-branch 的該分支沒有開啟中的 PR
# 4=找不到(HTTP 404,含 wiki 頁不存在、wiki-delete 要刪的頁不存在)、PR 系列子命令的 API 失敗,
# 以及 pr-of-branch 翻過 50 頁上限仍沒結束(分頁沒有前進)
# 5=wiki 頁沒有 html_url
# 7=Gitea 金鑰失效或權限不足(HTTP 401/403)
# 8=其他 API 失敗,訊息帶 HTTP 狀態
# 7 與 8 是 2026-08 實測補上的:原本認證失敗、伺服器錯誤全部被歸成「頁面不存在」,
# 而 wiki 寫入的「附加不覆蓋」判斷就靠讀得到舊頁,誤判會直接蓋掉舊紀錄。
set -eu set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
confirm_write() {
sh "$script_dir/write-confirm.sh" "$1" "$2"
}
resolve_wiki_repo() { # TYPE -> JSC_WIKI_REPO_{TYPE} -> JSC_WIKI_REPO resolve_wiki_repo() { # TYPE -> JSC_WIKI_REPO_{TYPE} -> JSC_WIKI_REPO
type=$(printf '%s' "${1:?TYPE required}" | tr a-z A-Z) type=$(printf '%s' "${1:?TYPE required}" | tr a-z A-Z)
case "$type" in case "$type" in
QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR) ;; QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING|MONITOR|CONTENTS) ;;
*) echo "unknown wiki type: $type" >&2; exit 2 ;; *) echo "unknown wiki type: $type" >&2; exit 2 ;;
esac esac
eval "v=\${JSC_WIKI_REPO_${type}:-}" eval "v=\${JSC_WIKI_REPO_${type}:-}"
@@ -95,13 +126,40 @@ fi
case "$GITEA_HOST" in http://*|https://*) HOST="$GITEA_HOST" ;; *) HOST="https://$GITEA_HOST" ;; esac case "$GITEA_HOST" in http://*|https://*) HOST="$GITEA_HOST" ;; *) HOST="https://$GITEA_HOST" ;; esac
API="${HOST%/}/api/v1" API="${HOST%/}/api/v1"
req() { # METHOD path [json-file] -> body(HTTP >= 400 時 exit 4;401/403 以 tea token 重試一次) # 最後一次請求的 HTTP 狀態碼寫進檔案,不是變數:req 幾乎都在 $(...) 裡跑,
# 子行程設的變數回不到主行程,狀態碼會在回來的路上不見。
REQ_CODE_FILE=$(mktemp)
trap 'rm -f "$REQ_CODE_FILE"' EXIT
req_code() { cat "$REQ_CODE_FILE" 2>/dev/null || true; }
api_fail() { # $1=情境說明 -> 依最後一次的 HTTP 狀態分流退出
# 失敗成因一定要分開回報。全部歸成「找不到」是最危險的一種簡化:
# 認證失敗看起來就會像頁面不存在,呼叫端接著就用新頁的邏輯往上蓋。
_c=$(req_code)
case "$_c" in
401|403)
echo "[jsc][gitea][ERR]:$1 —— Gitea 金鑰失效或權限不足(HTTP $_c)。請換一支有效的 GITEA_TOKEN,或重新 tea login 之後再跑一次。" >&2
exit 7 ;;
404)
echo "[jsc][gitea][ERR]:$1 —— 找不到(HTTP 404)。" >&2
exit 4 ;;
*)
echo "[jsc][gitea][ERR]:$1 —— API 失敗(HTTP ${_c:-無回應})。" >&2
exit 8 ;;
esac
}
req() { # METHOD path [body-file] -> body(HTTP >= 400 時回非 0;401/403 以 tea token 重試一次)
# 送出的 Content-Type 由 REQ_CONTENT_TYPE 決定,預設 application/json;
# /markdown/raw 這種吃純文字的端點要先改成 text/plain 再呼叫。
method="$1"; path="$2"; body_file="${3:-}" method="$1"; path="$2"; body_file="${3:-}"
ctype="${REQ_CONTENT_TYPE:-application/json}"
retried=0 retried=0
while :; do while :; do
if [ -n "$body_file" ]; then if [ -n "$body_file" ]; then
out=$(curl -sS -w '\n%{http_code}' -X "$method" \ out=$(curl -sS -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: $ctype" \
--data-binary "@$body_file" "$API$path") --data-binary "@$body_file" "$API$path")
else else
out=$(curl -sS -w '\n%{http_code}' -X "$method" \ out=$(curl -sS -w '\n%{http_code}' -X "$method" \
@@ -114,6 +172,7 @@ req() { # METHOD path [json-file] -> body(HTTP >= 400 時 exit 4;401/403 以
fi fi
break break
done done
printf '%s' "$code" > "$REQ_CODE_FILE"
printf '%s\n' "$out" | sed '$d' printf '%s\n' "$out" | sed '$d'
[ "$code" -lt 400 ] [ "$code" -lt 400 ]
} }
@@ -131,15 +190,21 @@ for i in items:
case "$cmd" in case "$cmd" in
owners) owners)
{ req GET "/user" | json_field login # req 不直接接管線:管線的結束狀態取自 json_field,會把 req 的失敗整個吃掉,
req GET "/user/orgs?limit=50" | json_field username; } | sort -u ;; # 金鑰失效看起來就變成「查詢成功,一個 owner 都沒有」。
me=$(req GET "/user") || api_fail "讀不到目前登入的使用者"
orgs=$(req GET "/user/orgs?limit=50") || api_fail "讀不到組織清單"
{ printf '%s' "$me" | json_field login
printf '%s' "$orgs" | json_field username; } | sort -u ;;
repos) repos)
owner="${1:?owner required}" owner="${1:?owner required}"
# 分頁抓 owner 的 repo(org 與 user 端點擇一成功) # 分頁抓 owner 的 repo(org 與 user 端點擇一成功)
page=1 page=1
while :; do while :; do
out=$(req GET "/orgs/$owner/repos?limit=50&page=$page" 2>/dev/null) \ if ! out=$(req GET "/orgs/$owner/repos?limit=50&page=$page" 2>/dev/null); then
|| out=$(req GET "/users/$owner/repos?limit=50&page=$page") out=$(req GET "/users/$owner/repos?limit=50&page=$page") \
|| api_fail "列不出 $owner 的存取庫"
fi
names=$(printf '%s' "$out" | json_field full_name) names=$(printf '%s' "$out" | json_field full_name)
[ -n "$names" ] || break [ -n "$names" ] || break
printf '%s\n' "$names" printf '%s\n' "$names"
@@ -147,17 +212,30 @@ case "$cmd" in
done ;; done ;;
default-branch) default-branch)
or="${1:?owner/repo required}" or="${1:?owner/repo required}"
req GET "/repos/$or" | json_field default_branch ;; out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field default_branch ;;
clone-url) clone-url)
or="${1:?owner/repo required}" or="${1:?owner/repo required}"
req GET "/repos/$or" | json_field clone_url ;; out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field clone_url ;;
wiki-list) wiki-list)
or="${1:?owner/repo required}" or="${1:?owner/repo required}"
req GET "/repos/$or/wiki/pages?limit=200" | json_field title ;; # 先接變數、先看 req 自己的結束碼,再餵給 json_field。直接接管線的話,
# 結束狀態會變成 json_field 的:金鑰失效回 401 時,這裡看起來像「列出成功,
# 一頁都沒有」,呼叫端就把整個 wiki 判成空的。
out=$(req GET "/repos/$or/wiki/pages?limit=200") || api_fail "列不出 $or 的 wiki 頁"
printf '%s' "$out" | json_field title ;;
wiki-get) wiki-get)
# 失敗成因一定要分開:技能組寫 wiki 的語意是「附加一節、不覆蓋舊紀錄」,
# 判斷依據就是先把舊內容讀回來。金鑰失效回 401 若被翻譯成「頁面不存在」,
# 呼叫端會把它當成一張新頁整份蓋上去,舊紀錄就沒了。
# 只有真的 404 才回 4;401/403 回 7,其他失敗回 8 並帶 HTTP 狀態。
or="${1:?owner/repo required}"; page="${2:?page required}" or="${1:?owner/repo required}"; page="${2:?page required}"
if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then
echo "wiki page not found: $page" >&2; exit 4 case "$(req_code)" in
404) echo "wiki page not found: $page" >&2; exit 4 ;;
*) api_fail "讀不到 wiki 頁 $or/$page" ;;
esac
fi fi
printf '%s' "$out" | python3 -c ' printf '%s' "$out" | python3 -c '
import json,sys,base64 import json,sys,base64
@@ -166,11 +244,14 @@ sys.stdout.write(base64.b64decode(d.get("content_base64","")).decode("utf-8"))
' ;; ' ;;
wiki-url) wiki-url)
# 印出 wiki 頁的絕對網址,取自 API 回應的 html_url,不自行組路徑。 # 印出 wiki 頁的絕對網址,取自 API 回應的 html_url,不自行組路徑。
# 跨存取庫連結(例如 LOG 頁連到 PLAN 頁,而兩者的 JSC_WIKI_REPO_{TYPE} 不同) # 連結一律寫成 [文字](絕對網址),網址全部取自這裡:只有絕對網址在哪裡都解得到,
# 只有絕對網址會通:[[頁名]] 與 markdown 相對連結都只在同一個 wiki 內解析。 # wiki 內部連結語法與 markdown 相對連結都只在同一個 wiki 內解析,跨存取庫就落到別頁去。
or="${1:?owner/repo required}"; page="${2:?page required}" or="${1:?owner/repo required}"; page="${2:?page required}"
if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then
echo "wiki page not found: $page" >&2; exit 4 case "$(req_code)" in
404) echo "wiki page not found: $page" >&2; exit 4 ;;
*) api_fail "讀不到 wiki 頁 $or/$page" ;;
esac
fi fi
url=$(printf '%s' "$out" | python3 -c ' url=$(printf '%s' "$out" | python3 -c '
import json,sys import json,sys
@@ -182,6 +263,7 @@ sys.stdout.write(json.load(sys.stdin).get("html_url") or "")
printf '%s\n' "$url" ;; printf '%s\n' "$url" ;;
wiki-put) wiki-put)
or="${1:?owner/repo required}"; page="${2:?page required}"; file="${3:?content file required}" or="${1:?owner/repo required}"; page="${2:?page required}"; file="${3:?content file required}"
confirm_write "寫入 wiki 頁" "$page"
tmp=$(mktemp) tmp=$(mktemp)
python3 -c ' python3 -c '
import json,sys,base64 import json,sys,base64
@@ -189,44 +271,175 @@ title,path=sys.argv[1],sys.argv[2]
content=open(path,"rb").read() content=open(path,"rb").read()
print(json.dumps({"title":title,"content_base64":base64.b64encode(content).decode()})) print(json.dumps({"title":title,"content_base64":base64.b64encode(content).decode()}))
' "$page" "$file" > "$tmp" ' "$page" "$file" > "$tmp"
# 先探路只為了決定新建或更新。探路失敗的成因若是認證問題,接下來的 POST 也會失敗,
# 由 api_fail 照實回報,不會靜靜蓋掉既有頁面。
if req GET "/repos/$or/wiki/page/$page" >/dev/null 2>&1; then if req GET "/repos/$or/wiki/page/$page" >/dev/null 2>&1; then
req PATCH "/repos/$or/wiki/page/$page" "$tmp" >/dev/null req PATCH "/repos/$or/wiki/page/$page" "$tmp" >/dev/null \
|| { rm -f "$tmp"; api_fail "更新 wiki 頁 $or/$page 失敗"; }
else else
req POST "/repos/$or/wiki/new" "$tmp" >/dev/null req POST "/repos/$or/wiki/new" "$tmp" >/dev/null \
|| { rm -f "$tmp"; api_fail "建立 wiki 頁 $or/$page 失敗"; }
fi fi
rm -f "$tmp" rm -f "$tmp"
echo "OK $page" ;; echo "OK $page" ;;
wiki-delete)
# 刪除 wiki 頁。刪除是不可回復的寫入,所以走 wiki-put 同一道人工確認。
# 404 要回 4 而不是併進通用失敗碼:搬移流程靠「舊頁還在不在」決定要不要重跑,
# 把找不到讀成一般失敗,會讓呼叫端一直重試一個已經刪掉的頁。
or="${1:?owner/repo required}"; page="${2:?page required}"
confirm_write "刪除 wiki 頁" "$page"
if ! req DELETE "/repos/$or/wiki/page/$page" >/dev/null 2>&1; then
case "$(req_code)" in
404) echo "wiki page not found: $page" >&2; exit 4 ;;
*) api_fail "刪除 wiki 頁 $or/$page 失敗" ;;
esac
fi
echo "OK $page" ;;
pr-create) pr-create)
or="${1:?owner/repo required}"; head="${2:?head required}"; base="${3:?base required}" or="${1:?owner/repo required}"; head="${2:?head required}"; base="${3:?base required}"
title="${4:?title required}"; body_file="${5:?body file required}" title="${4:?title required}"; body_file="${5:?body file required}"
# 描述檔缺席要回 2「用法錯誤」,跟 pr-edit、comment-reply 一致。少了這道檢查,
# 缺檔會變成 python3 的 open() 例外加 exit 1,呼叫端讀成「API 失敗」而去重試。
# 這道檢查排在確認之前:先擋掉自己打錯的參數,才不會問完使用者又失敗。
[ -f "$body_file" ] || { echo "找不到描述檔: $body_file" >&2; exit 2; }
confirm_write "建立 PR" "$or#$title"
tmp=$(mktemp) tmp=$(mktemp)
python3 -c ' python3 -c '
import json,sys import json,sys
print(json.dumps({"head":sys.argv[1],"base":sys.argv[2],"title":sys.argv[3], print(json.dumps({"head":sys.argv[1],"base":sys.argv[2],"title":sys.argv[3],
"body":open(sys.argv[4],encoding="utf-8").read()})) "body":open(sys.argv[4],encoding="utf-8").read()}))
' "$head" "$base" "$title" "$body_file" > "$tmp" ' "$head" "$base" "$title" "$body_file" > "$tmp"
req POST "/repos/$or/pulls" "$tmp" | json_field html_url if ! out=$(req POST "/repos/$or/pulls" "$tmp"); then
rm -f "$tmp" ;; rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
printf '%s' "$out" | json_field html_url ;;
pr-status) pr-status)
# 印出「{state} {merged} {mergeable}」,供呼叫端判斷 PR 是否已合併。 # 印出「{state} {merged} {mergeable}」,供呼叫端判斷 PR 是否已合併。
# 查不到 PR(404)維持印「? none none」並 exit 0:pr-watch.sh 的白名單靠這個字串
# 判成 exit 3「查不到該 PR」。認證或連線失敗改回非 0,不再混進同一個字串裡。
or="${1:?owner/repo required}"; idx="${2:?pr index required}" or="${1:?owner/repo required}"; idx="${2:?pr index required}"
req GET "/repos/$or/pulls/$idx" | python3 -c ' if ! out=$(req GET "/repos/$or/pulls/$idx" 2>/dev/null); then
case "$(req_code)" in
404) echo '? none none'; exit 0 ;;
*) api_fail "讀不到 PR $or#$idx" ;;
esac
fi
printf '%s' "$out" | python3 -c '
import json,sys import json,sys
p=json.load(sys.stdin) p=json.load(sys.stdin)
print("%s %s %s" % (p.get("state","?"), str(p.get("merged")).lower(), str(p.get("mergeable")).lower())) print("%s %s %s" % (p.get("state","?"), str(p.get("merged")).lower(), str(p.get("mergeable")).lower()))
' ;; ' ;;
pr-get)
# 印出 PR 的標題、base 分支與描述,供呼叫端比對本機資料是否已經跟 PR 不一致。
# 格式固定三段,描述放最後一段,因為只有它會多行:
# 第 1 行 title<TAB>{標題}
# 第 2 行 base<TAB>{base 分支}
# 第 3 行 body (單獨一個字,當描述的起始標記)
# 第 4 行起 描述原文,一直到檔尾
# 呼叫端取標題用 head -n1 | cut -f2-,取 base 用 sed -n 2p | cut -f2-,
# 取描述用 tail -n +4,全程不必解析 JSON。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
if ! out=$(req GET "/repos/$or/pulls/$idx"); then
printf '%s\n' "$out" >&2; exit 4
fi
printf '%s' "$out" | python3 -c '
import json,sys
p=json.load(sys.stdin)
print("title\t%s" % (p.get("title") or ""))
print("base\t%s" % ((p.get("base") or {}).get("ref") or ""))
print("body")
sys.stdout.write(p.get("body") or "")
' ;;
pr-of-branch)
# 找出某條分支目前開啟中的 PR。比對的是 head.ref,不是分支名的字串包含:
# feat/報表 與 feat/報表/main 只差一段,用包含比對會回錯的那一支。
# 輸出格式與 pr-get 對齊並多帶 index 與 url,呼叫端(jsc-git:commit、jsc-git:pr)
# 拿到就能直接比對標題與描述,不必再打一次 pr-get。
or="${1:?owner/repo required}"; branch="${2:?branch required}"
# 頁數上限。只靠「回空陣列」收尾的迴圈,遇上忽略 page 參數的站台或代理會一直
# 拿到同一頁而永遠停不下來——沒有輸出、也沒有結束碼,呼叫端只看得到卡住。
page=1
max_page=50
while :; do
if [ "$page" -gt "$max_page" ]; then
echo "[jsc][gitea][ERR]:列 $or 的開啟中 PR 超過 $max_page 頁仍沒有結束,分頁沒有前進(站台或代理可能忽略 page 參數)。" >&2
exit 4
fi
# req 的輸出先接進變數再解析。寫成 req ... | python3 的話,管線的結束狀態
# 取自 python,401 會變成「解不到相符的 PR」而回 3,正好踩中重複開 PR 那個坑。
if ! out=$(req GET "/repos/$or/pulls?state=open&limit=50&page=$page"); then
echo "[jsc][gitea][ERR]:列不出 $or 的開啟中 PR(HTTP $(req_code))。" >&2
exit 4
fi
res=$(printf '%s' "$out" | python3 -c '
import json,sys
want=sys.argv[1]
d=json.load(sys.stdin)
if not isinstance(d,list) or not d:
print("__EMPTY__"); raise SystemExit
for p in d:
if ((p.get("head") or {}).get("ref") or "") == want:
print("number\t%s" % (p.get("number") or p.get("index") or ""))
print("title\t%s" % (p.get("title") or ""))
print("base\t%s" % ((p.get("base") or {}).get("ref") or ""))
print("body")
sys.stdout.write(p.get("body") or "")
raise SystemExit
print("__NONE__")
' "$branch") || { echo "[jsc][gitea][ERR]:$or 的 PR 清單解不開。" >&2; exit 4; }
case "$res" in
__NONE__) page=$((page+1)); continue ;;
__EMPTY__) break ;;
esac
printf '%s\n' "$res"
exit 0
done
echo "[jsc][gitea][ERR]:分支 $branch 沒有開啟中的 PR。" >&2
exit 3 ;;
pr-edit)
# 更新 PR 的標題與描述。描述從檔案讀,才裝得下多行內容與全形標點。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
title="${3:?title required}"; body_file="${4:?body file required}"
[ -f "$body_file" ] || { echo "找不到描述檔: $body_file" >&2; exit 2; }
confirm_write "更新 PR" "$or#$idx"
tmp=$(mktemp)
python3 -c '
import json,sys
print(json.dumps({"title":sys.argv[1],"body":open(sys.argv[2],encoding="utf-8").read()}))
' "$title" "$body_file" > "$tmp"
if ! out=$(req PATCH "/repos/$or/pulls/$idx" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
echo "OK $or#$idx" ;;
pr-comments) pr-comments)
# 印出 PR 的所有留言,每行「{時間}<TAB>{作者}<TAB>{類型}<TAB>{內容單行化}」。 # 印出 PR 的所有留言,每行「{時間}<TAB>{作者}<TAB>{類型}#{id}<TAB>{內容單行化}」。
# 三個來源都要讀:issue 留言、review 本體的評語、review 內逐行的程式碼留言。 # 三個來源都要讀:issue 留言、review 本體的評語、review 內逐行的程式碼留言。
# 只讀 issue 留言會漏掉真正的審查意見,那正是需要修正的部分。 # 只讀 issue 留言會漏掉真正的審查意見,那正是需要修正的部分。
# 類型欄保留在第三欄,僅追加 id;呼叫端用第一欄做 since 比對時不受影響。
# req 一律先接進變數、先看它自己的結束碼,再餵給 python3。寫成 req … | python3
# 的話,管線的結束狀態取自 python:金鑰失效回 401 時,這裡會印出一份空的留言清單
# 並回 0,呼叫端(jsc-git:pr)就判成「這支 PR 沒有任何審查意見」,整輪留言修正被跳過。
or="${1:?owner/repo required}"; idx="${2:?pr index required}" or="${1:?owner/repo required}"; idx="${2:?pr index required}"
{ req GET "/repos/$or/issues/$idx/comments?limit=100" | python3 -c ' issues=$(req GET "/repos/$or/issues/$idx/comments?limit=100") \
|| api_fail "讀不到 PR $or#$idx 的留言"
reviews=$(req GET "/repos/$or/pulls/$idx/reviews?limit=100") \
|| api_fail "讀不到 PR $or#$idx 的審查清單"
rids=$(printf '%s' "$reviews" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
if r.get("comments_count", 0) or r.get("id"): print(r["id"])
')
# 行內留言逐則抓,抓失敗一樣要當成失敗。收集在 $lines 裡最後才排序:
# 把 api_fail 放進管線的話,它只結束子行程,主流程照樣往下印出殘缺清單。
lines=$(mktemp)
{ printf '%s' "$issues" | python3 -c '
import json,sys import json,sys
for c in json.load(sys.stdin): for c in json.load(sys.stdin):
body=" ".join((c.get("body") or "").split()) body=" ".join((c.get("body") or "").split())
if body: print("%s\t%s\t留言\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), body)) if body: print("%s\t%s\t留言#%s\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("id","?"), body))
' '
reviews=$(req GET "/repos/$or/pulls/$idx/reviews?limit=100")
printf '%s' "$reviews" | python3 -c ' printf '%s' "$reviews" | python3 -c '
import json,sys import json,sys
for r in json.load(sys.stdin): for r in json.load(sys.stdin):
@@ -234,26 +447,48 @@ for r in json.load(sys.stdin):
st=r.get("state","") st=r.get("state","")
# 沒有評語的審查照樣要印:APPROVED 代表可以合併,REQUEST_CHANGES 代表被要求修改, # 沒有評語的審查照樣要印:APPROVED 代表可以合併,REQUEST_CHANGES 代表被要求修改,
# 兩者都是呼叫端要據以決策的事實,過濾掉會看不見 PR 真正的狀態。 # 兩者都是呼叫端要據以決策的事實,過濾掉會看不見 PR 真正的狀態。
print("%s\t%s\t審查(%s)\t%s" % (r.get("submitted_at",""), (r.get("user") or {}).get("login","?"), st, body or "(無評語)")) print("%s\t%s\t審查#%s(%s)\t%s" % (r.get("submitted_at",""), (r.get("user") or {}).get("login","?"), r.get("id","?"), st, body or "(無評語)"))
' '
for rid in $(printf '%s' "$reviews" | python3 -c ' } > "$lines"
for rid in $rids; do
rc=$(req GET "/repos/$or/pulls/$idx/reviews/$rid/comments") \
|| { rm -f "$lines"; api_fail "讀不到 PR $or#$idx 審查 $rid 的行內留言"; }
printf '%s' "$rc" | python3 -c '
import json,sys import json,sys
for r in json.load(sys.stdin): d=json.load(sys.stdin)
if r.get("comments_count", 0) or r.get("id"): print(r["id"])
'); do
req GET "/repos/$or/pulls/$idx/reviews/$rid/comments" 2>/dev/null | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: raise SystemExit
for c in d: for c in d:
body=" ".join((c.get("body") or "").split()) body=" ".join((c.get("body") or "").split())
if body: print("%s\t%s\t行內(%s:%s)\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("path",""), c.get("original_position") or c.get("position") or "", body)) if body: print("%s\t%s\t行內#%s(%s:%s)\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("id","?"), c.get("path",""), c.get("original_position") or c.get("position") or "", body))
' || true ' >> "$lines"
done done
} | sort ;; sort "$lines"
rm -f "$lines" ;;
comment-reply)
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
kind="${3:?kind required}"; cid="${4:?comment id required}"; body_file="${5:?body file required}"
[ -f "$body_file" ] || { echo "找不到回覆檔: $body_file" >&2; exit 2; }
case "$kind" in issue|review|inline) ;; *) echo "kind must be issue, review, or inline" >&2; exit 2 ;; esac
confirm_write "回覆留言" "$or#$idx/$kind#$cid"
tmp=$(mktemp)
python3 -c '
import json,sys
print(json.dumps({"body":open(sys.argv[1],encoding="utf-8").read()}))
' "$body_file" > "$tmp"
case "$kind" in
inline)
path="/repos/$or/pulls/$idx/comments/$cid/replies" ;;
issue|review)
path="/repos/$or/issues/$idx/comments" ;;
esac
if ! out=$(req POST "$path" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
printf '%s' "$out" | json_field html_url ;;
pr-depend) pr-depend)
or="${1:?owner/repo required}"; idx="${2:?pr index required}" or="${1:?owner/repo required}"; idx="${2:?pr index required}"
dep_or="${3:?dep owner/repo required}"; dep_idx="${4:?dep index required}" dep_or="${3:?dep owner/repo required}"; dep_idx="${4:?dep index required}"
confirm_write "設定 PR 依賴" "$or#$idx -> $dep_or#$dep_idx"
tmp=$(mktemp) tmp=$(mktemp)
python3 -c ' python3 -c '
import json,sys import json,sys
@@ -267,6 +502,7 @@ print(json.dumps({"owner":o,"repo":r,"index":int(sys.argv[2])}))
echo "OK $or#$idx depends on $dep_or#$dep_idx" ;; echo "OK $or#$idx depends on $dep_or#$dep_idx" ;;
repo-set) repo-set)
or="${1:?owner/repo required}"; desc="${2:?description required}"; site="${3:-}" or="${1:?owner/repo required}"; desc="${2:?description required}"; site="${3:-}"
confirm_write "更新 repo 資訊" "$or"
tmp=$(mktemp) tmp=$(mktemp)
python3 -c ' python3 -c '
import json,sys import json,sys
@@ -279,6 +515,15 @@ print(json.dumps(b))
fi fi
rm -f "$tmp" rm -f "$tmp"
echo "OK $or" ;; echo "OK $or" ;;
markdown)
# markdown 檔 -> HTML 片段。走 /markdown/raw(吃純文字)而不是 /markdown:
# 後者在 Gitea 1.27 回 200 但內容是空的,看起來像成功,其實什麼都沒渲染。
# 代價:raw 端點不吃 context,wiki 的 [[頁名]] 內部連結不會變成連結,
# 呼叫端要先把它換成絕對網址。
file="${1:?markdown file}"
[ -f "$file" ] || { echo "找不到 markdown 檔: $file" >&2; exit 2; }
REQ_CONTENT_TYPE='text/plain'
req POST "/markdown/raw" "$file" || api_fail "markdown 渲染失敗($file)" ;;
api) api)
method="${1:?METHOD}"; path="${2:?path}"; body="${3:-}" method="${1:?METHOD}"; path="${2:?path}"; body="${3:-}"
req "$method" "$path" $body ;; req "$method" "$path" $body ;;
+12 -9
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# hash-id — 產生 8 碼大寫 SHA-1 hash。 # hash-id — 產生完整 40 碼大寫 SHA-1 hash。
# 用法: # 用法:
# hash-id <text> # hash-id <text>
# printf '%s' <text> | hash-id # printf '%s' <text> | hash-id
# 規則: # 規則:
# 先取 SHA-1 前 8 碼大寫。 # 取完整 SHA-1 四十碼,a-f 轉大寫,不截短、不加前綴。
# 若首碼為 0-9、A、B、C,改成 H 加上原 SHA-1 前 7 碼。 # 輸入為空即 exit 2。空字串本身也有合法的 SHA-1,靜靜回傳它就會生出
# 一個看起來正常的頁名,把內容寫到沒有人讀的那一頁。
# 結束碼: 0=成功 1=這台機器沒有 sha1sum 也沒有 shasum,算不出雜湊
# 2=用法錯誤:沒有給文字,或給的是空字串
set -eu set -eu
input='' input=''
@@ -17,6 +20,11 @@ else
input=$(cat) input=$(cat)
fi fi
if [ -z "$input" ]; then
echo 'usage: hash-id <text>' >&2
exit 2
fi
if command -v sha1sum >/dev/null 2>&1; then if command -v sha1sum >/dev/null 2>&1; then
raw=$(printf '%s' "$input" | sha1sum | awk '{print $1}') raw=$(printf '%s' "$input" | sha1sum | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then elif command -v shasum >/dev/null 2>&1; then
@@ -26,9 +34,4 @@ else
exit 1 exit 1
fi fi
hash=$(printf '%s' "$raw" | cut -c1-8 | tr a-f A-F) printf '%s\n' "$raw" | tr a-f A-F
case "$hash" in
[0-9ABC]*) hash="H$(printf '%s' "$hash" | cut -c1-7)" ;;
esac
printf '%s\n' "$hash"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env sh
# html-render.sh — 把 markdown 套上版型與風格,輸出單一 HTML 檔(供 jsc-gitea:html-export 使用)。
#
# 為什麼要有這支腳本:markdown 轉 HTML 交給 Gitea 自己渲染(gitea.sh markdown),出來的排版才跟
# wiki、議題頁看到的一致;版型與風格則是固定的字串替換。兩件事都有標準輸入輸出,不必每次重寫。
#
# 用法:
# html-render.sh --markdown <檔案> --title <標題> --out <輸出檔>
# [--subtitle <副標>] [--layout <版型>] [--style <風格>]
# [--source-url <來源網址>]
#
# --layout 預設 report,--style 預設 minimal。可用清單見 html-style.sh layouts / styles。
#
# 輸出: 寫出 --out 指定的 HTML 檔,並在標準輸出印出該檔路徑。
# 結束碼: 0=成功 1=渲染或寫檔失敗 2=用法錯誤 4=找不到版型或風格範本
#
# 陷阱:
# - HTML 是單一檔案,CSS 直接內嵌,不外連任何資源;產出物常常是寄給別人看的,外連在對方那裡會破圖。
# - markdown 渲染走 Gitea API。連不上就失敗收場,不自己拼一套半套的轉換——半套轉換出來的表格
# 跟 wiki 上看到的不一樣,比失敗更難發現。
# - 渲染端點不吃 wiki 情境,`[[頁名]]` 這種 wiki 內部連結會原樣留著。呼叫端要先換成絕對網址,
# 產出的 HTML 才連得回去。
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
plugin_root="${CLAUDE_PLUGIN_ROOT:-$script_dir/..}"
TPL="$plugin_root/templates/html"
GITEA="$script_dir/gitea.sh"
usage() {
cat >&2 <<'EOF'
用法:
html-render.sh --markdown <檔案> --title <標題> --out <輸出檔>
[--subtitle <副標>] [--layout <版型>] [--style <風格>]
[--source-url <來源網址>]
結束碼: 0=成功 1=渲染或寫檔失敗 2=用法錯誤 4=找不到版型或風格範本
EOF
exit 2
}
md=''; title=''; out=''; subtitle=''; layout=report; style=minimal; source_url=''
while [ "$#" -gt 0 ]; do
case "$1" in
--markdown) [ "$#" -ge 2 ] || usage; md="$2"; shift 2 ;;
--title) [ "$#" -ge 2 ] || usage; title="$2"; shift 2 ;;
--out) [ "$#" -ge 2 ] || usage; out="$2"; shift 2 ;;
--subtitle) [ "$#" -ge 2 ] || usage; subtitle="$2"; shift 2 ;;
--layout) [ "$#" -ge 2 ] || usage; layout="$2"; shift 2 ;;
--style) [ "$#" -ge 2 ] || usage; style="$2"; shift 2 ;;
--source-url) [ "$#" -ge 2 ] || usage; source_url="$2"; shift 2 ;;
*) echo "[jsc][HTML 產生][ERR]:不認得的選項「$1」。" >&2; usage ;;
esac
done
[ -n "$md" ] && [ -n "$title" ] && [ -n "$out" ] || usage
[ -f "$md" ] || { echo "[jsc][HTML 產生][ERR]:找不到 markdown 檔「$md」。" >&2; exit 2; }
layout_file="$TPL/layout/$layout.html"
style_file="$TPL/style/$style.css"
[ -f "$layout_file" ] || { echo "[jsc][HTML 產生][ERR]:找不到版型範本「$layout_file」。" >&2; exit 4; }
[ -f "$style_file" ] || { echo "[jsc][HTML 產生][ERR]:找不到風格範本「$style_file」。" >&2; exit 4; }
# markdown -> HTML 片段:交給 Gitea 自己渲染,排版才跟站上一致。
fragment=$(mktemp)
trap 'rm -f "$fragment"' EXIT
if ! "$GITEA" markdown "$md" > "$fragment" 2>/dev/null || [ ! -s "$fragment" ]; then
echo '[jsc][HTML 產生][ERR]:Gitea 的 markdown 渲染失敗,這次不出檔。請確認 GITEA_HOST 與權杖後重跑。' >&2
exit 1
fi
python3 - "$layout_file" "$style_file" "$fragment" "$out" "$title" "$subtitle" "$source_url" "$layout" "$style" "$TPL" <<'PY'
import html, sys, datetime, os
layout_file, style_file, frag_file, out_file, title, subtitle, source_url, layout, style, tpl_dir = sys.argv[1:11]
def read(p):
with open(p, encoding='utf-8') as f:
return f.read()
page = read(layout_file)
css = read(style_file)
base_css = read(os.path.join(tpl_dir, 'base.css'))
base_js = read(os.path.join(tpl_dir, 'base.js'))
content = read(frag_file)
generated = datetime.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M')
source_html = ''
if source_url:
safe = html.escape(source_url, quote=True)
source_html = '<a class="source" href="%s" rel="noreferrer">來源:%s</a>' % (safe, safe)
for key, value in (
('{{TITLE}}', html.escape(title)),
('{{SUBTITLE}}', html.escape(subtitle)),
('{{BASE}}', base_css),
('{{BASE_JS}}', base_js),
('{{STYLE}}', css),
('{{CONTENT}}', content),
('{{SOURCE}}', source_html),
('{{GENERATED}}', generated),
('{{LAYOUT}}', html.escape(layout)),
('{{STYLE_NAME}}', html.escape(style)),
):
page = page.replace(key, value)
with open(out_file, 'w', encoding='utf-8') as f:
f.write(page)
PY
printf '%s\n' "$out"
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env sh
# html-style.sh — 「哪一種 wiki 頁或議題,用哪一種版型與風格出 HTML」的設定(供 jsc-gitea:html-style、html-export 使用)。
#
# 設定格式(一行一筆):{種類}={版型},{風格}
# 種類:WIKI:{頁名前綴}(例 WIKI:PLAN)、ISSUE:{標籤名}(例 ISSUE:bug)、DEFAULT(都對不到時用)
# 「#」開頭為註解,空白行忽略。同一種類出現多行時取最後一行。
# 解析順序:
# 1. 目前工作目錄的 ./.jsc/html-styles(專案覆寫)
# 2. $JSC_HOME/html-styles.conf(JSC_HOME 預設 ~/.jsc)
# 3. 種類對不到就退 DEFAULT,DEFAULT 也沒有才用內建預設 report,minimal
#
# 用法:
# html-style.sh get <種類> 印出 版型<TAB>風格<TAB>來源(project/global/default/builtin)
# html-style.sh set <種類> <版型> <風格> [--project|--global] 寫入設定(預設 --global)
# html-style.sh unset <種類> [--project|--global] 移除設定
# html-style.sh list 印出合併後的所有設定:種類<TAB>版型<TAB>風格<TAB>來源
# html-style.sh layouts 列出可用版型:名稱<TAB>繁中說明
# html-style.sh styles 列出可用風格:名稱<TAB>繁中說明
# html-style.sh key wiki <頁名> 推導 wiki 頁的種類:取第一個底線前的字首
# html-style.sh key issue <owner>/<repo> <編號> [--labels <名稱>[,<名稱>]]
# 推導議題的種類:依序試每個標籤,第一個讓 get 回 project 或 global 的標籤勝出;
# 都對不到就回 ISSUE:DEFAULT。已經有標籤名單時用 --labels 帶進來,省掉一次 API 呼叫。
# key 的輸出固定一行:{種類}<TAB>{依據}
# 依據 = prefix:{字首}、label:{標籤名}、fallback:no-labels、fallback:no-configured-label
#
# 結束碼: 0=成功 1=取不到議題標籤,或設定檔的目錄建不出來 2=用法錯誤 4=版型或風格沒有對應範本檔
#
# 陷阱:
# - get 永遠印得出一組值:對不到就退 DEFAULT,再對不到就退內建預設。呼叫端不必自己準備退路,
# 但要看第三欄,才知道這組值是使用者設的還是撿來的。
# - key 的推導規則放在腳本裡,不放技能內文:字首怎麼切、標籤怎麼試,是固定的輸入輸出,
# 每次由模型重推就會有人切錯底線或跳過標籤順序。
# - set 會先確認範本檔真的存在,擋掉打錯字的版型或風格;設定寫得進去、出圖卻失敗最難查。
set -u
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
plugin_root="${CLAUDE_PLUGIN_ROOT:-$script_dir/..}"
TPL="$plugin_root/templates/html"
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
PROJECT_FILE=./.jsc/html-styles
GLOBAL_FILE="$JSC_HOME/html-styles.conf"
BUILTIN_LAYOUT=report
BUILTIN_STYLE=minimal
usage() {
cat >&2 <<'EOF'
用法:
html-style.sh get <種類>
html-style.sh set <種類> <版型> <風格> [--project|--global]
html-style.sh unset <種類> [--project|--global]
html-style.sh list | layouts | styles
html-style.sh key wiki <頁名>
html-style.sh key issue <owner>/<repo> <編號> [--labels <名稱>[,<名稱>]]
種類: WIKI:{頁名前綴}、ISSUE:{標籤名}、DEFAULT
結束碼: 0=成功 1=取不到議題標籤或建不出目錄 2=用法錯誤 4=版型或風格沒有對應範本檔
EOF
exit 2
}
read_value() { # $1=設定檔 $2=種類 -> 「版型,風格」
[ -f "$1" ] || return 0
awk -F= -v want="$2" '
{ sub(/\r$/, "") }
/^[ \t]*#/ { next }
/^[ \t]*$/ { next }
index($0, "=") == 0 { next }
{
key = $1
gsub(/[ \t]/, "", key)
if (key != want) next
val = substr($0, index($0, "=") + 1)
gsub(/[ \t]/, "", val)
if (val != "") v = val
}
END { if (v != "") print v }
' "$1"
}
resolve() { # $1=種類 -> 版型<TAB>風格<TAB>來源
for _f in "$PROJECT_FILE:project" "$GLOBAL_FILE:global"; do
_file=${_f%:*}; _src=${_f##*:}
_v=$(read_value "$_file" "$1")
if [ -n "$_v" ]; then
printf '%s\t%s\t%s\n' "${_v%%,*}" "${_v##*,}" "$_src"
return 0
fi
done
if [ "$1" != DEFAULT ]; then
_d=$(resolve DEFAULT)
_src=$(printf '%s' "$_d" | cut -f3)
# DEFAULT 自己也沒設定時,來源照實說是 builtin,不要蓋成 default
[ "$_src" = builtin ] || _src=default
printf '%s\t%s\n' "$(printf '%s' "$_d" | cut -f1,2)" "$_src"
return 0
fi
printf '%s\t%s\tbuiltin\n' "$BUILTIN_LAYOUT" "$BUILTIN_STYLE"
}
# 範本檔第一行註解就是繁中說明:版型放在 <!-- 說明 -->,風格放在 /* 說明 */。
describe() { # $1=檔案
head -n1 "$1" 2>/dev/null | sed 's/<!--[[:space:]]*//; s/[[:space:]]*-->//; s|/\*[[:space:]]*||; s|[[:space:]]*\*/||'
}
list_layouts() {
for f in "$TPL"/layout/*.html; do
[ -f "$f" ] || continue
name=$(basename "$f" .html)
printf '%s\t%s\n' "$name" "$(describe "$f")"
done
}
list_styles() {
for f in "$TPL"/style/*.css; do
[ -f "$f" ] || continue
name=$(basename "$f" .css)
printf '%s\t%s\n' "$name" "$(describe "$f")"
done
}
write_kv() { # $1=檔案 $2=種類 $3=值
dir=$(dirname "$1")
mkdir -p "$dir" || { echo "[jsc][HTML 設定][ERR]:建不出目錄「$dir」。" >&2; exit 1; }
tmp="$1.tmp.$$"
{ [ -f "$1" ] && grep -v "^[[:space:]]*$2[[:space:]]*=" "$1" || true; } > "$tmp"
[ -n "$3" ] && printf '%s=%s\n' "$2" "$3" >> "$tmp"
mv "$tmp" "$1"
}
cmd="${1:-}"; [ -n "$cmd" ] || usage
shift || true
case "$cmd" in
layouts) list_layouts; exit 0 ;;
styles) list_styles; exit 0 ;;
list)
keys=$( { [ -f "$PROJECT_FILE" ] && cut -d= -f1 "$PROJECT_FILE" || true
[ -f "$GLOBAL_FILE" ] && cut -d= -f1 "$GLOBAL_FILE" || true; } \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^#' | grep -v '^$' | sort -u)
[ -n "$keys" ] || { printf 'DEFAULT\t%s\t%s\tbuiltin\n' "$BUILTIN_LAYOUT" "$BUILTIN_STYLE"; exit 0; }
printf '%s\n' "$keys" | while IFS= read -r k; do
printf '%s\t%s\n' "$k" "$(resolve "$k")"
done
exit 0 ;;
get)
key="${1:-}"; [ -n "$key" ] || usage
resolve "$key"
exit 0 ;;
key)
kind="${1:-}"; [ -n "$kind" ] || usage
shift
case "$kind" in
wiki)
page="${1:-}"; [ -n "$page" ] || usage
# 頁名前綴 = 第一個底線之前那一段;沒有底線就是整個頁名。
prefix=${page%%_*}
printf 'WIKI:%s\tprefix:%s\n' "$prefix" "$prefix"
exit 0 ;;
issue)
repo="${1:-}"; idx="${2:-}"
[ -n "$repo" ] && [ -n "$idx" ] || usage
shift 2
labels=''
have_labels=0
case "${1:-}" in
--labels) [ "$#" -ge 2 ] || usage; labels="$2"; have_labels=1 ;;
'') ;;
*) usage ;;
esac
if [ "$have_labels" = 0 ]; then
raw=$("$script_dir/issue.sh" labels-of "$repo" "$idx") || {
echo "[jsc][HTML 設定][ERR]:取不到議題 $repo#$idx 的標籤。" >&2; exit 1; }
labels=$(printf '%s' "$raw" | tr '\n' ',')
fi
matched=''
old_ifs=$IFS
IFS=','
for l in $labels; do
[ -n "$l" ] || continue
# 標籤順序就是優先序:第一個使用者真的設過的標籤勝出,
# 撿來的 DEFAULT 不算數,否則第一個標籤永遠贏,後面的設定形同虛設。
case "$(resolve "ISSUE:$l" | cut -f3)" in
project|global) matched="$l"; break ;;
esac
done
IFS=$old_ifs
if [ -n "$matched" ]; then
printf 'ISSUE:%s\tlabel:%s\n' "$matched" "$matched"
elif [ -z "$(printf '%s' "$labels" | tr -d ',')" ]; then
printf 'ISSUE:DEFAULT\tfallback:no-labels\n'
else
printf 'ISSUE:DEFAULT\tfallback:no-configured-label\n'
fi
exit 0 ;;
*) usage ;;
esac ;;
set)
key="${1:-}"; layout="${2:-}"; style="${3:-}"
[ -n "$key" ] && [ -n "$layout" ] && [ -n "$style" ] || usage
shift 3
target="$GLOBAL_FILE"; scope=global
case "${1:-}" in
--project) target="$PROJECT_FILE"; scope=project ;;
--global|'') ;;
*) usage ;;
esac
[ -f "$TPL/layout/$layout.html" ] || {
echo "[jsc][HTML 設定][ERR]:沒有版型「$layout」。可用:$(list_layouts | cut -f1 | tr '\n' ' ')" >&2; exit 4; }
[ -f "$TPL/style/$style.css" ] || {
echo "[jsc][HTML 設定][ERR]:沒有風格「$style」。可用:$(list_styles | cut -f1 | tr '\n' ' ')" >&2; exit 4; }
write_kv "$target" "$key" "$layout,$style"
printf '已寫入 %s:%s=%s,%s(%s)\n' "$target" "$key" "$layout" "$style" "$scope"
exit 0 ;;
unset)
key="${1:-}"; [ -n "$key" ] || usage
shift
target="$GLOBAL_FILE"
case "${1:-}" in
--project) target="$PROJECT_FILE" ;;
--global|'') ;;
*) usage ;;
esac
[ -f "$target" ] || { echo "找不到設定檔:$target" >&2; exit 0; }
write_kv "$target" "$key" ''
printf '已移除 %s 的 %s\n' "$target" "$key"
exit 0 ;;
*) usage ;;
esac
Executable
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env sh
# issue.sh — Gitea 議題的讀取與建立(供 jsc-gitea:wiki-to-issue、html-export 使用)。
#
# 為什麼要有這支腳本:建議題要組 JSON、要把標籤名換成標籤 id、要把回應裡的編號與網址挑出來,
# 全是固定的輸入輸出。交給模型每次自己拼 curl,跳脫字元一錯就把議題內容寫壞,而議題是對外的。
#
# 用法:
# issue.sh labels <owner>/<repo> 列出既有標籤:id<TAB>name
# issue.sh label-ids <owner>/<repo> <name>[,<name>] 標籤名換成 id(逗號分隔);有名字對不到就 exit 4
# issue.sh projects <owner>/<repo> 列出專案看板:id<TAB>title;站台沒有這個 API 就 exit 3
# issue.sh title <owner>/<repo> <index> 印出議題標題
# issue.sh body <owner>/<repo> <index> 印出議題正文(markdown)
# issue.sh labels-of <owner>/<repo> <index> 印出議題目前的標籤名,一行一個
# issue.sh show <owner>/<repo> <index> 一次印出標題、標籤與正文(只打一次 API)
# 輸出格式固定四段,正文放最後,因為只有它會多行:
# 第 1 行 title<TAB>{標題}
# 第 2 行 labels<TAB>{標籤名,逗號分隔;沒有標籤時這一欄留空}
# 第 3 行 body (單獨一個字,當正文的起始標記)
# 第 4 行起 正文原文,一直到檔尾
# 呼叫端取標題用 head -n1 | cut -f2-,取標籤用 sed -n 2p | cut -f2-,取正文用 tail -n +4。
# 同一筆議題要標題、正文又要標籤時一律用 show,不要連打 title、body、labels-of 三次。
# issue.sh create <owner>/<repo> <title> <body-file> [--labels <id>[,<id>]] [--milestone <id>]
# 建立議題。輸出兩行: index=<編號>、url=<議題網址>
#
# 環境變數: 同 gitea.sh(GITEA_HOST、GITEA_TOKEN;token 缺或被拒時退回 tea 登入金鑰)。
# 結束碼: 0=成功 1=API 失敗 2=用法錯誤 3=站台不支援該 API 4=名稱對不到 id
#
# 陷阱:
# - 正文一律用檔案傳進來,內容維持 UTF-8 與真實換行;不要在參數裡塞 \n。
# - Gitea 1.27 沒有公開的專案看板 API,projects 會 exit 3。呼叫端要據實回報「請手動把議題拖到看板」,
# 不要假裝已經關聯好——關聯不上跟關聯成功看起來一樣,是最容易被當成完成的一種失敗。
# exit 3 的兩條路徑(端點回錯、端點回了但不是清單)都會印出看板網址,呼叫端一定拿得到那條連結。
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
GITEA="$script_dir/gitea.sh"
usage() {
cat >&2 <<'EOF'
用法:
issue.sh labels <owner>/<repo> 列出既有標籤:id<TAB>name
issue.sh label-ids <owner>/<repo> <name>[,<name>] 標籤名換成 id
issue.sh projects <owner>/<repo> 列出專案看板;站台不支援就 exit 3
issue.sh title|body|labels-of|show <owner>/<repo> <index>
issue.sh create <owner>/<repo> <title> <body-file> [--labels <id>[,<id>]] [--milestone <id>]
結束碼: 0=成功 1=API 失敗 2=用法錯誤 3=站台不支援 4=名稱對不到 id
EOF
exit 2
}
[ -f "$GITEA" ] || { echo "[jsc][議題][ERR]:找不到 $GITEA。" >&2; exit 1; }
valid_repo() {
case "${1:-}" in
*/*/*|/*|*/) return 1 ;;
*/*) return 0 ;;
*) return 1 ;;
esac
}
board_url() { # 看板頁的人工網址。站台沒有看板 API 時,呼叫端要靠這條連結手動處理
_host="${GITEA_HOST:-}"
case "$_host" in http://*|https://*) : ;; *) _host="https://$_host" ;; esac
printf '%s/%s/projects\n' "${_host%/}" "$repo"
}
cmd="${1:-}"; [ -n "$cmd" ] || usage
shift || true
repo="${1:-}"
valid_repo "$repo" || { echo "[jsc][議題][ERR]:存取庫須為 {owner}/{repo},收到「${repo:-空值}」。" >&2; exit 2; }
shift
# gitea.sh 的輸出一律先接進變數再解析,不要寫成 "$GITEA" api ... | python3。
# 管線的結束狀態取自 python,會把 gitea.sh 的失敗整個吃掉:金鑰失效時,回應是一份
# 錯誤 JSON,python 從裡面挑不到 labels 就印出空清單並回 0,看起來像「這個議題沒有標籤」。
api_get() { # $1=API 路徑 -> 回應內容;失敗回 1
"$GITEA" api GET "$1"
}
case "$cmd" in
labels)
out=$(api_get "/repos/$repo/labels?limit=100") \
|| { echo "[jsc][議題][ERR]:讀不到 $repo 的標籤清單。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
for l in json.load(sys.stdin):
print("%s\t%s" % (l["id"], l["name"]))
' ;;
label-ids)
names="${1:-}"; [ -n "$names" ] || usage
out=$(api_get "/repos/$repo/labels?limit=100") \
|| { echo "[jsc][議題][ERR]:讀不到 $repo 的標籤清單。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
want=[n.strip() for n in sys.argv[1].split(",") if n.strip()]
have={l["name"]: l["id"] for l in json.load(sys.stdin)}
miss=[n for n in want if n not in have]
if miss:
sys.stderr.write("[jsc][議題][ERR]:這些標籤在存取庫裡找不到:" + "、".join(miss) + "\n")
sys.exit(4)
print(",".join(str(have[n]) for n in want))
' "$names" ;;
projects)
manual=0
if out=$(api_get "/repos/$repo/projects" 2>/dev/null); then
printf '%s' "$out" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(3)
if not isinstance(d, list):
sys.exit(3)
for p in d:
print("%s\t%s" % (p.get("id",""), p.get("title") or p.get("name","")))
' || manual=1
else
manual=1
fi
if [ "$manual" = 1 ]; then
# 兩條 exit 3 的路徑都要印出看板網址:呼叫端的技能被要求把這條連結交給使用者,
# 少印一次,使用者就只收到「關聯不上」而不知道要去哪裡手動拖。
echo "[jsc][議題][WARN]:這個 Gitea 站台沒有專案看板 API,程式關聯不了。請開 $(board_url) 手動把議題拖進看板。" >&2
exit 3
fi ;;
title)
idx="${1:-}"; [ -n "$idx" ] || usage
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
print(json.load(sys.stdin).get("title",""))
' ;;
body)
idx="${1:-}"; [ -n "$idx" ] || usage
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
sys.stdout.write(json.load(sys.stdin).get("body","") or "")
' ;;
labels-of)
idx="${1:-}"; [ -n "$idx" ] || usage
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx 的標籤。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
for l in json.load(sys.stdin).get("labels") or []:
print(l.get("name",""))
' ;;
show)
# 標題、標籤、正文都在同一份 API 回應裡。分成 title、body、labels-of 三次呼叫,
# 打的是同一支端點三遍,還可能取到三個不同時間點的版本。
idx="${1:-}"; [ -n "$idx" ] || usage
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(1)
if not isinstance(d, dict) or not d.get("title"):
sys.exit(1)
print("title\t%s" % d.get("title"))
print("labels\t%s" % ",".join((l.get("name") or "") for l in (d.get("labels") or [])))
print("body")
sys.stdout.write(d.get("body") or "")
' || { echo "[jsc][議題][ERR]:議題 $repo#$idx 的回應解不出標題。" >&2; exit 1; } ;;
create)
title="${1:-}"; body_file="${2:-}"
[ -n "$title" ] || usage
[ -n "$body_file" ] || usage
[ -f "$body_file" ] || { echo "[jsc][議題][ERR]:找不到正文檔「$body_file」。" >&2; exit 2; }
shift 2
labels=''
milestone=''
while [ "$#" -gt 0 ]; do
case "$1" in
--labels) [ "$#" -ge 2 ] || usage; labels="$2"; shift 2 ;;
--milestone) [ "$#" -ge 2 ] || usage; milestone="$2"; shift 2 ;;
*) echo "[jsc][議題][ERR]:不認得的選項「$1」。" >&2; usage ;;
esac
done
confirm_target="議題「$title」"
[ -n "$labels" ] && confirm_target="$confirm_target,標籤:$labels"
[ -n "$milestone" ] && confirm_target="$confirm_target,里程碑:$milestone"
sh "$script_dir/write-confirm.sh" "建立議題" "$confirm_target"
payload=$(mktemp)
trap 'rm -f "$payload"' EXIT
python3 -c '
import json,sys
title, body_file, labels, milestone, out = sys.argv[1:6]
d={"title": title, "body": open(body_file, encoding="utf-8").read()}
if labels:
d["labels"]=[int(x) for x in labels.split(",") if x.strip()]
if milestone:
d["milestone"]=int(milestone)
with open(out, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
' "$title" "$body_file" "$labels" "$milestone" "$payload"
out=$("$GITEA" api POST "/repos/$repo/issues" "$payload") \
|| { echo "[jsc][議題][ERR]:建不出議題到 $repo。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print("index=%s" % (d.get("number") or d.get("index") or ""))
print("url=%s" % (d.get("html_url") or ""))
' ;;
*) usage ;;
esac
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env sh
# link-check.sh — 連結可達性檢查(連結寫進任何文件之前先跑這一支)。
# 用法:
# link-check.sh <網址>...
# printf '%s\n' <網址>... | link-check.sh # 沒給參數就從標準輸入一行一個讀
#
# 輸出: 每個網址一行,三欄以 TAB 分隔
# {OK|DEAD|SKIP}<TAB>{網址}<TAB>{說明}
# OK = 連得到
# DEAD = 連不到,呼叫端不得把它寫進文件
# SKIP = 沒有可查的端點(mailto:、錨點、相對路徑),不影響結束碼
#
# 驗證方式依網址種類分流:
# Gitea wiki 頁(<GITEA_HOST>/<owner>/<repo>/wiki/<頁名>) -> API /repos/<owner>/<repo>/wiki/page/<頁名>
# Gitea 議題(…/issues/<編號>) -> API /repos/<owner>/<repo>/issues/<編號>
# 其他 Gitea 網址 -> HTTP HEAD,帶金鑰
# 非 Gitea 的外部網址 -> HTTP HEAD,不帶金鑰
#
# 規則:
# - Gitea 一律走 API,不看網頁狀態碼。私有存取庫的網頁網址對未登入請求一律回 404,
# 用網頁狀態碼判斷會把好連結判成壞連結,接著整批砍掉還在的頁。
# - 外部網址不帶金鑰。金鑰是這個站台的憑證,送去別的主機就是憑證外洩。
# - 這支只回報,不改任何檔案。呼叫端拿到結束碼 0 才可以把連結寫進文件。
#
# 環境變數: GITEA_HOST(例 https://gitea.jsc.idv.tw)、GITEA_TOKEN
# GITEA_TOKEN 未設定時退回 tea CLI 的登入金鑰(~/.config/tea/config.yml)。
# 優先取 url 與 GITEA_HOST 同主機的登入:tea 可以同時登入多個站台,只看 default
# 會把 A 站的金鑰送去 B 站,那是憑證外洩,不是單純取錯值。
#
# 結束碼: 0=全部連得到 1=至少一筆連不到 2=用法錯誤(一個網址都沒給)
# 3=清單裡有 Gitea 網址,但 GITEA_HOST 未設定 7=Gitea 認證失敗(HTTP 401、403)
# 7 一定要與 1 分開:金鑰失效時,Gitea 對私有存取庫的回應與「頁不存在」難以分辨。
# 兩者混用,一次金鑰過期就把整批還在的頁判成死連結,接著這些頁會被當成壞連結刪掉或改寫。
set -eu
TAB=$(printf '\t')
emit() { # <狀態> <網址> <說明>
printf '%s%s%s%s%s\n' "$1" "$TAB" "$2" "$TAB" "$3"
}
host_of() { # <網址> -> 只留主機名,轉小寫
printf '%s' "$1" | sed 's#^[a-zA-Z]*://##; s#/.*##; s#^.*@##; s#:[0-9]*$##' | tr 'A-Z' 'a-z'
}
path_of() { # <網址> -> 去掉協定、主機、查詢字串與錨點之後的路徑,頭尾不留斜線
printf '%s' "$1" | sed 's#^[a-zA-Z]*://[^/]*##; s/[?#].*$//; s#^/##; s#/$##'
}
seg() { # <路徑> <序號> -> 第 n 段
printf '%s' "$1" | cut -d/ -f"$2"
}
nseg() { # <路徑> -> 段數
printf '%s' "$1" | awk -F/ '{print NF}'
}
looks_like_gitea_path() { # <路徑> -> 形狀像 wiki 頁或議題就回 0
_p="$1"
[ "$(nseg "$_p")" -ge 4 ] || return 1
case "$(seg "$_p" 3)" in
wiki|issues) return 0 ;;
*) return 1 ;;
esac
}
resolve_token() { # 印出這次要用的金鑰;一把都沒有就印空字串
if [ -n "${GITEA_TOKEN:-}" ]; then printf '%s' "$GITEA_TOKEN"; return 0; fi
cfg="${HOME:-}/.config/tea/config.yml"
[ -f "$cfg" ] || return 0
awk -v want="$GITEA_HOST_ONLY" '
function flush() {
if (tok == "") return
if (first == "") first = tok
if (want != "" && host == want) match_tok = tok
if (def) def_tok = tok
}
/^ *- / { flush(); tok=""; host=""; def=0 }
/^ *token: */ { line=$0; sub(/^ *token: */,"",line); gsub(/"/,"",line); tok=line }
/^ *url: */ { line=$0; sub(/^ *url: */,"",line); gsub(/"/,"",line)
sub(/^https?:\/\//,"",line); sub(/\/.*$/,"",line); host=line }
/^ *default: *true/ { def=1 }
END {
flush()
if (match_tok != "") { printf "%s", match_tok; exit }
if (def_tok != "") printf "%s", def_tok
else printf "%s", first
}
' "$cfg"
}
http_status() { # <HEAD|GET> <網址> [金鑰] -> 印出 HTTP 狀態碼,連不上印 000
_m="$1"; _u="$2"; _t="${3:-}"
# HEAD 走 -I,不走 -X HEAD:後者讓 curl 等一個永遠不會來的內文,整支腳本卡在那裡。
# GET 退讓只要第一個位元組(-r 0-0),拿的是狀態碼,不必把整份內容抓回來。
if [ "$_m" = HEAD ]; then set -- -I; else set -- -r 0-0; fi
# 連線與總時間分兩個上限。主機根本不通時,5 秒就收工;主機活著但慢,才讓它用滿 20 秒。
# 只設總時間的話,一頁十條連結碰上不通的主機要等三分多鐘,呼叫端會以為整支腳本掛了。
if [ -n "$_t" ]; then
_c=$(curl -sS -o /dev/null -L --connect-timeout 5 --max-time 20 -w '%{http_code}' "$@" \
-H "Authorization: token $_t" "$_u" 2>/dev/null || true)
else
_c=$(curl -sS -o /dev/null -L --connect-timeout 5 --max-time 20 -w '%{http_code}' "$@" "$_u" 2>/dev/null || true)
fi
case "$_c" in ''|*[!0-9]*) _c=000 ;; esac
printf '%s' "$_c"
}
api_status() { # <API 路徑> -> 印出 HTTP 狀態碼,連不上印 000
_c=$(curl -sS -o /dev/null --connect-timeout 5 --max-time 20 -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API$1" 2>/dev/null || true)
case "$_c" in ''|*[!0-9]*) _c=000 ;; esac
printf '%s' "$_c"
}
auth_stop() { # <網址> -> 印出這一筆,回報金鑰問題並中止
emit SKIP "$1" "Gitea 認證失敗(HTTP $2),無法判定"
echo "[jsc][連結檢查][ERR]:Gitea 金鑰失效或權限不足(HTTP $2)。請換一支有效的 GITEA_TOKEN,或重新 tea login 之後再跑一次;在那之前不要把任何連結判成死連結。" >&2
exit 7
}
# ---- 收集網址 ----
list=$(mktemp)
raw=$(mktemp)
trap 'rm -f "$list" "$raw"' EXIT
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@" > "$raw"
else
cat > "$raw"
fi
# 先去掉行尾空白,再丟掉空白行:清單多半是別的指令產出的,尾巴常帶一行空的,
# 那一行會被當成一個網址,最後回一個沒人看得懂的 DEAD。
sed 's/[[:space:]]*$//' "$raw" | grep -v '^[[:space:]]*$' > "$list" || true
if [ ! -s "$list" ]; then
echo 'usage: link-check.sh <網址>... ;或用標準輸入一行一個網址' >&2
exit 2
fi
# ---- Gitea 站台設定 ----
GITEA_HOST_ONLY=$(host_of "${GITEA_HOST:-}")
if [ -z "$GITEA_HOST_ONLY" ]; then
# GITEA_HOST 沒設定就認不出哪些是 Gitea 網址,只能看路徑形狀。認出一筆就整批停下來:
# 少了主機設定,Gitea 連結只驗得到網頁狀態碼,私有存取庫會全部被判成死連結。
while IFS= read -r u; do
if looks_like_gitea_path "$(path_of "$u")"; then
echo "[jsc][連結檢查][ERR]:清單裡有 Gitea 網址($u),但 GITEA_HOST 未設定。請先設定 GITEA_HOST 再跑一次,不得跳過驗證。" >&2
exit 3
fi
done < "$list"
fi
case "${GITEA_HOST:-}" in
http://*|https://*) HOST="${GITEA_HOST:-}" ;;
'') HOST='' ;;
*) HOST="https://${GITEA_HOST}" ;;
esac
API=''
[ -z "$HOST" ] || API="${HOST%/}/api/v1"
TOKEN=$(resolve_token)
# ---- 逐筆檢查 ----
dead=0
while IFS= read -r url; do
case "$url" in
http://*|https://*) ;;
*)
emit SKIP "$url" "不是 http 或 https 網址,沒有可查的端點"
continue ;;
esac
uhost=$(host_of "$url")
upath=$(path_of "$url")
if [ -n "$GITEA_HOST_ONLY" ] && [ "$uhost" = "$GITEA_HOST_ONLY" ]; then
owner=$(seg "$upath" 1); repo=$(seg "$upath" 2); kind=$(seg "$upath" 3)
rest=''
[ "$(nseg "$upath")" -lt 4 ] || rest=$(printf '%s' "$upath" | cut -d/ -f4-)
# 編輯畫面的網址尾巴指的還是同一頁,去掉再查。
rest=$(printf '%s' "$rest" | sed 's#/_edit$##; s#/_new$##; s#/_pages$##')
if [ "$kind" = wiki ] && [ -n "$rest" ]; then
code=$(api_status "/repos/$owner/$repo/wiki/page/$rest")
case "$code" in
2*) emit OK "$url" "wiki 頁存在(API HTTP $code)" ;;
401|403) auth_stop "$url" "$code" ;;
404) emit DEAD "$url" "wiki 頁不存在(API HTTP 404)"; dead=1 ;;
*) emit DEAD "$url" "wiki API 回 HTTP $code"; dead=1 ;;
esac
continue
fi
if [ "$kind" = issues ] && [ -n "$rest" ]; then
idx=$(printf '%s' "$rest" | cut -d/ -f1)
case "$idx" in
''|*[!0-9]*)
emit DEAD "$url" "議題編號不是數字:$idx"; dead=1; continue ;;
esac
code=$(api_status "/repos/$owner/$repo/issues/$idx")
case "$code" in
2*) emit OK "$url" "議題存在(API HTTP $code)" ;;
401|403) auth_stop "$url" "$code" ;;
404) emit DEAD "$url" "議題不存在(API HTTP 404)"; dead=1 ;;
*) emit DEAD "$url" "議題 API 回 HTTP $code"; dead=1 ;;
esac
continue
fi
code=$(http_status HEAD "$url" "$TOKEN")
case "$code" in
2*|3*) emit OK "$url" "Gitea 網址可達(HEAD HTTP $code)" ;;
401|403) auth_stop "$url" "$code" ;;
000) emit DEAD "$url" "連不上 Gitea 主機(沒有回應)"; dead=1 ;;
*) emit DEAD "$url" "Gitea 網址回 HTTP $code"; dead=1 ;;
esac
continue
fi
# 外部網址:不帶金鑰。HEAD 被擋掉時再用 GET 試一次,有些站台只擋 HEAD。
code=$(http_status HEAD "$url")
case "$code" in
2*|3*) ;;
*) code=$(http_status GET "$url") ;;
esac
case "$code" in
2*|3*) emit OK "$url" "外部網址可達(HTTP $code)" ;;
000) emit DEAD "$url" "連不上主機(沒有回應)"; dead=1 ;;
*) emit DEAD "$url" "外部網址回 HTTP $code"; dead=1 ;;
esac
done < "$list"
exit "$dead"
+486
View File
@@ -0,0 +1,486 @@
#!/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
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env sh
# page-name.sh — wiki 頁名樣式的唯一正本。
#
# 為什麼要有這支腳本:頁名樣式散在多支腳本與技能裡各寫一份,改一次規則就得
# 到處找。集中成一支,比對的人與比對的程式讀的是同一條式子。
#
# 用法:
# page-name.sh regex # 印出合法頁名的 ERE
# page-name.sh check <page> # 驗證單一頁名
#
# 規則:
# 合法頁名是 {前綴}_{尾段}。前綴取十四種內容型別之一。
# 尾段四選一:CONTENTS 是目錄頁;40 碼大寫十六進位是內容頁;
# 8 碼大寫十六進位,以及 H 加 7 碼大寫十六進位,留給還沒搬過來的舊頁。
#
# 為什麼舊頁收兩種樣式:舊演算法算出前 8 碼後,首碼落在 0-9ABC 就改寫成 H 加前 7 碼。
# 十六個首碼裡有十三個會命中,既有舊頁名絕大多數長成 H 開頭那一種。只收純 8 碼,
# 等於把八成的舊頁判成不合法,先驗頁名的技能就再也讀不到它們。
#
# 為什麼 CONTENTS 不能當前綴:CONTENTS 只用來解目錄頁的存取庫,它自己沒有頁。
# 放行 CONTENTS_CONTENTS 或 CONTENTS_{HASH},會建出一個規格上不存在的頁。
# 結束碼: 0=合法 1=不合法 2=用法錯誤
set -eu
PREFIXES='QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING|MONITOR'
RE="^($PREFIXES)_(CONTENTS|[0-9A-F]{8}|H[0-9A-F]{7}|[0-9A-F]{40})\$"
cmd="${1-}"
case "$cmd" in
regex)
[ "$#" -eq 1 ] || { echo 'usage: page-name.sh regex' >&2; exit 2; }
printf '%s\n' "$RE" ;;
check)
page="${2-}"
[ "$#" -eq 2 ] && [ -n "$page" ] || { echo 'usage: page-name.sh check <page>' >&2; exit 2; }
if printf '%s\n' "$page" | grep -Eq "$RE"; then
exit 0
fi
echo "[jsc][gitea][ERR]:頁名不合規則:$page" >&2
exit 1 ;;
*)
echo 'usage: page-name.sh regex | page-name.sh check <page>' >&2
exit 2 ;;
esac
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env sh
# pr-watch.sh — 盯著一支 PR,直到它合併或關閉。
# 用法: pr-watch.sh <owner>/<repo> <pr-index> [state-file]
# 規則: 資料一律經同目錄的 gitea.sh(pr-status、pr-comments),不自行拼 API。
# 輪詢間隔預設 60 秒,JSC_PR_WATCH_INTERVAL 可覆寫,單位是秒的正整數。
# 不自動逾時退場,盯到 PR 合併或關閉為止。
# 已回報過的留言不重複回報:最後處理過的留言時間戳寫進狀態檔,
# 預設 $JSC_HOME/pr-watch/{owner}-{repo}-{index}.seen,JSC_HOME 未設定時用 ~/.jsc。
# 狀態檔還不存在時,把既有留言整批當成新留言回報一次。寧可重複回報,
# 也不要漏掉開始盯之前就留下的審查意見。
# 每輪先看 PR 狀態,再看留言。PR 收尾那一輪若還有沒回報的留言,
# 照樣印出來再退出,退出碼仍是 0:留言不會因為 PR 剛好合併就消失。
# 輸出: 全部繁中,留言行維持 gitea.sh pr-comments 的原格式
# 「{時間}<TAB>{作者}<TAB>{類型}<TAB>{內容}」,呼叫端可直接用 cut -f 取欄位。
# 結束碼: 0=PR 已合併或關閉,最終狀態印在 stdout。盯完了,不用再盯一次。
# 2=用法或環境問題:參數個數不對、第一個參數不是 {owner}/{repo}、PR 編號不是數字、
# JSC_PR_WATCH_INTERVAL 不是正整數秒、建不出暫存檔,或第一輪就連不上 Gitea。
# 照 stderr 的訊息修參數或 GITEA_HOST、GITEA_TOKEN,再重新盯。
# 3=查不到該 PR。確認存取庫與 PR 編號再重新盯,不要原樣重試——
# 編號打錯會變成永遠輪詢一支不存在的 PR。
# 10=有新留言,內容印在 stdout。接手跑決策樹處理完,再重新盯同一支 PR。
# 130=收到 INT 或 TERM 訊號而中止。狀態檔留著,重新盯會從上次那則留言接下去。
# 護欄: gitea.sh pr-status 對不存在的 PR 會印「? none none」並且 exit 0。
# 所以 state 只認 open 與 closed、merged 只認 true 與 false,對不上就回 3。
# 少了這道白名單,PR 編號打錯會變成永遠輪詢一支不存在的 PR。
# 第一輪查詢失敗算環境沒接好,直接回 2;之後才失敗算連線抖動,警告一句繼續輪詢。
set -u
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
GITEA="$script_dir/gitea.sh"
usage() {
echo "用法: pr-watch.sh <owner>/<repo> <pr-index> [state-file]" >&2
exit 2
}
[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage
REPO="$1"; INDEX="$2"; STATE="${3:-}"
case "$REPO" in
*/*) ;;
*) echo "錯誤: 第一個參數要寫成 {owner}/{repo},收到「$REPO」。" >&2; exit 2 ;;
esac
case "$INDEX" in
''|*[!0-9]*) echo "錯誤: PR 編號要是數字,收到「$INDEX」。" >&2; exit 2 ;;
esac
INTERVAL="${JSC_PR_WATCH_INTERVAL:-60}"
case "$INTERVAL" in
''|*[!0-9]*) echo "錯誤: JSC_PR_WATCH_INTERVAL 要是正整數秒,收到「$INTERVAL」。" >&2; exit 2 ;;
esac
[ "$INTERVAL" -gt 0 ] || { echo "錯誤: JSC_PR_WATCH_INTERVAL 要大於 0。" >&2; exit 2; }
if [ -z "$STATE" ]; then
jsc_home="${JSC_HOME:-$HOME/.jsc}"
owner=${REPO%%/*}; name=${REPO#*/}
STATE="$jsc_home/pr-watch/$owner-$name-$INDEX.seen"
fi
TMP_NEW=$(mktemp) || { echo "錯誤: 無法建立暫存檔。" >&2; exit 2; }
trap 'rm -f "$TMP_NEW"' EXIT
trap 'rm -f "$TMP_NEW"; exit 130' INT TERM
SEEN=""
[ -f "$STATE" ] && SEEN=$(head -n1 "$STATE" 2>/dev/null)
save_seen() { # 時間戳 -> 寫回狀態檔
ts="$1"
[ -n "$ts" ] || return 0
dir=$(dirname -- "$STATE")
mkdir -p "$dir" 2>/dev/null || { echo "警告: 建不出狀態檔目錄 $dir,這批留言下次會重複回報。" >&2; return 0; }
printf '%s\n' "$ts" > "$STATE" 2>/dev/null \
|| echo "警告: 寫不進狀態檔 $STATE,這批留言下次會重複回報。" >&2
SEEN="$ts"
}
collect_new() { # 新留言寫進 $TMP_NEW;有東西才回 0
# 時間戳一律由同一台 Gitea 產生,格式與時區都一致,所以字串比大小就夠用;
# pr-comments 自己也是照這個字串排序的。
"$GITEA" pr-comments "$REPO" "$INDEX" 2>/dev/null \
| awk -F '\t' -v seen="$SEEN" 'NF >= 4 && ($1 "") > (seen "")' > "$TMP_NEW"
[ -s "$TMP_NEW" ]
}
flush_new() { # 印出新留言並記住最後一筆的時間戳
cat "$TMP_NEW"
save_seen "$(awk -F '\t' 'END { print $1 }' "$TMP_NEW")"
}
first_round=1
while :; do
if ! status=$("$GITEA" pr-status "$REPO" "$INDEX" 2>/dev/null); then
if [ "$first_round" -eq 1 ]; then
echo "錯誤: 連不上 Gitea 或 gitea.sh 執行失敗。請確認 GITEA_HOST 與 GITEA_TOKEN,再重新盯。" >&2
exit 2
fi
echo "警告: 這一輪查不到 PR 狀態,$INTERVAL 秒後再試。" >&2
sleep "$INTERVAL"
continue
fi
first_round=0
state=$(printf '%s\n' "$status" | awk '{ print $1 }')
merged=$(printf '%s\n' "$status" | awk '{ print $2 }')
case "$state" in
open|closed) ;;
*) echo "錯誤: 查不到 PR $REPO#$INDEX。請確認存取庫與 PR 編號。" >&2; exit 3 ;;
esac
case "$merged" in
true|false) ;;
*) echo "錯誤: 查不到 PR $REPO#$INDEX。請確認存取庫與 PR 編號。" >&2; exit 3 ;;
esac
if [ "$state" = closed ]; then
if [ "$merged" = true ]; then
printf '最終狀態: PR %s#%s 已合併。\n' "$REPO" "$INDEX"
else
printf '最終狀態: PR %s#%s 已關閉,沒有合併。\n' "$REPO" "$INDEX"
fi
if collect_new; then
echo "收尾前還有沒回報的留言:"
flush_new
fi
exit 0
fi
if collect_new; then
printf '新留言 %s 則(%s#%s),請接手跑決策樹:\n' "$(wc -l < "$TMP_NEW" | tr -d ' ')" "$REPO" "$INDEX"
flush_new
exit 10
fi
sleep "$INTERVAL"
done
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env sh
# repo-sync.sh — 把單一存取庫同步到本機。
# 用法: repo-sync.sh <owner>/<repo> [target-dir] # target-dir 預設為 <repo>
# 目錄不存在 → git clone(clone URL 取自 gitea.sh clone-url)
# 目錄已存在 → git fetch、切到基準分支、git pull --ff-only
# 有未提交變更 → 只回報 dirty 與基準分支,不動分支也不 pull
# 基準分支的唯一優先序(呼叫端不必再判斷,也不要自己再推導一次):
# 1. gitea.sh default-branch <owner>/<repo>(該分支要在遠端存在)
# 2. 查不到時依序取遠端的 develop、main、master
# 3. 都沒有 → failed no default branch
# 輸出: 恰好一行,cloned、updated、dirty {分支} 或 failed {原因}。
# 前三種 exit 0;failed exit 1。
# dirty 會把解析好的基準分支一起帶出來,呼叫端直接拿去當 PR 的 base。
# 結束碼: 0=同步完成,輸出是 cloned、updated 或 dirty {分支}
# 1=失敗,輸出是 failed {原因};參數錯誤、clone-url 取不到、fetch/checkout/pull 失敗、
# 以及找不到基準分支都走這一碼。只有 0 與 1 兩種。
set -u
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
gitea="$script_dir/gitea.sh"
fail() { # 原因壓成一行,避免呼叫端解析多行輸出
printf 'failed %s\n' "$(printf '%s' "$1" | tr '\n\r\t' ' ')"
exit 1
}
full="${1:-}"
[ -n "$full" ] || fail "usage: repo-sync.sh <owner>/<repo> [target-dir]"
case "$full" in
*/*/*|/*|*/) fail "not owner/repo: $full" ;;
*/*) ;;
*) fail "not owner/repo: $full" ;;
esac
repo="${full#*/}"
target="${2:-$repo}"
remote_has() { # 遠端是否有這個分支
git -C "$target" rev-parse --verify --quiet "refs/remotes/origin/$1" >/dev/null 2>&1
}
if [ ! -e "$target" ]; then
# clone URL 只收 stdout:把 stderr 併進來會讓任何雜訊變成網址的一部分
err=$(mktemp)
url=$("$gitea" clone-url "$full" 2>"$err") || url=''
if [ -z "$url" ]; then
reason=$(cat "$err"); rm -f "$err"
fail "clone-url failed for $full: ${reason:-no clone URL}"
fi
rm -f "$err"
out=$(git clone "$url" "$target" 2>&1) || fail "clone: $out"
printf 'cloned\n'
exit 0
fi
git -C "$target" rev-parse --git-dir >/dev/null 2>&1 || fail "$target is not a git repo"
dirty=0
[ -z "$(git -C "$target" status --porcelain 2>/dev/null)" ] || dirty=1
# dirty 的存取庫也要解析基準分支:呼叫端拿它當 PR 的 base,自己再推導一次就會
# 出現第二套優先序。fetch 只更新 refs,不動工作目錄,dirty 時照樣安全;但 dirty
# 時網路失敗不算致命,還是要把 dirty 回報出去,改用本機既有的 remote refs 判斷。
if ! out=$(git -C "$target" fetch --prune origin 2>&1); then
[ "$dirty" -eq 1 ] || fail "fetch: $out"
fi
branch=$("$gitea" default-branch "$full" 2>/dev/null) || branch=''
if [ -z "$branch" ] || ! remote_has "$branch"; then
branch=''
for b in develop main master; do
if remote_has "$b"; then branch="$b"; break; fi
done
fi
[ -n "$branch" ] || fail "no default branch"
if [ "$dirty" -eq 1 ]; then
printf 'dirty %s\n' "$branch"
exit 0
fi
out=$(git -C "$target" checkout "$branch" 2>&1) || fail "checkout $branch: $out"
out=$(git -C "$target" pull --ff-only origin "$branch" 2>&1) || fail "pull $branch: $out"
printf 'updated\n'
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env sh
# wiki-contents.sh — 目錄頁(*_CONTENTS)的區塊 upsert。
#
# 為什麼要有這支腳本:目錄頁的「找同一筆就取代、找不到就附加」原本靠模型照
# SKILL.md 手工做,十四個目錄頁只有一處寫成程式。同一段判斷做十四次,錯一次
# 就少一筆紀錄。抽成一支,讀舊頁、比對鍵、整頁寫回只有一種做法。
#
# 版面:一筆紀錄一個 H2 區塊。H2 標題就是這一筆的鍵,寫成對應內容頁的頁名;欄位是
# 標題底下一層條列,一行一條「- {欄位名}:{值}」。目錄頁上不留 markdown 表格。
#
# 用法:
# wiki-contents.sh upsert <TYPE> <key-col> <key> <entry-file> [template-file]
# TYPE 頁型,決定頁名 {TYPE}_CONTENTS
# key-col 只有舊頁還是表格時才用得到:舊表格裡持有這一筆身分的欄位序號,
# 1 起算。轉檔時該欄格子有連結就取網址最後一段路徑當 H2 標題,
# 沒有連結才取格子純文字。頁面已經是條列格式時完全忽略這個參數
# key 這一筆的 H2 標題文字,也就是內容頁頁名。用來找既有區塊
# entry-file 整個 H2 區塊的 markdown:「## {key}」那一行、空行、各條條列
# template-file 選用。頁不存在時用它建新頁;舊頁還是表格而要自動轉檔時,
# 也用它的 H1 與「>」引言取代舊頁那一份
#
# wiki-contents.sh format <key-col> <key> <entry-file> <old-file> <new-file> [template-file|--fresh]
# 只做文字處理,不碰 API,把結果寫進 new-file 並印出 updated 或 added。
# 轉檔與 upsert 的判斷只有這一份,離線驗證餵檔案給它就好,不必打 API。
# 第六個參數給 --fresh 代表 old-file 是範本,要剝掉示範資料;給檔案路徑
# 則等同 upsert 的 template-file,只在轉檔那一次用來換掉引言。
#
# 規則:
# 目錄頁一律住在 CONTENTS 專用存取庫,所以存取庫走 wiki-repo CONTENTS,
# 不走各自的頁型。
# 舊頁還是 markdown 表格時,先整頁轉成 H2 區塊再做 upsert;一頁同時有表格與區塊,
# 表格轉出來的區塊接在既有區塊後面。三種舊頁狀態都不得毀掉別人那一筆。
# 轉檔取 H2 標題只看身分欄那一格:有連結就取網址最後一段路徑,網址經過百分號編碼
# 就先解碼;沒有連結就取純文字,去掉反引號與頭尾空白。取到什麼就用什麼,不驗頁名樣式。
# 找「## {key}」:標題文字去頭尾空白後完全相等才算命中。命中就換掉整塊,從那一行
# 到下一個「## 」之前或檔尾;沒命中就附加到最後一個區塊之後。
# 只有 wiki-get 回 4 才准建新頁。回 7 或 8 一律中止:把金鑰失效讀成
# 「頁面不存在」,就會拿新範本蓋掉活著的頁,舊紀錄整份沒了。
# 這條規則的正本在 skills/wiki/SKILL.md 的 Rules 第 4 條。
# 建新頁時剝掉範本的示範資料,只留 H1 與「>」引言。
# 轉檔那一次若呼叫端給了範本,連引言一起換成範本那一份:轉檔只搬表格不動散文,
# 舊引言會一直講「每個存取庫一列」這種只對表格成立的話,誤導之後讀的人。
# 頁面已經是條列格式、不需要轉檔時引言原樣不動,那時呼叫端只是更新自己那一筆,
# 沒有理由改別人寫的散文;沒給範本也保留舊引言,因為沒有正本可換。
# 結束碼: 0=已更新或已新增 1=組不出頁面內容或寫入失敗 2=用法錯誤 3=CONTENTS 存取庫未設定
# 4=頁不存在且沒給範本 7=金鑰失效或權限不足 8=其他 API 失敗
set -eu
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
gitea="$dir/gitea.sh"
page_name="$dir/page-name.sh"
usage() {
echo 'usage: wiki-contents.sh upsert <TYPE> <key-col> <key> <entry-file> [template-file]' >&2
echo ' wiki-contents.sh format <key-col> <key> <entry-file> <old-file> <new-file> [template-file|--fresh]' >&2
exit 2
}
check_keycol() {
case "$1" in
''|*[!0-9]*) echo "key-col must be a positive integer: $1" >&2; exit 2 ;;
esac
[ "$1" -ge 1 ] || { echo "key-col must be a positive integer: $1" >&2; exit 2; }
}
# 轉檔與 upsert 只有這一份實作。upsert 與 format 共用它,離線驗證跑的就是正式路徑那一段。
# 參數:舊頁檔 區塊檔 鍵欄序號 鍵 輸出檔 是否為新建(1 或 0) 範本檔(沒有就給空字串)
render() {
python3 - "$1" "$2" "$3" "$4" "$5" "$6" "$7" <<'PY'
import re
import sys
from urllib.parse import unquote
old_path, entry_path, keycol, key, new_path, fresh, template_path = sys.argv[1:8]
keycol = int(keycol)
fresh = fresh == '1'
key = key.strip()
lines = open(old_path, encoding='utf-8').read().split('\n')
entry = open(entry_path, encoding='utf-8').read().strip('\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()
def last_segment(url):
"""取網址最後一段路徑,也就是 .../wiki/{頁名} 的頁名。"""
u = url.strip().split('#', 1)[0].split('?', 1)[0]
parts = [p for p in u.split('/') if p]
seg = parts[-1] if parts else ''
# 頁名有空白或中文時網址會被百分號編碼,解碼後才是頁面上看到的頁名。
return unquote(seg).replace('`', '').strip()
def title_from_cell(v):
# 身分欄的連結文字常常不是頁名,是工作包名稱或計畫名稱;真正的頁名在網址最後一段。
# 拿連結文字當 H2 標題,就跟呼叫端傳進來的鍵對不上,同一筆會長出第二個區塊。
m = re.search(r'\[[^\]]*\]\(([^)]*)\)', v)
if m:
return last_segment(m.group(1))
# wiki 連結 [[頁名|文字]] 的目標寫在前半段,那一段就是頁名。
m = re.search(r'\[\[([^\]|]*)(?:\|[^\]]*)?\]\]', v)
if m:
return m.group(1).replace('`', '').strip()
# 沒有連結就是純文字身分欄,例如 {owner}/{repo}。取到什麼就用什麼,不判形狀。
return v.replace('`', '').strip()
def split_tables(src):
"""把每一段 markdown 表格從行清單裡拿掉。回傳剩下的行與各表格的列。"""
rest = []
tables = []
i = 0
fence = False
while i < len(src):
line = src[i]
# 程式碼圍欄裡的「|」是內容不是表格。mermaid 圖與範例被當表格拆掉,引言就毀了。
if line.lstrip().startswith('```'):
fence = not fence
rest.append(line)
i += 1
continue
if not fence and cells(line) is not None:
j = i
while j < len(src) and cells(src[j]) is not None:
j += 1
rows = [cells(x) for x in src[i:j]]
if len(rows) >= 2 and is_sep(rows[1]):
tables.append(rows)
else:
# 沒有分隔列就不是表格,原樣留著。
rest.extend(src[i:j])
i = j
continue
rest.append(line)
i += 1
return rest, tables
def table_blocks(rows):
"""一列一個 H2 區塊,欄位順序照表頭從左到右。"""
head = rows[0]
out = []
for cs in rows[2:]:
if is_sep(cs):
continue
if not any(c for c in cs):
continue
title = title_from_cell(cs[keycol - 1]) if len(cs) >= keycol else ''
if not title:
# 取不出身分就不猜標題。猜錯的標題比不到任何鍵,之後每次 upsert 都在它旁邊
# 再長一筆;停下來讓人看那一列,比留一筆對不上的紀錄安全。
sys.stderr.write(
'[jsc][gitea][ERR]:表格有一列取不出第 %d 欄的鍵,轉不成區塊。\n' % keycol)
raise SystemExit(1)
body = []
for n, name in enumerate(head):
label = plain(name) or ('欄位%d' % (n + 1))
value = cs[n].strip() if n < len(cs) else ''
body.append('- %s:%s' % (label, value))
out.append('## %s\n\n%s' % (title, '\n'.join(body)))
return out
def split_blocks(src):
"""切成引言與各 H2 區塊。第一個「## 」之前的都是引言。"""
pre = []
blocks = []
cur = None
fence = False
for line in src:
if line.lstrip().startswith('```'):
fence = not fence
if not fence and line.startswith('## '):
cur = [line]
blocks.append(cur)
continue
(cur if cur is not None else pre).append(line)
return pre, ['\n'.join(b).strip('\n') for b in blocks]
def preamble(path):
"""取一份檔案第一個「## 」之前的內容,也就是 H1 加「>」引言那一段。"""
src = open(path, encoding='utf-8').read().split('\n')
# 先拆掉表格:範本若在引言之前放了示範表格,照搬進去就等於在目錄頁上留下表格。
head, _ = split_blocks(split_tables(src)[0])
return head
rest, tables = split_tables(lines)
pre, blocks = split_blocks(rest)
# 範本的示範區塊會被當成真的一筆。照抄進新頁,那一筆就永遠留著,之後每次 upsert 都
# 比不到它的鍵而跳過,正式頁上多出一筆指向不存在的頁的死紀錄。所以建新頁只留引言。
if fresh:
blocks = []
else:
# 有表格就代表這一頁還是舊版面,這一次要轉檔。
converting = bool(tables)
for rows in tables:
blocks.extend(table_blocks(rows))
# 轉檔只搬表格、不動散文,舊引言就會一直講只對表格成立的話。範本的引言是正本,
# 轉檔正好是換掉它的時機。不轉檔就不動引言:那時呼叫端只是更新自己那一筆。
if converting and template_path:
tpl_pre = preamble(template_path)
# 範本沒有引言時保留舊的,換成空白等於把 H1 也弄掉。
if '\n'.join(tpl_pre).strip():
pre = tpl_pre
# 鍵就是標題,所以標題一律重寫成 key。兩者不一致的話,這一筆下一次就找不回來。
body = entry.split('\n')
if body and body[0].startswith('## '):
body = body[1:]
while body and not body[0].strip():
body = body[1:]
block = '## %s' % key
if body:
block += '\n\n' + '\n'.join(body).strip('\n')
hit = -1
for i, b in enumerate(blocks):
if b.split('\n', 1)[0][3:].strip() == key:
hit = i
break
if hit >= 0:
blocks[hit] = block
action = 'updated'
else:
blocks.append(block)
action = 'added'
head = '\n'.join(pre).strip('\n')
parts = ([head] if head else []) + blocks
text = '\n\n'.join(parts)
if not text.strip():
sys.stderr.write('[jsc][gitea][ERR]:組不出頁面內容,不寫入。\n')
raise SystemExit(1)
open(new_path, 'w', encoding='utf-8').write(text + '\n')
print(action)
PY
}
cmd="${1-}"
[ "$cmd" = upsert ] || [ "$cmd" = format ] || usage
shift
if [ "$cmd" = format ]; then
[ "$#" -ge 5 ] && [ "$#" -le 6 ] || usage
keycol="$1"
key="$2"
entryfile="$3"
oldfile="$4"
newfile="$5"
fresh=0
template=''
if [ "$#" -eq 6 ]; then
if [ "$6" = --fresh ]; then
fresh=1
else
template="$6"
fi
fi
check_keycol "$keycol"
[ -n "$key" ] || { echo 'key required' >&2; exit 2; }
[ -f "$entryfile" ] || { echo "找不到區塊檔案: $entryfile" >&2; exit 2; }
[ -f "$oldfile" ] || { echo "找不到舊頁檔案: $oldfile" >&2; exit 2; }
[ -z "$template" ] || [ -f "$template" ] || { echo "找不到範本檔: $template" >&2; exit 2; }
rc=0
render "$oldfile" "$entryfile" "$keycol" "$key" "$newfile" "$fresh" "$template" || rc=$?
[ "$rc" -eq 0 ] || exit 1
exit 0
fi
[ "$#" -ge 4 ] && [ "$#" -le 5 ] || usage
type=$(printf '%s' "${1-}" | tr a-z A-Z)
keycol="${2-}"
key="${3-}"
entryfile="${4-}"
template="${5-}"
[ -n "$type" ] || usage
page="${type}_CONTENTS"
# 頁型合不合法交給 page-name.sh 判:型別清單只留一份正本。
# 它連 CONTENTS 一起擋掉——CONTENTS 只用來解存取庫,沒有 CONTENTS_CONTENTS 這一頁。
sh "$page_name" check "$page" >/dev/null 2>&1 || { echo "不能用來組目錄頁頁名的頁型: $type" >&2; usage; }
check_keycol "$keycol"
[ -n "$key" ] || { echo 'key required' >&2; exit 2; }
[ -f "$entryfile" ] || { echo "找不到區塊檔案: $entryfile" >&2; exit 2; }
[ -z "$template" ] || [ -f "$template" ] || { echo "找不到範本檔: $template" >&2; exit 2; }
# 存取庫解析失敗照原碼傳出去:3 是「沒設定」,2 是型別不認得,兩者處置不同。
rc=0
repo=$(sh "$gitea" wiki-repo CONTENTS) || rc=$?
[ "$rc" -eq 0 ] || exit "$rc"
old=$(mktemp)
new=$(mktemp)
trap 'rm -f "$old" "$new"' EXIT
fresh=0
rc=0
sh "$gitea" wiki-get "$repo" "$page" > "$old" 2>/dev/null || rc=$?
case "$rc" in
0) ;;
4)
# 頁不存在。只有這一碼准許建新頁。
[ -n "$template" ] || {
echo "[jsc][gitea][ERR]:$repo/$page 不存在,也沒有給範本,不建新頁。" >&2
exit 4
}
cat "$template" > "$old"
fresh=1 ;;
7)
echo "[jsc][gitea][ERR]:讀 $repo/$page 遇金鑰失效或權限不足,整個動作中止。" >&2
exit 7 ;;
*)
echo "[jsc][gitea][ERR]:讀 $repo/$page 失敗(結束碼 $rc),整個動作中止。" >&2
exit 8 ;;
esac
rc=0
action=$(render "$old" "$entryfile" "$keycol" "$key" "$new" "$fresh" "$template") || rc=$?
# 整不出正確的頁就不要送出去。組不出內容跟送出失敗一樣寫不進去,共用結束碼 1。
[ "$rc" -eq 0 ] || exit 1
rc=0
sh "$gitea" wiki-put "$repo" "$page" "$new" >/dev/null || rc=$?
case "$rc" in
0) printf '%s %s/%s\n' "$action" "$repo" "$page" ;;
7) echo "[jsc][gitea][ERR]:寫入 $repo/$page 遇金鑰失效或權限不足。" >&2; exit 7 ;;
8) echo "[jsc][gitea][ERR]:寫入 $repo/$page 遇 API 失敗。" >&2; exit 8 ;;
*) echo "[jsc][gitea][ERR]:寫入 $repo/$page 失敗(結束碼 $rc)。" >&2; exit 1 ;;
esac
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env sh
# write-confirm.sh — Gitea 寫入前的人工確認。
#
# 為什麼要有這支腳本:wiki 與議題都是對外寫入。先確認,才能避免把
# 錯頁、錯內容、錯標籤直接送出去。
#
# 用法:
# write-confirm.sh <動作> <目標>
#
# 規則:
# - JSC_GITEA_CONFIRM=yes:直接放行。
# - JSC_GITEA_CONFIRM=no:直接拒絕。
# - 互動式執行:要求輸入「確認」。
# - 沒有互動終端:拒絕,避免默默寫出錯資料。
# 結束碼: 0=已確認,呼叫端接著送出這次寫入。
# 2=不要寫入,原因印在 stderr:缺動作名稱、缺目標名稱、JSC_GITEA_CONFIRM=no、
# 輸入的不是「確認」、讀不到輸入,以及沒有互動終端這五種都走這一碼。
# 呼叫端一律中止這次寫入,不要重試,也不要繞過確認直接呼叫 API。
# 只有 0 與 2 兩種。用法錯誤與人工拒絕共用 2,因為兩者的處置一樣是「不寫」;
# 拆成兩碼只會誘使呼叫端把用法錯誤當成可重試,反而寫出沒經過確認的資料。
set -eu
action="${1:-}"
target="${2:-}"
[ -n "$action" ] || { echo '[jsc][Gitea][ERR]:缺少動作名稱。' >&2; exit 2; }
[ -n "$target" ] || { echo '[jsc][Gitea][ERR]:缺少目標名稱。' >&2; exit 2; }
case "${JSC_GITEA_CONFIRM:-}" in
yes|YES|1|true|TRUE)
exit 0 ;;
no|NO|0|false|FALSE)
echo "[jsc][Gitea][ERR]:已取消寫入「$target」。" >&2
exit 2 ;;
esac
if [ -t 0 ]; then
printf '[jsc][Gitea] 即將%s「%s」。輸入「確認」繼續:' "$action" "$target" >&2
IFS= read -r answer || exit 2
case "$answer" in
確認)
exit 0 ;;
*)
echo "[jsc][Gitea][ERR]:已取消寫入「$target」。" >&2
exit 2 ;;
esac
fi
echo "[jsc][Gitea][ERR]:寫入「$target」需要先確認;請改用互動式執行,或先設 JSC_GITEA_CONFIRM=yes。" >&2
exit 2