diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c5e2c..a8956ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,13 @@ --- +## 0.18.34(2026-08-20) + +- 🔴 **雲端現在看得到你資料夾的完整層次,不再只有第一層**:以前不管你的資料夾疊了幾層,雲端只認得最外面那一層——`專案/教育部標案/會議紀錄` 這種放在深處的資料夾,在知識庫裡完全不存在,只會以檔案路徑的一段文字出現。現在**每一層資料夾都有自己的一格**,而且**每一格都連著它的上一層**,所以你可以像在檔案總管裡一樣,從最外層一路點進最深的那個資料夾。每一格還會告訴你這一層直接放了幾份文件、連同底下的子資料夾一共幾份。 +- 這些索引**不經 AI 萃取**(本機掃一遍就有答案),所以不吃你的每日額度,額度用完的時候照樣會更新。 +- 空的資料夾目前還不會出現(要等掃描開始記錄資料夾本身);已經同步過的內容不受影響。 +- 🔑 **同一份檔同步到兩台電腦,問答的出處不再只看得到一台**:上一版已經讓每份原稿記住它來自哪一台機器,但 AI 回答時還是會把同名的檔案當成同一份而只列出其中一台。現在內容還是只留一份(不會重複),但出處那一格會把它存在的每一台都列出來。 + ## 0.18.33(2026-08-18) - 🔴 **現在看得出每份原稿在哪一台電腦上**:以前知識庫只記得檔案的相對路徑,所以你在兩台電腦、或電腦加 NAS 上放了同名的檔,系統分不出誰是誰——問答的出處只會顯示其中一台,另一台就這樣消失了。現在每份原稿都會記住它來自哪一台機器,預設用「你的帳號@電腦名稱」,你可以改成自己看得懂的稱呼(例如「教育部 Leo 的 Mac」)。既有的資料不受影響,那一格會誠實地空著,不會憑空編一個機器名給你。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 69ee9a5..8c74fae 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,12 +1,12 @@ { "_algo": 4, - "version": "0.18.33", - "fingerprint": "7ba0f6dd2d080925", + "version": "0.18.34", + "fingerprint": "d8c7490b9d32e62e", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "5f2cd870b97a25fee01f5d2b4e7ac7c9afdd091c95ca5725ff7401823b651967", + "CHANGELOG.md": "435942d764a283e1637b2ac7b6bc7304b9fc1e79b152f89780d1fa8e62440aa4", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", - "README.md": "3329fbc8f4ca5e966f26c5745117bf2ddc65603b4ed78060835270dc39132bfc", + "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", "cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f", "cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa", @@ -111,7 +111,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13", - "direct.go": "27fed72987d3f5984cae939f0b09de069a9f13f97e24e91361b60399e1bdb5d8", + "direct.go": "98d51b233e31320de20f4c605ce420433fb4720136c9eb188fbd69e1a136fb69", "direct_extract_test.go": "d3465c1b5953858ae780d192a2cf515d452f48eee3ff18a6345ce64320debbbb", "direct_multi_test.go": "d0aa211d90aaaa9d7dbe2b04f1aaa5989e91e91b037144cc0f4e0fd068f43c2b", "direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3", @@ -126,6 +126,9 @@ "extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76", "extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3", "extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e", + "folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69", + "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", + "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", "foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b", "go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db", "go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3", @@ -144,7 +147,7 @@ "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", "machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0", "main.go": "e02e7b32dd230f33e35c7ebf62d81b7599e2b86a0ef7cadc0fd34b672f42381d", - "manifest.go": "ce3733ed13c6caedad477392e14af7714a39616819477182ab8bd14eb5d8eae6", + "manifest.go": "768049a34c06920711a980bb19fb41e7f1feff648f22265ba0c3d1c1ad85020f", "manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e", "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 3d13d78..2b92a74 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -3,5 +3,6 @@ "v0.18.30": "396f3c3b68cdd0b3", "0.18.31": "d6b2b6083258b853", "0.18.32": "4753db807169fe3d", - "0.18.33": "7ba0f6dd2d080925" + "0.18.33": "7ba0f6dd2d080925", + "0.18.34": "d8c7490b9d32e62e" } diff --git a/direct.go b/direct.go index 8387eab..415ffd5 100644 --- a/direct.go +++ b/direct.go @@ -1323,6 +1323,17 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta } } + // 碎形目錄索引(`inkstone/Arcrun#146`,leo 2026-08-19「每個巢狀資料夾都要有 index, + // 且每一層指向子層次目錄」):接在總覽卡後面,同一個理由放在萃取迴圈之前—— + // 目錄結構是本機掃一遍就有的答案,不該跟 LLM 排同一條隊、不該被額度牆擋住。 + // 一卡一關聯(見 folderindex.go 檔頭)⇒ 單一請求恆定 1 條 rel,不受資料夾數量影響。 + if fcRes := syncFolderCards(cfg, absRoot, m, len(payload.Events) > 0, dryRun, runNow); len(fcRes) > 0 { + results = append(results, fcRes...) + if fcRes[0].Status != "planned" { + saveManifest() // 同上:folder_card_hashes 當下就落盤,斷點續傳才接得上 + } + } + // 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、 // POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態, // 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。 diff --git a/folderindex.go b/folderindex.go new file mode 100644 index 0000000..cc2994d --- /dev/null +++ b/folderindex.go @@ -0,0 +1,364 @@ +// folderindex.go — 讓目錄索引變成**碎形**:每一層資料夾各有自己的一格,父子接得起來 +// (`inkstone/Arcrun#146`,leo 2026-08-19)。 +// +// leo 的原話(兩句,就是本檔的全部規格): +// +// 「daemon 可以**碎形**的在每個巢狀資料夾中產生 index & wiki」 +// 「**每一層目錄指向子層次目錄**」 +// +// 🔴 為什麼不是把 inventory.go 的上限調大: +// +// `inventory.go` 的資料模型只有**兩層**——`inventoryCardPath(library)` 讓一個監看根 +// 只能有一張卡,`invTopDirOf(rel)` 只取相對路徑第一段。實測(14 檔/4 層巢狀)產出 +// 1 張卡、3 條「第一層 >> part_of >> 總覽頁」,第二層以下一個節點都沒有。 +// 深度是**寫死在模型裡的**,不是參數。 +// +// 🔴 為什麼是「一個資料夾一張卡」而不是「一張卡帶全部關聯」: +// +// `rag_ingest_card` 對每條 rel 各發一個 POST(foreach)+upsert 前置的逐筆 DELETE。 +// 把 N 個目錄的關聯塞進同一張卡 ⇒ 單一請求 N 條 rel ⇒ 撞 Workers 免費層 +// **50 subrequests/請求**的天花板(`inventory.go` 的 `maxInventoryRels = 10` +// 就是為了這面牆才壓的)。拆成一卡一關聯後,**每個請求恆定 1 條 rel**, +// 天花板與資料夾數量脫鉤;變多的是請求「次數」,那個由既有的 `pace()` 節流吸收。 +// +// 誠實界線(不要讓讀的人以為它做了沒做的事): +// - **空目錄不會有卡**。目錄集合是從 manifest 的檔案路徑反推的,而 `scan.go` 從來 +// 沒有記錄過目錄本身(`d.IsDir()` 只用來決定要不要整棵跳過)。 +// 「空資料夾也要看得見」是 `inkstone/arcrun-rag#106`,要先讓掃描記得目錄才做得到。 +// - 卡片內容是**機械統計**(檔數、子資料夾、最近改了什麼),不經 LLM —— +// 與 `inventory.go` 同一個理由:這種問題本機掃一遍就有答案,不該跟萃取排同一條隊。 +package collector + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "path" + "sort" + "strings" + "time" +) + +const ( + // maxFolderCards=單一監看根最多產幾張資料夾卡。 + // + // 🔴 這是一道**會被講出來**的上限,不是安靜截斷(principles「no silent caps」): + // 超過時 `syncFolderCards` 會在結果訊息裡指名少送了幾個、從哪一層以下開始少。 + // 200 的由來:一次同步 200 個請求已經是既有 `MaxEventsPerRun`(預設同量級)的規模, + // 再多就該讓使用者知道「你的資料夾深到要分批」,而不是我們自己吞掉。 + maxFolderCards = 200 + // maxFolderChildrenListed=卡片上「子資料夾」段最多列幾個。純顯示上限, + // 不影響關聯(關聯永遠由子卡自己宣告,見檔頭)。 + maxFolderChildrenListed = 50 + // maxFolderFilesListed=卡片上「本層檔案」最多列幾個。 + maxFolderFilesListed = 100 +) + +// FolderCard=一個資料夾在雲端的那一格。 +type FolderCard struct { + // Rel=相對監看根的路徑(`""`=根本身,根不由本檔產卡,見 BuildFolderCards)。 + Rel string + // Page=雲端頁名(也是三元組裡的節點名)。 + Page string + // Path=合成的 source_uri 鍵(不落地本機,同 inventoryCardPath 的慣例)。 + Path string + // Parent=父資料夾的頁名。這就是「每一層指向子層次目錄」的那條邊的另一端。 + Parent string + // Content=卡片全文(markdown)。 + Content string +} + +// folderCardPageName 回傳某個相對路徑的卡片頁名。 +// 根用 inventoryPageName(沿用既有那張總覽卡,不另開一張打對台); +// 子層用 `資料夾:<根名>/<相對路徑>`——帶根名是因為不同監看根可能有同名子資料夾。 +func folderCardPageName(absRoot, rel string) string { + if rel == "" { + return inventoryPageName(absRoot) + } + base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")) + if base == "" || base == "." || base == "/" { + base = "知識資料夾" + } + return "資料夾:" + base + "/" + rel +} + +// folderCardPath 回傳子資料夾卡的合成路徑。 +// 帶 library(每個監看根唯一)+相對路徑的 slug ⇒ 同帳號多資料夾不互撞。 +// slug 為空(例如純中文路徑)時退成路徑雜湊,理由同 librarySlug 的 t89。 +func folderCardPath(library, rel string) string { + slug := pathSlug(rel) + return ".arcrun-rag/wiki/cards/arcrun-資料夾-" + library + "-" + slug + ".md" +} + +// pathSlug 把相對路徑轉成檔名安全的鍵。非 ASCII 一律轉底線分段(同 librarySlug 的規則), +// 全轉光就退成 sha256 前 8 hex——**路徑穩定所以鍵穩定,兩個不同路徑必然不同鍵**。 +func pathSlug(rel string) string { + var b strings.Builder + lastUnderscore := false + for _, r := range rel { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_': + b.WriteRune(r) + lastUnderscore = false + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) + lastUnderscore = false + default: + if !lastUnderscore && b.Len() > 0 { + b.WriteRune('_') + lastUnderscore = true + } + } + } + s := strings.Trim(b.String(), "_") + sum := sha256.Sum256([]byte(rel)) + short := hex.EncodeToString(sum[:])[:8] + if s == "" { + return "d_" + short + } + // 一律接上雜湊尾碼:slug 會把「A/B」與「A_B」壓成同一個字串, + // 只靠 slug 會讓兩個不同資料夾共用一張卡(靜默混層,同 t89 的形狀)。 + return s + "-" + short +} + +// dirsFromEntries 從 manifest 的檔案路徑反推出**所有祖先目錄**(含中間層)。 +// 回傳排序過的相對路徑(不含根的 "")。 +func dirsFromEntries(entries map[string]*ManifestEntry) []string { + set := map[string]bool{} + for p, e := range entries { + if e == nil { + continue + } + rel := strings.ReplaceAll(p, "\\", "/") + seg := strings.Split(rel, "/") + if len(seg) < 2 { + continue // 根層檔案,沒有祖先目錄 + } + for i := 1; i < len(seg); i++ { + set[strings.Join(seg[:i], "/")] = true + } + } + out := make([]string, 0, len(set)) + for d := range set { + out = append(out, d) + } + sort.Strings(out) + return out +} + +// parentRel 回傳某個相對目錄的父目錄(頂層目錄的父是根,回 "")。 +func parentRel(rel string) string { + if i := strings.LastIndexByte(rel, '/'); i > 0 { + return rel[:i] + } + return "" +} + +// BuildFolderCards 為每一個(含有檔案的)子資料夾各做一張卡。純函式、零 IO、輸出確定。 +// +// 根本身不在回傳值裡——根那張是 `inventory.go` 的總覽卡,兩者分工不重疊: +// 總覽卡回答「這個監看根整體有什麼」,資料夾卡回答「這一層有什麼、它掛在誰底下」。 +func BuildFolderCards(absRoot string, entries map[string]*ManifestEntry, library string) []FolderCard { + dirs := dirsFromEntries(entries) + + // 每層直屬檔案 / 每層子目錄 / 每層含子孫的總檔數 + direct := map[string][]invFile{} + subtotal := map[string]int{} + children := map[string][]string{} + for p, e := range entries { + if e == nil { + continue + } + rel := strings.ReplaceAll(p, "\\", "/") + d := path.Dir(rel) + if d == "." { + d = "" + } + direct[d] = append(direct[d], invFile{path: path.Base(rel), mtime: e.Mtime}) + for cur := d; ; cur = parentRel(cur) { + subtotal[cur]++ + if cur == "" { + break + } + } + } + for _, d := range dirs { + p := parentRel(d) + children[p] = append(children[p], d) + } + for k := range children { + sort.Strings(children[k]) + } + + cards := make([]FolderCard, 0, len(dirs)) + for _, rel := range dirs { + page := folderCardPageName(absRoot, rel) + var b strings.Builder + sep := " " + strings.Repeat(">", 2) + " " + + b.WriteString("# " + page + "\n\n") + + b.WriteString("## 一句話定義\n") + b.WriteString(fmt.Sprintf("這是「%s」底下的資料夾 `%s`:本層直接放了 %d 份文件,連同子資料夾共 %d 份。本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取。\n\n", + path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")), + rel, len(direct[rel]), subtotal[rel])) + + // 子資料夾:這一段就是「每一層目錄指向子層次目錄」給人看的那一面 + // (給機器看的那一面是子卡自己的「## 關聯」)。 + b.WriteString("## 子資料夾\n") + kids := children[rel] + if len(kids) == 0 { + b.WriteString("(沒有子資料夾)\n") + } + for i, c := range kids { + if i >= maxFolderChildrenListed { + b.WriteString(fmt.Sprintf("- (其餘 %d 個子資料夾未列出)\n", len(kids)-i)) + break + } + b.WriteString(fmt.Sprintf("- %s(%d 份)\n", path.Base(c), subtotal[c])) + } + b.WriteString("\n") + + b.WriteString("## 本層檔案\n") + files := direct[rel] + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + if len(files) == 0 { + b.WriteString("(本層沒有直接放文件,內容都在子資料夾裡)\n") + } + for i, f := range files { + if i >= maxFolderFilesListed { + b.WriteString(fmt.Sprintf("- (其餘 %d 份未列出)\n", len(files)-i)) + break + } + b.WriteString("- " + f.path + "(" + invDateOf(f.mtime) + ")\n") + } + b.WriteString("\n") + + // ── 關聯:**恰好一條**,指向父層。天花板與資料夾數量脫鉤的關鍵就在這裡。── + parent := folderCardPageName(absRoot, parentRel(rel)) + b.WriteString("## 關聯\n") + b.WriteString("- " + page + sep + "part_of" + sep + parent + "\n") + + cards = append(cards, FolderCard{ + Rel: rel, Page: page, Path: folderCardPath(library, rel), + Parent: parent, Content: b.String(), + }) + } + return cards +} + +// syncFolderCards 在總覽卡之後送資料夾卡。回 nil=這輪不必送。 +// +// 冪等:每張卡的內容雜湊記在 `manifest.folder_card_hashes[rel]`,沒變不送。 +// 失敗不擋主流程(同 syncInventory);額度訊息換成人話。 +// 上限:`maxFolderCards`,超過的**指名講出來**,不安靜吞掉。 +func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) []DirectResult { + if !hasEvents && len(m.FolderCardHashes) > 0 { + return nil // 靜止輪不重送(同 syncInventory 的③) + } + lib := cfg.libraryFor(absRoot) + cards := BuildFolderCards(absRoot, m.Entries, lib) + if len(cards) == 0 { + return nil + } + if m.FolderCardHashes == nil { + m.FolderCardHashes = map[string]string{} + } + + // 先刪掉「這輪已經不存在的資料夾」的記帳,避免雜湊表無限長大。 + live := map[string]bool{} + for _, c := range cards { + live[c.Rel] = true + } + for rel := range m.FolderCardHashes { + if !live[rel] { + delete(m.FolderCardHashes, rel) + } + } + + // 🔴 先挑「這輪真的要送的」,**再**套上限——順序反過來就會變成永遠只重試前 200 個, + // 後面的資料夾一輩子送不上去(而且畫面上看起來一切正常)。 + type pending struct { + card FolderCard + hash string + } + var todo []pending + for _, c := range cards { + sum := sha256.Sum256([]byte(c.Content)) + h := "sha256:" + hex.EncodeToString(sum[:]) + if m.FolderCardHashes[c.Rel] == h { + continue // 冪等:內容沒變 + } + todo = append(todo, pending{card: c, hash: h}) + } + if len(todo) == 0 { + return nil + } + + var dropped []string + if len(todo) > maxFolderCards { + for _, t := range todo[maxFolderCards:] { + dropped = append(dropped, t.card.Rel) + } + todo = todo[:maxFolderCards] + } + + var out []DirectResult + wf := cfg.CardIngestWF + if wf == "" { + wf = "rag_ingest_card" + } + mach := cfg.machineIdentity() + for _, t := range todo { + c := t.card + res := DirectResult{Type: "folder", Path: c.Path} + if dryRun { + res.Status = "planned" + out = append(out, res) + continue + } + pace() + status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{ + "page_name": c.Page, + "path": c.Path, + "card_content": c.Content, + "library": lib, + "machine": mach.ID, + "machine_label": mach.Label, + }) + res.HTTPStatus = status + if err != nil { + res.Status = "failed" + if isQuotaExhausted(err.Error()) { + res.Error = "雲端今天的額度用完了,資料夾索引會稍後自動補送(不影響檔案同步)" + } else { + res.Error = "資料夾索引上傳失敗(不影響檔案同步):" + err.Error() + } + out = append(out, res) + // 這一張失敗就停手:後面幾百張多半會撞同一面牆,繼續撞只是把 + // 「一個壞掉的雲端」變成「幾百筆一樣的錯誤」(t195 的 1387 輪教訓)。 + break + } + res.Status = "ingested" + m.FolderCardHashes[c.Rel] = t.hash + out = append(out, res) + } + + if len(dropped) > 0 { + out = append(out, DirectResult{ + Type: "folder", + Status: "skipped", + Error: fmt.Sprintf("資料夾超過單輪上限 %d 個,這輪少送 %d 個(例:%s)——下一輪會接著送", + maxFolderCards, len(dropped), strings.Join(firstN(dropped, 3), "、")), + }) + } + return out +} + +// firstN 取前 n 個(不足就全給)。只給訊息用。 +func firstN(ss []string, n int) []string { + if len(ss) <= n { + return ss + } + return ss[:n] +} diff --git a/folderindex_helpers_test.go b/folderindex_helpers_test.go new file mode 100644 index 0000000..3c350b1 --- /dev/null +++ b/folderindex_helpers_test.go @@ -0,0 +1,10 @@ +package collector + +import ( + "fmt" + "time" +) + +func pad(i int) string { return fmt.Sprintf("%03d", i) } + +func nowFixed() time.Time { return time.Unix(1787100000, 0) } diff --git a/folderindex_test.go b/folderindex_test.go new file mode 100644 index 0000000..d569c00 --- /dev/null +++ b/folderindex_test.go @@ -0,0 +1,164 @@ +package collector + +import ( + "strings" + "testing" +) + +// ent 造一份最小 manifest entries(只有 path 有意義)。 +func ent(paths ...string) map[string]*ManifestEntry { + m := map[string]*ManifestEntry{} + for i, p := range paths { + m[p] = &ManifestEntry{ContentHash: "sha256:x", Mtime: int64(1787000000 + i)} + } + return m +} + +// 這份就是 leo 08-19 要 demo 的形狀:4 層巢狀。 +var nested = ent( + "README.md", + "01-公司制度/請假規則.md", + "01-公司制度/資訊安全/密碼原則.md", + "02-專案/教育部標案/RFP摘要.md", + "02-專案/教育部標案/會議紀錄/20260801-啟動會議.md", + "02-專案/教育部標案/會議紀錄/20260812-期中檢討.md", + "03-產品/價目表.csv", +) + +func TestDirsIncludeIntermediateLevels(t *testing.T) { + got := dirsFromEntries(nested) + want := []string{ + "01-公司制度", "01-公司制度/資訊安全", + "02-專案", "02-專案/教育部標案", "02-專案/教育部標案/會議紀錄", + "03-產品", + } + if len(got) != len(want) { + t.Fatalf("目錄數 %d,期望 %d:%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("第 %d 個是 %q,期望 %q", i, got[i], want[i]) + } + } +} + +// 🔴 本檔的核心宣稱:每個巢狀資料夾各一張卡,而且父子接得起來。 +func TestFractalOneCardPerFolderAndChainReachesRoot(t *testing.T) { + cards := BuildFolderCards("/x/demo-test1", nested, "test1") + if len(cards) != 6 { + t.Fatalf("卡數 %d,期望 6(每個子資料夾一張)", len(cards)) + } + + byPage := map[string]FolderCard{} + for _, c := range cards { + byPage[c.Page] = c + // 每張卡的「## 關聯」恰好一條——這是「不撞 50 subrequests」的結構保證 + rel := c.Content[strings.Index(c.Content, "## 關聯"):] + if n := strings.Count(rel, "\n- "); n != 1 { + t.Fatalf("%s 的關聯有 %d 條,必須恰好 1 條", c.Page, n) + } + if !strings.Contains(rel, c.Page+" >> part_of >> "+c.Parent) { + t.Fatalf("%s 的關聯不是指向父層 %s:%q", c.Page, c.Parent, rel) + } + } + + // 從最深的一格一路往上走,必須走得到根(=總覽卡頁名),且步數正確。 + deepest := folderCardPageName("/x/demo-test1", "02-專案/教育部標案/會議紀錄") + cur, hops := byPage[deepest], 0 + if cur.Page == "" { + t.Fatalf("找不到最深的那張卡 %q", deepest) + } + for { + hops++ + parent, ok := byPage[cur.Parent] + if !ok { + if cur.Parent != inventoryPageName("/x/demo-test1") { + t.Fatalf("鏈斷在 %q,它的父 %q 既不是卡也不是根", cur.Page, cur.Parent) + } + break + } + cur = parent + if hops > 10 { + t.Fatal("往上走超過 10 步,疑似有環") + } + } + if hops != 3 { + t.Fatalf("從第三層走到根走了 %d 步,期望 3", hops) + } +} + +// 卡片要講得出「這一層有幾份、含子層幾份」與子資料夾清單——那是給人看的「指向子層」。 +func TestCardCountsAndChildrenListing(t *testing.T) { + cards := BuildFolderCards("/x/demo-test1", nested, "test1") + var top FolderCard + for _, c := range cards { + if c.Rel == "02-專案" { + top = c + } + } + if top.Page == "" { + t.Fatal("找不到 02-專案 的卡") + } + if !strings.Contains(top.Content, "本層直接放了 0 份文件,連同子資料夾共 3 份") { + t.Fatalf("份數講錯了:\n%s", top.Content) + } + if !strings.Contains(top.Content, "- 教育部標案(3 份)") { + t.Fatalf("子資料夾清單不對:\n%s", top.Content) + } +} + +// t89 同款:slug 會把 "A/B" 與 "A_B" 壓成同一個字串,只靠 slug 會讓兩個資料夾共用一張卡。 +func TestPathSlugDoesNotCollapseDifferentPaths(t *testing.T) { + if pathSlug("a/b") == pathSlug("a_b") { + t.Fatal("a/b 與 a_b 的鍵相同 ⇒ 兩個資料夾會共用一張卡(靜默混層)") + } + // 純中文路徑 slug 後為空,必須退成雜湊而不是空字串 + z := pathSlug("專案/會議") + if z == "" || strings.HasPrefix(z, "-") { + t.Fatalf("純中文路徑的鍵不合法:%q", z) + } + if pathSlug("專案/會議") == pathSlug("專案/紀錄") { + t.Fatal("兩個不同的純中文路徑得到同一個鍵") + } +} + +// 🔴 迴歸守門:上限必須套在「要送的」上,不是套在「全部」上。 +// 反過來寫的話,第 201 個之後的資料夾永遠送不上去,而畫面上一切正常。 +func TestCapAppliesToPendingNotAll(t *testing.T) { + paths := make([]string, 0, maxFolderCards+20) + for i := 0; i < maxFolderCards+20; i++ { + paths = append(paths, "d"+pad(i)+"/f.md") + } + entries := ent(paths...) + cards := BuildFolderCards("/x/root", entries, "lib") + if len(cards) != maxFolderCards+20 { + t.Fatalf("卡數 %d,期望 %d", len(cards), maxFolderCards+20) + } + + m := &Manifest{Entries: entries, FolderCardHashes: map[string]string{}} + // 假裝前 maxFolderCards 個都已經送成功(雜湊相同) + for _, c := range cards[:maxFolderCards] { + m.FolderCardHashes[c.Rel] = hashOf(c.Content) + } + cfg := &DirectConfig{} + res := syncFolderCards(cfg, "/x/root", m, true, true /*dryRun*/, nowFixed()) + if len(res) != 20 { + t.Fatalf("這輪排到 %d 張,期望剩下的 20 張——上限若套在全部上,這裡會是 0", len(res)) + } +} + +func TestIdempotentWhenNothingChanged(t *testing.T) { + m := &Manifest{Entries: nested, FolderCardHashes: map[string]string{}} + cfg := &DirectConfig{} + first := syncFolderCards(cfg, "/x/demo-test1", m, true, true, nowFixed()) + if len(first) != 6 { + t.Fatalf("第一輪 %d 張,期望 6", len(first)) + } + // dryRun 不寫雜湊,改用真的算一次填進去模擬送成功 + for _, c := range BuildFolderCards("/x/demo-test1", nested, "") { + m.FolderCardHashes[c.Rel] = hashOf(c.Content) + } + if second := syncFolderCards(cfg, "/x/demo-test1", m, true, true, nowFixed()); second != nil { + t.Fatalf("內容沒變卻還要送 %d 張", len(second)) + } +} diff --git a/manifest.go b/manifest.go index eb14e30..7c2973e 100644 --- a/manifest.go +++ b/manifest.go @@ -70,6 +70,11 @@ const MaxFailBeforeSkip = 8 // Manifest 對應一個被勾選的資料夾。 type Manifest struct { FolderID string `json:"folder_id"` + // FolderCardHashes=每個子資料夾索引卡的內容雜湊(鍵=相對監看根的目錄路徑)。 + // 冪等用:內容沒變就不重送(同 InventoryHash 的角色,只是一層變多層)。 + // 資料夾消失時由 syncFolderCards 清掉對應的鍵,不讓這張表無限長大。 + // (`inkstone/Arcrun#146` 碎形目錄索引) + FolderCardHashes map[string]string `json:"folder_card_hashes,omitempty"` Root string `json:"root"` Entries map[string]*ManifestEntry `json:"entries"` // InventoryHash=最後一次**成功送達雲端**的資料夾總覽卡內容雜湊(結構先行,