From 9cd0adab226beb4058c311fcce2a3b4444cd889b Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 6 Aug 2026 01:04:25 +0800 Subject: [PATCH] =?UTF-8?q?G-6.2=EF=BC=9A=E8=AE=80=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E6=AA=94=E6=A1=88=E4=B8=8D=E5=86=8D=E5=AE=89=E9=9D=9C?= =?UTF-8?q?=E6=B6=88=E5=A4=B1=E2=80=94=E2=80=94=E9=A6=96=E9=A0=81=E7=95=B6?= =?UTF-8?q?=E5=A0=B4=E8=AA=AA=E5=87=BA=E4=BE=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服務 J-1 / S6「我丟進去的檔案,查得到」的考題 G-6.2: 「要嘛查得到,要嘛**當場被告知這種檔案還不支援**,不准安靜地略過。」 本次只做後半句(轉檔本體另有人閘,等 leo 裁「那段 Go 住哪裡」)。 ## 真實現況比記載更糟 t16 寫的「PDF 被靜默略過」已不成立(t73 的轉檔層把 pdf/docx/xlsx/csv/pptx 都接上了,實測 2 頁中文 PDF 完整抽出)。**真正還在沉默的是別的東西**: 副檔名不在 allowedExt 的檔案在 scan.go 直接 `return nil`—— 不進事件、不進 manifest、不進 status、不進畫面。 使用者丟一份 .doc 進去,從頭到尾一個字都沒有。 實測基線(真檔):丟 .pdf/.md/.doc/.key/.jpg 進資料夾, `collector scan` 只吐出 pdf 與 md 兩個事件,另外三個檔沒留下任何痕跡。 ## 改了什麼 - scan.go:白名單閘不再是死巷。像文件的(.doc/.xls/.ppt/.pages/.key/ .numbers/.odt/.ods/.odp/.rtf/.epub/.wpd/.msg/.eml)逐檔留名; 其餘(圖片/影音/程式碼)只計總數——**避免 Obsidian 附件庫炸出幾百行噪音**。 - 兩個新欄位標 `json:"-"`:collector-trigger schema 是 additionalProperties:false,且 BuildSendablePayload 是淺拷貝 ⇒ 有 tag 就會漏到雲端被擋。這是給本機使用者看的,不上 wire。 - direct.go → status.json → App 首頁一張卡:講檔名與格式(「舊版報告.doc (舊版 Word)」),並告訴他不用重丟、也給替代路(另存成 PDF/.docx)。 - 刻意不進 manifest、不走 CarryForwardActivity:每輪由檔案系統重算, 不製造 t195 那種「跨輪欄位漏 carry 就靜默歸零」的債。 ## 順手修掉一個會讓驗收失效的回歸 同綑的 arcrun-collector 一路自稱 `dev`:退役 fyne 版 (arcrun-tray/build-mac.sh:24,t150/t72)本來就有版本注入, t194 換 Wails 時沒帶過來,Mac 與 Windows 兩邊都掉。 **幹活的是 collector**——它不報版本,就沒人能判斷修復有沒有到使用者手上。 ## 實測 - go test ./... 135 過 0 敗(新增 10 條:scan 5+首頁文案 5) - check-cis / check-render / check-tray 三閘全過;淺色深色都抓過畫面 - 從 **DMG 裡那支** collector 實跑:Info.plist 0.18.6、 collector 回報 v0.18.6 (build 20260806-0101)、 status.json 吐出 skipped_docs=[簡報.key, 舊版報告.doc]、other=1 ⚠️ 未出貨:推 bundles repo 在 GitHub,要 leo 開 D20 閘。 DMG 已備妥 dist/Arcrun-v0.18.6.dmg。 Co-Authored-By: Claude Opus 5 --- cmd/arcrun-app/app.go | 114 +++++++++++++++++++-- cmd/arcrun-app/build-mac.sh | 15 ++- cmd/arcrun-app/build-win.sh | 5 +- cmd/arcrun-app/frontend/src/main.js | 22 ++++ cmd/arcrun-app/frontend/src/style.css | 10 ++ cmd/arcrun-app/skipped_test.go | 91 +++++++++++++++++ direct.go | 25 +++++ scan.go | 61 ++++++++++- scan_skipped_test.go | 141 ++++++++++++++++++++++++++ sync_status.go | 18 ++++ 10 files changed, 486 insertions(+), 16 deletions(-) create mode 100644 cmd/arcrun-app/skipped_test.go create mode 100644 scan_skipped_test.go diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index cd6dec4..78f5894 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -83,6 +83,46 @@ type syncStatus struct { LastActivityAt string `json:"last_activity_at,omitempty"` LastActivityOK int `json:"last_activity_ok"` LastActivityFailed int `json:"last_activity_failed"` + // G-6.2(2026-08-06):collector 讀不了、因此整個跳過的檔案。 + // 以前這些檔在 scan 的白名單閘就無聲蒸發,使用者只看到「什麼都沒發生」。 + SkippedDocs []skippedDoc `json:"skipped_docs,omitempty"` + SkippedDocCount int `json:"skipped_doc_count"` + SkippedOtherCount int `json:"skipped_other_count"` +} + +type skippedDoc struct { + Path string `json:"path"` + Ext string `json:"ext"` +} + +// extLabel 把副檔名翻成使用者認得的東西。 +// 使用者不會因為看到「.pages」就懂,但看到「Pages」會——那是他自己按存檔時的名字。 +func extLabel(ext string) string { + switch strings.ToLower(ext) { + case ".doc": + return "舊版 Word" + case ".xls": + return "舊版 Excel" + case ".ppt": + return "舊版 PowerPoint" + case ".pages": + return "Pages" + case ".numbers": + return "Numbers" + case ".key": + return "Keynote" + case ".odt", ".ods", ".odp": + return "OpenDocument" + case ".rtf": + return "RTF" + case ".epub": + return "EPUB" + case ".msg", ".eml": + return "郵件檔" + case ".wpd": + return "WordPerfect" + } + return strings.TrimPrefix(ext, ".") } // loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位** @@ -135,20 +175,70 @@ type UIAccount struct { Folders []UIFolder `json:"folders"` } type UIState struct { - Version string `json:"version"` - StatusBig string `json:"statusBig"` - StatusSub string `json:"statusSub"` - Syncing bool `json:"syncing"` - Accounts []UIAccount `json:"accounts"` - Engine string `json:"engine"` // "workers-ai" | "gemma" - GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值 - ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」 - Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6) + Version string `json:"version"` + StatusBig string `json:"statusBig"` + StatusSub string `json:"statusSub"` + Syncing bool `json:"syncing"` + Accounts []UIAccount `json:"accounts"` + Engine string `json:"engine"` // "workers-ai" | "gemma" + GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值 + ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」 + Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6) + Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫) +} + +// UISkipped=首頁那張「這些檔案現在還處理不了」的卡。 +// +// 🔴 存在理由(J-1/S6 考題 G-6.2): +// +// 「Given 我丟進去的是 PDF 或 Word/When 我搜它的內容/ +// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」 +// +// 後半句在這裡兌現。文字一律白話:講「你的哪個檔沒進去」「你要不要做什麼」, +// 不講 allowedExt、副檔名白名單、extractor 這些系統內部詞。 +type UISkipped struct { + Title string `json:"title"` // 「有 3 個檔案現在還讀不了」 + Note string `json:"note"` // 該不該做什麼——這裡的答案是「不用,之後會自動補上」 + Files []string `json:"files"` // 「舊版報告.doc(舊版 Word)」 + More int `json:"more"` // 沒列出來的還有幾個 + Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串) +} + +// buildSkipped 把 status.json 的三個欄位翻成首頁看得懂的一張卡。 +// 完全沒有東西被略過時回 nil ⇒ 前端不畫這張卡(沒事就別佔畫面)。 +func buildSkipped(s syncStatus) *UISkipped { + if s.SkippedDocCount == 0 && s.SkippedOtherCount == 0 { + return nil + } + u := &UISkipped{} + for _, d := range s.SkippedDocs { + u.Files = append(u.Files, fmt.Sprintf("%s(%s)", filepath.Base(d.Path), extLabel(d.Ext))) + } + if n := s.SkippedDocCount - len(s.SkippedDocs); n > 0 { + u.More = n + } + if s.SkippedDocCount > 0 { + u.Title = fmt.Sprintf("有 %d 個檔案現在還讀不了", s.SkippedDocCount) + // 誠實地告訴他「這不是你的錯,也不用你動手」——否則使用者會反覆重丟同一個檔。 + u.Note = "這些格式我們還沒支援,所以沒有進你的知識庫。等支援了會自動補上,你不用重丟。" + + "急著要的話,先用原本的軟體另存成 PDF 或 Word(.docx)放進同一個資料夾就行。" + } else { + // 只有非文件檔的情況(例如整個資料夾都是照片)——不需要驚動他,但也不能不說。 + u.Title = "有些檔案沒有被整理" + u.Note = "看起來不是文件,所以跳過了。" + } + if s.SkippedOtherCount > 0 { + u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。", + s.SkippedOtherCount) + } + return u } // Step 是首頁狀態時間軸的一格。 // 🔴 leo 08-04:「首頁要顯示的應該是 status,如果**看守、發現變化、萃取、上傳… -// 不同 status 在哪裡顯示**?」 +// +// 不同 status 在哪裡顯示**?」 +// // ⇒ 把一輪同步拆成四步,讓使用者看得到「現在走到哪」,而不是只有一句「看守中」。 type Step struct { Title string `json:"title"` @@ -226,6 +316,7 @@ func (a *App) GetState() UIState { st.ExtractedOK = sync.ExtractedOK st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync) st.Steps = buildSteps(sync, st.Syncing) + st.Skipped = buildSkipped(sync) return st } @@ -427,7 +518,8 @@ func (a *App) ShowWindow() { // Quit 真的結束程式(=停止看守)。只有托盤右鍵那一項會呼叫。 // // 🔴 leo 實測④:「**用強制結束把它關掉才能測試**」——代表沒有一條正常的結束路徑。 -// 這裡先停掉 collector 子行程再關 App,否則子行程會變孤兒繼續跑。 +// +// 這裡先停掉 collector 子行程再關 App,否則子行程會變孤兒繼續跑。 func (a *App) Quit() { stopSupervisor() runtime.Quit(a.ctx) diff --git a/cmd/arcrun-app/build-mac.sh b/cmd/arcrun-app/build-mac.sh index 5e3bfc6..1a286ee 100755 --- a/cmd/arcrun-app/build-mac.sh +++ b/cmd/arcrun-app/build-mac.sh @@ -7,11 +7,20 @@ set -euo pipefail cd "$(dirname "$0")" VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" +BUILD_TIME="$(date '+%Y%m%d-%H%M')" +LDFLAGS_VER="-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" export PATH="$PATH:$(go env GOPATH)/bin" -echo "🏷 版本:${VERSION}" +echo "🏷 版本:${VERSION}(build ${BUILD_TIME})" echo "① 編 collector(同綑執行檔,純 stdlib、CGO_ENABLED=0)" -( cd ../.. && CGO_ENABLED=0 go build -ldflags "-s -w" -o "cmd/arcrun-app/arcrun-collector" . ) +# 🔴 2026-08-06 補回版本注入(**Wails 換代時掉的**): +# 退役的 fyne 版 `arcrun-tray/build-mac.sh:24` 本來就有這個(t150/t72, +# leo 07-29「我的和下載下來的會是同一個嗎?」),t194 重寫成 Wails 版時沒帶過來 +# ⇒ 同綑的 collector 一路自稱 `dev`,App 門面卻顯示 v0.18.x。 +# **幹活的是 collector**:它不報版本,就沒人能判斷某個修復有沒有真的到使用者手上—— +# 正是「版本號是 leo 唯一驗收介面」要擋的那個病。 +# 實撞:v0.18.6 打包後 `arcrun-collector --version` 回 `dev`。 +( cd ../.. && CGO_ENABLED=0 go build -ldflags "-s -w ${LDFLAGS_VER}" -o "cmd/arcrun-app/arcrun-collector" . ) # 🔴 2026-08-05(leo 開 DMG 檢查時抓到):Info.plist 的 CFBundleShortVersionString # 是 **1.0.0**(Wails 預設),不是我們的版本號——因為 wails.json 沒有 info.productVersion, @@ -32,7 +41,7 @@ PY trap 'mv -f wails.json.bak wails.json 2>/dev/null || true' EXIT echo "② wails build" -wails build -clean -ldflags "-X main.version=${VERSION}" +wails build -clean -ldflags "${LDFLAGS_VER}" APP="build/bin/Arcrun.app" [ -d "build/bin/arcrun-app.app" ] && { rm -rf "$APP"; mv "build/bin/arcrun-app.app" "$APP"; } diff --git a/cmd/arcrun-app/build-win.sh b/cmd/arcrun-app/build-win.sh index 68c2c89..e8222f0 100755 --- a/cmd/arcrun-app/build-win.sh +++ b/cmd/arcrun-app/build-win.sh @@ -31,8 +31,11 @@ command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { } echo "① 編 collector.exe(同綑執行檔,純 stdlib、CGO_ENABLED=0)" +# 🔴 2026-08-06:同 build-mac.sh 補回版本注入(Wails 換代時掉的,理由見該檔註解)。 +# 2b83c47 當年就修過一次「Mac 有、Windows 漏了」——換代後兩邊又一起掉,這次一起補。 ( cd ../.. && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ - go build -ldflags "-s -w" -o "cmd/arcrun-app/arcrun-collector.exe" . ) + go build -ldflags "-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \ + -o "cmd/arcrun-app/arcrun-collector.exe" . ) # 🔴 同 build-mac.sh:wails.json 沒 info.productVersion ⇒ exe 內容資訊永遠是 1.0.0。 # Windows 使用者在「內容→詳細資料」看到的版本讀這個欄位;MSIX 送審也會對照。 diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 219cd2a..05f12ce 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -72,6 +72,7 @@ function pageHome(s) { `).join('')} + ${cardSkipped(s.skipped)}

總計

@@ -82,6 +83,27 @@ function pageHome(s) {
`; } +// 🔴 G-6.2「不准安靜地略過」(2026-08-06)——J-1/S6 考題的後半句: +// 「Then 我一樣找得到——**或當場被告知這種檔案還不支援**」 +// 以前 .doc/.pages 這類檔在 collector 掃描時就被丟掉,畫面上一個字都沒有, +// 使用者只能得到「我丟了檔,然後什麼都沒發生」這個結論。 +// 這張卡就是那句話該出現的地方——**首頁**,他每次打開 App 一定會看到。 +// 沒有東西被略過時後端回 null ⇒ 這裡回空字串,畫面保持乾淨(沒事不佔版面)。 +function cardSkipped(k) { + if (!k) return ''; + return ` +
+

${esc(k.title)}

+
${esc(k.note)}
+ ${(k.files || []).length ? ` +
    + ${k.files.map((f) => `
  • ${esc(f)}
  • `).join('')} + ${k.more ? `
  • …等 ${k.more} 個
  • ` : ''} +
` : ''} + ${k.other ? `
${esc(k.other)}
` : ''} +
`; +} + // ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)── function pageLib(s, idx) { const a = s.accounts[idx]; diff --git a/cmd/arcrun-app/frontend/src/style.css b/cmd/arcrun-app/frontend/src/style.css index 15577f4..bbf3fdb 100644 --- a/cmd/arcrun-app/frontend/src/style.css +++ b/cmd/arcrun-app/frontend/src/style.css @@ -119,6 +119,16 @@ button.ghost:hover { color: var(--err); background: transparent; } .card .row { display: flex; align-items: center; gap: 12px; } .card .row .g { flex: 1; min-width: 0; } +/* 「這些檔案還讀不了」清單(G-6.2)。刻意**不用紅色/錯誤色**: + 這不是使用者做錯了什麼,是我們還沒支援——語氣要是告知,不是指責。 */ +.skiplist { margin: 10px 0 0; padding: 0; list-style: none; } +.skiplist li { + font-size: 13.5px; padding: 6px 0; + border-top: 1px solid rgba(var(--ink-rgb), .08); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.skiplist li.more { color: rgba(var(--ink-rgb), .45); } + .acct { padding: 4px 2px 8px; display: flex; align-items: baseline; gap: 10px; } .acct .name { font-size: 14.5px; font-weight: 600; } .acct .host { font-size: 12px; color: rgba(var(--ink-rgb), .4); font-family: ui-monospace, Menlo, monospace; } diff --git a/cmd/arcrun-app/skipped_test.go b/cmd/arcrun-app/skipped_test.go new file mode 100644 index 0000000..424fb3c --- /dev/null +++ b/cmd/arcrun-app/skipped_test.go @@ -0,0 +1,91 @@ +package main + +// skipped_test.go — 首頁「這些檔案還讀不了」那張卡(G-6.2,2026-08-06)。 +// +// 這一層驗的不是資料對不對(那在 collector/scan_skipped_test.go), +// 而是**話有沒有講成人話**:使用者看到的是檔名與該不該動手, +// 不是 allowedExt、extractor、副檔名白名單這些系統內部詞。 + +import ( + "strings" + "testing" +) + +func TestBuildSkippedNilWhenNothingSkipped(t *testing.T) { + if got := buildSkipped(syncStatus{}); got != nil { + t.Fatalf("沒東西被略過時不該生出卡片(會白佔首頁版面):%+v", got) + } +} + +func TestBuildSkippedNamesTheFilesInPlainWords(t *testing.T) { + u := buildSkipped(syncStatus{ + SkippedDocs: []skippedDoc{ + {Path: "提案/舊版報告.doc", Ext: ".doc"}, + {Path: "簡報.key", Ext: ".key"}, + }, + SkippedDocCount: 2, + }) + if u == nil { + t.Fatal("有讀不了的檔卻沒生出卡片=又回到安靜略過") + } + if !strings.Contains(u.Title, "2") { + t.Errorf("標題要講幾個檔,實得 %q", u.Title) + } + // 使用者看的是檔名,不是我們的相對路徑。 + if u.Files[0] != "舊版報告.doc(舊版 Word)" { + t.Errorf("檔名該配上他認得的格式名,實得 %q", u.Files[0]) + } + if u.Files[1] != "簡報.key(Keynote)" { + t.Errorf("實得 %q", u.Files[1]) + } + // 要回答「我現在該做什麼」——答案是不用做什麼,而且要說出替代路。 + for _, want := range []string{"不用重丟", "PDF"} { + if !strings.Contains(u.Note, want) { + t.Errorf("說明要包含 %q,實得 %q", want, u.Note) + } + } + // 不准把系統內部詞漏給使用者。 + for _, leak := range []string{"allowedExt", "extractor", "ErrUnsupported", "副檔名"} { + if strings.Contains(u.Title+u.Note, leak) { + t.Errorf("不該對使用者講 %q", leak) + } + } +} + +// 清單有上限,但**總數不能被截掉**——否則使用者以為只有 20 個檔沒進去。 +func TestBuildSkippedShowsRemainderCount(t *testing.T) { + docs := make([]skippedDoc, 20) + for i := range docs { + docs[i] = skippedDoc{Path: "檔.doc", Ext: ".doc"} + } + u := buildSkipped(syncStatus{SkippedDocs: docs, SkippedDocCount: 57}) + if u.More != 37 { + t.Errorf("沒列出來的還有 37 個,實得 %d", u.More) + } + if !strings.Contains(u.Title, "57") { + t.Errorf("標題該講真實總數 57,實得 %q", u.Title) + } +} + +// 整個資料夾都是照片這種情況:不必驚動他,但也不能一個字都不說。 +func TestBuildSkippedOtherOnly(t *testing.T) { + u := buildSkipped(syncStatus{SkippedOtherCount: 312}) + if u == nil { + t.Fatal("只有非文件檔時仍要說一句,不能沉默") + } + if len(u.Files) != 0 { + t.Error("非文件檔不逐檔點名(幾百張圖會變成噪音)") + } + if !strings.Contains(u.Other, "312") { + t.Errorf("要講出數量,實得 %q", u.Other) + } +} + +func TestExtLabelFallsBackToRawExt(t *testing.T) { + if got := extLabel(".xyz"); got != "xyz" { + t.Errorf("沒對照到的格式原樣顯示,實得 %q", got) + } + if got := extLabel(".DOC"); got != "舊版 Word" { + t.Errorf("大寫副檔名也要認得(Windows 常見),實得 %q", got) + } +} diff --git a/direct.go b/direct.go index a05806a..756bb13 100644 --- a/direct.go +++ b/direct.go @@ -30,6 +30,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "time" ) @@ -547,6 +548,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge }} } + // G-6.2:跨帳號、跨監看根累積「被略過的檔案」。 + // 用 map 去重——同一個資料夾可能被兩個帳號同時看守(t104 多帳號), + // 使用者不該因為我們的內部結構而看到同一個檔名列兩次。 + skippedSeen := map[string]SkippedFile{} + skippedOther := 0 + accountDetails := map[string]AccountSyncStatus{} for _, acc := range accounts { if acc.CypherURL == "" || acc.Namespace == "" { @@ -604,6 +611,13 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge } if p != nil { lastPayload = p + // G-6.2:這一根掃出來的「讀不了的檔」收進總表。 + // 要在**每一根**都收,不能像 lastPayload 那樣只留最後一根—— + // 多資料夾時前面幾根的檔案會整批消失(t149 那類「只有最後一個生效」的病)。 + for _, sf := range p.Skipped { + skippedSeen[sf.Path] = sf + } + skippedOther += p.SkippedOther } } accountDetails[accHost] = accSt @@ -617,6 +631,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge ExtractorError: extractorError, AccountDetails: accountDetails, } + // G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。 + // 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。 + st.SkippedOtherCount = skippedOther + st.SkippedDocCount = len(skippedSeen) + for _, sf := range skippedSeen { + st.SkippedDocs = append(st.SkippedDocs, sf) + } + sort.Slice(st.SkippedDocs, func(i, j int) bool { return st.SkippedDocs[i].Path < st.SkippedDocs[j].Path }) + if len(st.SkippedDocs) > MaxSkippedListed { + st.SkippedDocs = st.SkippedDocs[:MaxSkippedListed] // 總數仍在 SkippedDocCount,UI 說「等 N 個」 + } // 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀) if cfg.Extractor != "" { for _, r := range results { diff --git a/scan.go b/scan.go index aa2431a..261875e 100644 --- a/scan.go +++ b/scan.go @@ -33,6 +33,42 @@ var allowedExt = map[string]bool{ ".xlsx": true, } +// docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。 +// +// 🔴 為什麼要有這張表(J-1/S6 考題 G-6.2,2026-08-06): +// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、 +// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**, +// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**; +// 「安靜地略過」不是可接受的第三種結果。 +// +// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png) +// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。 +// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默, +// 但只有前者值得佔用他的注意力。 +// +// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」), +// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。 +var docLikeExt = map[string]bool{ + // 舊版 Office(OLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式) + ".doc": true, ".xls": true, ".ppt": true, + // Apple iWork + ".pages": true, ".numbers": true, ".key": true, + // OpenDocument(LibreOffice) + ".odt": true, ".ods": true, ".odp": true, + // 其他常見文件容器 + ".rtf": true, ".epub": true, ".wpd": true, ".msg": true, ".eml": true, +} + +// SkippedFile=這一輪被略過、且值得對使用者逐檔點名的檔案。 +// +// ⚠️ 刻意**不寫進 manifest**:它每輪由檔案系統重算,永遠反映現況。 +// (對照 t195 的坑:凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行, +// 漏了就靜默歸零。這裡不建立那份債。) +type SkippedFile struct { + Path string `json:"path"` + Ext string `json:"ext"` +} + // ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json)---- type Event struct { @@ -59,6 +95,14 @@ type TriggerPayload struct { GeneratedAt int64 `json:"generated_at,omitempty"` Events []Event `json:"events"` Warnings []Warning `json:"warnings,omitempty"` + + // 🔴 兩個 `json:"-"`(G-6.2,2026-08-06):被略過的檔案是**給本機使用者看的**, + // 不是給雲端 ingest 的料。collector-trigger.v1.schema.json 頂層寫死 + // `additionalProperties: false`,多帶一個欄位上線就會被 schema 擋掉 + //(BuildSendablePayload 是 `sendable := *p` 淺拷貝,有 tag 就會一起送出去)。 + // ⇒ 留在記憶體裡,由 direct.go 收進 status.json,給 App 首頁用。 + Skipped []SkippedFile `json:"-"` // 像文件、但還讀不了的(逐檔點名) + SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)只計數 } type ScanOptions struct { @@ -108,6 +152,8 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。 current := map[string]fileState{} + var skipped []SkippedFile + skippedOther := 0 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { if werr != nil { return werr @@ -128,7 +174,17 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] { return nil } - if !allowedExt[strings.ToLower(filepath.Ext(name))] { + ext := strings.ToLower(filepath.Ext(name)) + if !allowedExt[ext] { + // G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。 + // 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。 + if docLikeExt[ext] { + if rel, rerr := filepath.Rel(root, p); rerr == nil { + skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(rel), Ext: ext}) + } + } else { + skippedOther++ + } return nil } info, ierr := d.Info() @@ -299,6 +355,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if events == nil { events = []Event{} } + sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path }) return &TriggerPayload{ SchemaVersion: 1, FolderID: m.FolderID, @@ -306,5 +363,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { GeneratedAt: time.Now().Unix(), Events: events, Warnings: warnings, + Skipped: skipped, + SkippedOther: skippedOther, }, nil } diff --git a/scan_skipped_test.go b/scan_skipped_test.go new file mode 100644 index 0000000..b89afc2 --- /dev/null +++ b/scan_skipped_test.go @@ -0,0 +1,141 @@ +package main + +// scan_skipped_test.go — G-6.2「不准安靜地略過」(J-1/S6,2026-08-06)。 +// +// 考題:「Given 我丟進去的是 PDF 或 Word / When 我搜它的內容 / +// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」 +// +// 改動前的實測基線(真檔跑過):把 .doc/.key/.jpg 丟進資料夾,`collector scan` +// 吐出的 events 只有 .pdf 與 .md,另外三個檔**沒有在任何輸出裡留下一個字**。 +// 本檔把「那三個檔要留下名字」釘住。 + +import ( + "encoding/json" + "strings" + "testing" +) + +// 讀不了的文件要逐檔點名;純粹不是文件的只計數;**能處理的檔完全不受影響**(回歸)。 +func TestScanReportsSkippedFiles(t *testing.T) { + root := t.TempDir() + // 支援的(必須照常產生事件) + writeFile(t, root, "筆記.md", "# 內容", baseTime) + writeFile(t, root, "報表.csv", "a,b\n1,2", baseTime) + // 像文件、但還讀不了的(必須逐檔點名) + writeFile(t, root, "舊版報告.doc", "\xd0\xcf\x11\xe0binary", baseTime) + writeFile(t, root, "提案/簡報.key", "binary", baseTime) + // 不是文件的(只計數,不點名——避免附件庫炸出幾百行噪音) + writeFile(t, root, "照片.jpg", "\xff\xd8\xff", baseTime) + writeFile(t, root, "封存.zip", "PK", baseTime) + + m := newTestManifest() + p := mustScan(t, root, m) + + // ① 回歸:支援的格式照舊,一個不多一個不少。 + added := eventsOfType(p, "added") + if len(added) != 2 { + t.Fatalf("支援的檔應產生 2 個 added 事件,實得 %d:%+v", len(added), added) + } + for _, ev := range added { + if strings.HasSuffix(ev.Path, ".doc") || strings.HasSuffix(ev.Path, ".key") || + strings.HasSuffix(ev.Path, ".jpg") || strings.HasSuffix(ev.Path, ".zip") { + t.Fatalf("讀不了的檔不該被送進管線(會被當二進位餵給模型):%s", ev.Path) + } + } + + // ② 讀不了的文件必須留名——這就是「不准安靜地略過」。 + if len(p.Skipped) != 2 { + t.Fatalf("應點名 2 個讀不了的文件,實得 %d:%+v", len(p.Skipped), p.Skipped) + } + got := map[string]string{} + for _, s := range p.Skipped { + got[s.Path] = s.Ext + } + if got["舊版報告.doc"] != ".doc" { + t.Errorf("舊版報告.doc 沒被點名:%+v", got) + } + if got["提案/簡報.key"] != ".key" { + t.Errorf("子目錄裡的 .key 沒被點名(相對路徑要保留):%+v", got) + } + + // ③ 非文件檔只給總數。 + if p.SkippedOther != 2 { + t.Errorf("非文件檔應計數 2(jpg+zip),實得 %d", p.SkippedOther) + } +} + +// manifest 不該因為「有讀不了的檔」而被污染——它們沒進管線,就不該有帳本條目。 +func TestSkippedFilesStayOutOfManifest(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "好的.md", "x", baseTime) + writeFile(t, root, "壞的.doc", "x", baseTime) + + m := newTestManifest() + mustScan(t, root, m) + + if _, ok := m.Entries["壞的.doc"]; ok { + t.Error("讀不了的檔不該進 manifest(會被誤當成已收錄)") + } + if _, ok := m.Entries["好的.md"]; !ok { + t.Error("支援的檔應照常進 manifest") + } +} + +// 🔴 schema 保護:collector-trigger.v1.schema.json 頂層是 additionalProperties:false, +// 多帶一個欄位上線就會被雲端擋掉。Skipped 是**給本機使用者看的**,不准漏到 wire 上。 +// (BuildSendablePayload 是 `sendable := *p` 淺拷貝 ⇒ 有 json tag 就會一起送出去。) +func TestSkippedNeverGoesOnTheWire(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "壞的.doc", "x", baseTime) + writeFile(t, root, "照片.jpg", "x", baseTime) + + p := mustScan(t, root, newTestManifest()) + if len(p.Skipped) == 0 { + t.Fatal("前提不成立:這輪應該要有被略過的檔") + } + + blob, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"skipped", "壞的.doc"} { + if strings.Contains(string(blob), forbidden) { + t.Errorf("送雲端的 payload 不該出現 %q:%s", forbidden, blob) + } + } +} + +// 隱藏檔/被排除的目錄不該被點名——那些是系統與產品自己的東西, +// 使用者從來沒把它們當成「我丟進去的檔案」。報了只會變噪音。 +func TestSkippedIgnoresHiddenAndExcluded(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ".DS_Store", "x", baseTime) + writeFile(t, root, ".hidden.doc", "x", baseTime) + writeFile(t, root, ".obsidian/workspace.key", "x", baseTime) + writeFile(t, root, "system-dev/舊稿.doc", "x", baseTime) + writeFile(t, root, "真的.doc", "x", baseTime) + + m := newTestManifest() + p, err := Scan(root, m, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}}) + if err != nil { + t.Fatal(err) + } + if len(p.Skipped) != 1 || p.Skipped[0].Path != "真的.doc" { + t.Fatalf("只有使用者自己的檔該被點名,實得 %+v", p.Skipped) + } + if p.SkippedOther != 0 { + t.Errorf("隱藏檔不該進非文件計數,實得 %d", p.SkippedOther) + } +} + +// 沒有任何讀不了的檔時,兩個欄位都該是零值 ⇒ UI 才不會憑空冒出一張卡。 +func TestNoSkippedWhenEverythingSupported(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "x", baseTime) + writeFile(t, root, "b.txt", "y", baseTime) + + p := mustScan(t, root, newTestManifest()) + if len(p.Skipped) != 0 || p.SkippedOther != 0 { + t.Errorf("全部都讀得了時不該有略過紀錄:%+v / %d", p.Skipped, p.SkippedOther) + } +} diff --git a/sync_status.go b/sync_status.go index 6b1771c..2efa0cd 100644 --- a/sync_status.go +++ b/sync_status.go @@ -45,8 +45,26 @@ type SyncStatus struct { CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true // t104:per-account 狀態(key = instanceHostOf(cypher_url)) AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"` + + // 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案, + // 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。 + // 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。 + // ⇒ 每輪把它們帶出來,讓 App 首頁講一句人話。 + // + // ⚠️ 與 ExtractedOK 不同,**這三個欄位不進 CarryForwardActivity**: + // 它們是每輪重走檔案系統算出來的「現況快照」,不是「本輪做了幾件事」的計數 + //(後者才會在沒事做的那輪被歸零=db17f28 修的那個病)。 + // 檔案還躺在資料夾裡,每輪都會被重新數到,所以原地重算就是對的。 + SkippedDocs []SkippedFile `json:"skipped_docs,omitempty"` // 逐檔點名(已排序,上限 MaxSkippedListed) + SkippedDocCount int `json:"skipped_doc_count"` // 文件類被略過的**總數**(可能大於清單長度) + SkippedOtherCount int `json:"skipped_other_count"` // 其餘非文件檔(圖片/影音/程式碼…)只給總數 } +// MaxSkippedListed:status.json 裡最多逐檔列幾個。 +// 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔 +// 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。 +const MaxSkippedListed = 20 + // ExtractFail 記一筆萃取失敗(路徑+白話原因)。 type ExtractFail struct { Path string `json:"path"`