G-6.2:讀不了的檔案不再安靜消失——首頁當場說出來

服務 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 01:04:25 +08:00
parent f32e82bab5
commit 9cd0adab22
10 changed files with 486 additions and 16 deletions
+103 -11
View File
@@ -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.22026-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)
+12 -3
View File
@@ -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` 本來就有這個(t150t72
# 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-05leo 開 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"; }
+4 -1
View File
@@ -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.shwails.json 沒 info.productVersion ⇒ exe 內容資訊永遠是 1.0.0。
# Windows 使用者在「內容→詳細資料」看到的版本讀這個欄位;MSIX 送審也會對照。
+22
View File
@@ -72,6 +72,7 @@ function pageHome(s) {
</div>`).join('')}
</div>
</div>
${cardSkipped(s.skipped)}
<div class="card">
<h3>總計</h3>
<div class="kv" style="margin-top:10px">
@@ -82,6 +83,27 @@ function pageHome(s) {
</div>`;
}
// 🔴 G-6.2「不准安靜地略過」(2026-08-06)——J-1/S6 考題的後半句:
// 「Then 我一樣找得到——**或當場被告知這種檔案還不支援**」
// 以前 .doc.pages 這類檔在 collector 掃描時就被丟掉,畫面上一個字都沒有,
// 使用者只能得到「我丟了檔,然後什麼都沒發生」這個結論。
// 這張卡就是那句話該出現的地方——**首頁**,他每次打開 App 一定會看到。
// 沒有東西被略過時後端回 null ⇒ 這裡回空字串,畫面保持乾淨(沒事不佔版面)。
function cardSkipped(k) {
if (!k) return '';
return `
<div class="card">
<h3>${esc(k.title)}</h3>
<div class="d" style="margin-top:6px">${esc(k.note)}</div>
${(k.files || []).length ? `
<ul class="skiplist">
${k.files.map((f) => `<li>${esc(f)}</li>`).join('')}
${k.more ? `<li class="more">…等 ${k.more} 個</li>` : ''}
</ul>` : ''}
${k.other ? `<div class="d" style="margin-top:8px">${esc(k.other)}</div>` : ''}
</div>`;
}
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
function pageLib(s, idx) {
const a = s.accounts[idx];
+10
View File
@@ -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; }
+91
View File
@@ -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] != "簡報.keyKeynote" {
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)
}
}