1 Commits

6 changed files with 234 additions and 7 deletions
+6
View File
@@ -47,6 +47,12 @@
---
## 0.18.492026-08-29
- **雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的**:以前小幫手回報資料夾結構時沒有附上自己是誰,所以你在雲端看到的每一個資料夾都掛在「未知來源」底下——幾年下來累積的資料夾全擠在同一個位置,看起來就像別人的東西。現在每一次回報都帶著這台電腦的名字(你在設定裡取過名就用你取的)。
- 改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去。
- ⚠️ 這一格要雲端也換新了才看得到;小幫手這邊先把名字送出去。收什麼檔、怎麼整理完全沒改。
## 0.18.482026-08-28
- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。
+6 -5
View File
@@ -1,10 +1,10 @@
{
"_algo": 4,
"version": "0.18.48",
"fingerprint": "a440a58a629f960c",
"version": "0.18.49",
"fingerprint": "7cb86e646aeaa057",
"files": {
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
"CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b",
"CHANGELOG.md": "19af5eaa6b67bcd922fd07a53a42ce75c52bec23ad2c161323e925d5860e9031",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
@@ -124,7 +124,7 @@
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
"direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f",
"direct.go": "7a3d2355b5e7442c55b99ce3896e9ba9acee875604b83f2673c1240aed57bbcf",
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
@@ -144,9 +144,10 @@
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
"foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd",
"foldertree.go": "fb86c8d869a476d77d548e9c9a7c14be51c76fa1400a6497016b705ff9c4ce13",
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
"foldertree_machine_test.go": "f5dc73afc261b0fb0298f27962628e8538abc704540b52d66e13bf85600a59dd",
"foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea",
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
"foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa",
+2 -1
View File
@@ -18,5 +18,6 @@
"0.18.45": "dc05513103f93306",
"0.18.46": "1325784830f592ff",
"0.18.47": "0decb0810da46a3f",
"0.18.48": "a440a58a629f960c"
"0.18.48": "a440a58a629f960c",
"0.18.49": "7cb86e646aeaa057"
}
+6 -1
View File
@@ -1542,8 +1542,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// payload.AllExcludedDirsplan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries**
//(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。
// 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。
//
// StampMachine`inkstone/Arcrun#180`):蓋上「這棵樹是哪一台機器算的」。蓋在這裡
// ——build 之後、Publish 與 sync 之前——所以**本機快照與上雲酬載必然是同一份身分**。
// 卡片那條路(folderindexinventorysourcerepair)用的是同一個 cfg.machineIdentity()
// 同一輪只解析一次,兩條路送上去的值必然相同。
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
payload.AllExcludedDirs, plan, runNow)
payload.AllExcludedDirs, plan, runNow).StampMachine(cfg.machineIdentity())
if !dryRun {
PublishFolderTreeNow(cfg.Manifest, root, tree, runNow)
}
+41
View File
@@ -149,6 +149,41 @@ type FolderTree struct {
Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來
TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數
GeneratedAt int64 `json:"generated_at"`
// MachineMachineLabel**這棵樹是哪一台機器報上來的**`inkstone/Arcrun#180`)。
//
// 🔴 為什麼非有不可:雲端的庫清單是「總庫 → 機器 → 資料夾」三層。樹的酬載裡沒有
// 這一格時,**每一個庫都只能掛在「未知來源」底下** ⇒ leo 2026-08-28 看到 16 個庫
// 全擠在同一個未知節點下,第一反應是「這是把別的帳號同步的資料夾外洩了?」。
// 那個驚嚇是畫面造成的,而畫面之所以說不出來,是因為**上行酬載根本沒送**。
//
// 🔴 **不是新發明的東西**:機器身分早就存在(`machine.json``ResolveMachine`),
// 卡片那條路也早就在送了(`folderindex.go``inventory.go``sourcerepair.go`
// 的 `"machine"``"machine_label"` 兩欄)。這裡**照那條路走**,欄名一字不差,
// 收端才不必為了樹另認一組欄位。
//
// Machine=比對鍵(鑄好不變);MachineLabel=顯示名(使用者可在 config 改)。
// 兩格分開的理由見 machineid.go:改名不該讓庫裡憑空多出一台機器。
//
// 🔴 這兩格**進 Hash()**(不像 GeneratedAt 那樣被剔掉):使用者改了 machine_label
// 之後,雲端要看得到新名字 ⇒ 內容雜湊必須跟著變,否則冪等閘會讓它永遠不再送。
Machine string `json:"machine,omitempty"`
MachineLabel string `json:"machine_label,omitempty"`
}
// StampMachine 蓋上「這棵樹是哪一台機器算的」,回一份新的樹(不改原件)。
//
// 🔴 為什麼是「蓋章」而不是塞進 BuildFolderTree 的參數:BuildFolderTree 是純函式,
// 吃的全是「這個資料夾長什麼樣」的事實;機器身分是**這台電腦是誰**,是另一回事。
// 分開之後,樹的既有測試不必為了機器身分多餵一份假身分。
//
// 🔴 為什麼要蓋在樹上、而不是送出時才從 cfg 拿:本機快照(folder-trees.json
// 桌面小幫手讀的那份)與上雲的酬載**是同一個 FolderTree**。蓋在樹上,兩邊必然一致;
// 送出時才拿,本機那份就永遠是空的——而「本機那份有沒有 machine」正是這次的檢查點。
func (t FolderTree) StampMachine(m MachineIdentity) FolderTree {
t.Machine = m.ID
t.MachineLabel = m.Label
return t
}
// BuildFolderTree 把「走訪時數出來的分母」與「manifest 現況的分子」合成一棵樹。
@@ -354,6 +389,12 @@ func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderT
"generated_at": tree.GeneratedAt,
"sync_token": h,
"nodes": tree.Nodes,
// 🔴 機器身分(`inkstone/Arcrun#180`):欄名與卡片那條路一字不差
// `folderindex.go``inventory.go``sourcerepair.go`),收端不必另認一組。
// 值取自 `tree`(已由 StampMachine 蓋章)而不是這裡再問一次 cfg——
// 本機快照與上雲酬載共用同一個 FolderTree,一個來源就不會漂。
"machine": tree.Machine,
"machine_label": tree.MachineLabel,
}
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
res.HTTPStatus = status
+173
View File
@@ -0,0 +1,173 @@
package collector
// foldertree_machine_test.go — 資料夾樹要說得出「**是哪一台機器報上來的**」
//`inkstone/Arcrun#180`)。
//
// 這張票的現象是 leo 2026-08-28 的原話:
//
// 「youlin 從桌面同步 4 個資料夾,雲端顯示 16 個,**這是把別的帳號同步的資料夾外洩了?**」
//
// 實查後不是外洩(那 17 個庫全是他自己歷史上同步過的),但**畫面說不出來**:
// 雲端的庫清單是「總庫 → 機器 → 資料夾」三層,而樹的上行酬載裡沒有機器身分
// ⇒ 每一個庫都只能掛在「未知來源」底下 ⇒ 看起來就像別人的東西。
//
// 🔴 本檔守的是「**送出去的東西身上有沒有這一格**」,不是畫面長怎樣(那在 matrix/arcrun)。
// 三個檢查點,各對應一種會靜默壞掉的方式:
//
// ① 酬載欄名要與卡片那條路一字不差 —— 收端不必為了樹另認一組欄位
// ② 改名要算成「內容變了」 —— 否則冪等閘會讓新名字永遠送不出去
// ③ 真的跑一輪,兩邊都要有 —— 有人在別處新增 BuildFolderTree 呼叫卻忘了蓋章時,這條會紅
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// ① 酬載欄名:與 folderindex.goinventory.gosourcerepair.go 送卡片時完全相同。
func TestSyncFolderTree酬載帶得出機器身分(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &got)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
mustWrite(t, filepath.Join(root, "note.md"), "# note")
tree := buildTreeFromDisk(t, root).StampMachine(MachineIdentity{
ID: "youlinhsieh@Leo-MBA",
Label: "教育部 Leo 的 Mac",
})
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
res := syncFolderTree(cfg, root, m, tree, false, time.Unix(1786900000, 0))
if res == nil || res.Status != "ingested" {
t.Fatalf("應送達:%+v", res)
}
// 🔴 欄名寫死在這裡是刻意的:收端(cypher-executor 的 /portal/daemon/folder-tree
// 認的就是這兩個字串。改欄名=改協定,這條測試就是那道閘。
if got["machine"] != "youlinhsieh@Leo-MBA" {
t.Errorf("酬載的 machine(比對鍵)不對:%v", got["machine"])
}
if got["machine_label"] != "教育部 Leo 的 Mac" {
t.Errorf("酬載的 machine_label(顯示名)不對:%v", got["machine_label"])
}
// 比對鍵與顯示名**必須是兩格**:只送一格的話,使用者改名就等於雲端多出一台機器。
if got["machine"] == got["machine_label"] {
t.Error("改過名的機器,比對鍵與顯示名不該相同——有一格被另一格頂替了")
}
}
// ② 改名要算成「內容變了」。
//
// 這是本檔最容易被忽略的一格:`Hash()` 是冪等閘的唯一判準,機器身分若沒進雜湊,
// 使用者在 config.json 改了 `machine_label` 之後,樹的內容沒動 ⇒ 永遠不再送
// ⇒ **雲端會一直顯示舊名字,而且沒有任何機制會發現**。
func TestFolderTreeHash把機器改名算成內容變了(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "note.md"), "# note")
base := buildTreeFromDisk(t, root)
before := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "youlinhsieh@Leo-MBA"})
after := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "教育部 Leo 的 Mac"})
if before.Hash() == after.Hash() {
t.Error("改了顯示名雜湊卻沒變——冪等閘會讓新名字永遠送不上去")
}
// 沒蓋章的樹與蓋了章的樹也必須不同,否則升級到本版的機器不會補送一次。
if base.Hash() == before.Hash() {
t.Error("蓋上機器身分之後雜湊沒變——舊機器升級後不會補送,雲端永遠是「未知來源」")
}
// 但時間仍然不進雜湊(既有慣例,不准被這次改動弄壞)。
later := before
later.GeneratedAt = before.GeneratedAt + 999
if later.Hash() != before.Hash() {
t.Error("時間跑進雜湊了——那等於沒有冪等,每輪都會重送整棵樹")
}
}
// ③ 真的跑一輪:本機快照與上雲酬載**是同一份身分**。
//
// 🔴 這條守的是「有人忘了蓋章」。`StampMachine` 蓋在 direct.go 的 BuildFolderTree
// 呼叫點上;日後若有人在別處另起一個呼叫、或把蓋章那一行刪掉,
// 前兩條測試(各自手動蓋章)照樣會綠,只有這條會紅。
func TestRunDirectOnce的樹一路帶著機器身分(t *testing.T) {
var treeBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &treeBody)
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "我的知識庫")
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
defer func() { fetchCloudVersion = orig }()
manifest := filepath.Join(base, "manifest.json")
cfg := &DirectConfig{
Manifest: manifest,
MachineLabel: "教育部 Leo 的 Mac", // 使用者改過名 ⇒ 比對鍵與顯示名不同,兩格才分得出來
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true, // 隔離變因:本測驗的是身分有沒有一路帶著,不是萃取
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatalf("同步應成功,exit=%d", exit)
}
// 這一輪實際解析出來的身分(machine.json 就鑄在 manifest 旁邊)。
want := cfg.machineIdentity()
if want.ID == "" {
t.Fatal("這台機器連身分都沒鑄出來——machineid.go 那條線先壞了")
}
// 本機快照(桌面小幫手讀的那份)
store, err := LoadFolderTreeStore(FolderTreeStorePath(manifest))
if err != nil {
t.Fatalf("跑完一輪應該有快照可讀:%v", err)
}
tree, ok := store.Trees[root]
if !ok {
t.Fatalf("快照裡沒有這個看守根:%+v", store.Trees)
}
if tree.Machine != want.ID || tree.MachineLabel != want.Label {
t.Errorf("本機快照沒蓋機器身分:machine=%q label=%q(應為 %q%q",
tree.Machine, tree.MachineLabel, want.ID, want.Label)
}
// 上雲酬載
if treeBody == nil {
t.Fatal("這一輪沒有把樹送上去——沒東西可驗")
}
if treeBody["machine"] != want.ID || treeBody["machine_label"] != want.Label {
t.Errorf("上雲酬載沒帶機器身分:%v%v(應為 %q/%q",
treeBody["machine"], treeBody["machine_label"], want.ID, want.Label)
}
// 🔴 兩邊必須是同一份,不是各算各的(本 repo 對「第二份實作」的一貫紅線)。
if treeBody["machine"] != tree.Machine || treeBody["machine_label"] != tree.MachineLabel {
t.Error("本機快照與上雲酬載的機器身分不一致——有一邊自己另算了一份")
}
// 使用者改過名 ⇒ 顯示名該是他設的那個,比對鍵不該跟著變。
if want.Label != "教育部 Leo 的 Mac" {
t.Errorf("config 的 machine_label 沒被採用:%q", want.Label)
}
if want.ID == want.Label {
t.Error("改名把比對鍵也改掉了——那會讓雲端以為多出一台機器")
}
}