Files
arcrun-collector/scan.go
T
Leo 67effe3b7f 首頁「沒被整理的檔案」修兩病:同一件事講兩次/不說是哪一個檔
leo 08-06 封測回報(附截圖+「他放一個 md 檔無法通過」)。

## ① 同一件事講兩次,而且「另外」前面沒有東西
截圖實況:
  「看起來不是文件,所以跳過了。」   ← 底下是空的(非文件檔不逐檔點名)
  「**另外**有 1 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。」
真兇:app.go buildSkipped 的 else 分支先寫一句通用說明,
接著無條件再寫 Other 那句——後者的「另外」是為「兩種都有」寫的,
在「只有非文件檔」時就變成前面沒有東西可以「另外」。
解:該分支只講一句、自己帶數量,且不留 Other。「另外」只在兩種都有時出現。

## ② 只報總數=等於沒說(這才是 md 那題卡住的原因)
封測者放 .md 說「無法通過」,但畫面只寫「有 1 個不是文件的檔案」,
**沒說是哪一個** ⇒ 誰也判斷不出發生什麼事。
而 `.md` 明明在 allowedExt 白名單裡(scan.go:26),我在本機實測也一次就過:
    "path":"arcrun-md-test.md","status":"ingested","http_status":200
⇒ 那個「1 個」必然不是他以為的那個檔(副檔名被 Windows 藏起來、存成別的格式…),
  但沒有檔名就永遠查不出來。
解:scan 收集非文件檔的檔名(上限 5 個),一路帶到 status.json 與首頁。
「只報總數」在幾百張圖時是對的,在 1 個時是失職——少量就點名。

## 驗
· 兩支新測試釘住規則:只有非文件檔時不准出現第二句、不准出現「另外」;
  兩種都有時「另外」才成立並帶數量
· 實際文案打出來看過(見下),不是只看測試綠
    有 1 個檔案沒有被整理
    看起來不是文件(圖片、影片、壓縮檔之類),所以跳過了。這是正常的,你不用做什麼。
      · 我的筆記.md.txt
· 312 張圖的情況:列 5 個 +「…還有 307 個」,不洗版
· collector 全測試過、app 測試過、go vet 全綠
2026-08-06 16:12:01 +08:00

389 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
// 再分類其餘 added/modified/removedremoved 數 > manifest 條目 × 門檻(預設 40%)
// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 收檔白名單。**注意這只是「收不收」,能不能讀由 convert.go 的 extractors 決定**——
// 兩者要一起看(2026-07-27 t73`.pdf` 早就在這裡,但 ingest 端擋著=檔案上了 R2 卻進不了
// 知識庫,使用者看到的是「丟檔進去沒反應」)。
//
// .csv/.xlsx 於 2026-07-27 加入——leo:「要思考 Excel 和 csv 的問題,**因為企業用很多**」。
var allowedExt = map[string]bool{
".md": true,
".markdown": true,
".txt": true,
".docx": true,
".pptx": true,
".pdf": true,
".csv": true,
".xlsx": true,
}
// docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。
//
// 🔴 為什麼要有這張表(J-1/S6 考題 G-6.22026-08-06):
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**,
// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**;
// 「安靜地略過」不是可接受的第三種結果。
//
// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png
// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。
// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默,
// 但只有前者值得佔用他的注意力。
//
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
// maxOtherNames=非文件檔最多點名幾個。超過就只報總數(避免幾百張圖洗版)。
const maxOtherNames = 5
var docLikeExt = map[string]bool{
// 舊版 OfficeOLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式)
".doc": true, ".xls": true, ".ppt": true,
// Apple iWork
".pages": true, ".numbers": true, ".key": true,
// OpenDocumentLibreOffice
".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 {
Type string `json:"type"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
SourceHash string `json:"source_hash"`
Size *int64 `json:"size,omitempty"`
R2Key string `json:"r2_key,omitempty"`
}
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
RemovedCount int `json:"removed_count,omitempty"`
ManifestCount int `json:"manifest_count,omitempty"`
ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}
type TriggerPayload struct {
SchemaVersion int `json:"schema_version"`
FolderID string `json:"folder_id"`
Root string `json:"root,omitempty"`
GeneratedAt int64 `json:"generated_at,omitempty"`
Events []Event `json:"events"`
Warnings []Warning `json:"warnings,omitempty"`
// 🔴 兩個 `json:"-"`G-6.22026-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:"-"` // 其餘非文件檔(圖片/影音/程式碼…)總數
// SkippedOtherNames=上面那些檔的檔名,**最多 maxOtherNames 個**。
// 🔴 2026-08-06leo 封測):封測者放了一個 .md 進去說「無法通過」,而畫面只寫
// 「有 1 個不是文件的檔案」——**沒說是哪一個**,於是誰也判斷不出發生什麼事
// (.md 明明在白名單裡,所以那個 1 一定是別的東西:可能副檔名被 Windows 藏起來、
// 可能存成了別的格式)。只報總數在「幾百張圖」時是對的,在「1 個」時等於沒說。
// ⇒ 少量時就點名,讓使用者自己一眼看出「喔,我存錯格式了」。
SkippedOtherNames []string `json:"-"`
}
type ScanOptions struct {
// MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。
MaxRemovedRatio float64
// SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。
SkipPaths map[string]bool
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool
}
const DefaultMaxRemovedRatio = 0.4
type fileState struct {
hash string // sha256:<hex>
size int64
mtime int64
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
func r2KeyOf(sourceHash string) string {
return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}
// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at
// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio
}
orig := m.Entries
manifestCountBefore := len(orig)
// 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。
current := map[string]fileState{}
var skipped []SkippedFile
skippedOther := 0
var skippedOtherNames []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
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++
// 只留前幾個:多了就變噪音(Obsidian 附件庫可能有幾百張 .png)。
if len(skippedOtherNames) < maxOtherNames {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skippedOtherNames = append(skippedOtherNames, filepath.ToSlash(rel))
}
}
}
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
st.hash = e.ContentHash // fast-pathmtime+size 沒變,跳過重算
} else {
h, herr := hashFile(p)
if herr != nil {
return herr
}
st.hash = h
}
current[rel] = st
return nil
})
if err != nil {
return nil, err
}
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
var addedPaths, removedPaths []string
for p := range current {
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
}
for p := range orig {
if _, ok := current[p]; !ok {
removedPaths = append(removedPaths, p)
}
}
sort.Strings(addedPaths)
sort.Strings(removedPaths)
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
removedByHash := map[string][]string{}
for _, p := range removedPaths {
h := orig[p].ContentHash
removedByHash[h] = append(removedByHash[h], p)
}
renamedOldOf := map[string]string{} // newPath -> oldPath
pairedOld := map[string]bool{}
var events []Event
for _, np := range addedPaths {
h := current[np].hash
cands := removedByHash[h]
if len(cands) == 0 {
continue
}
op := cands[0]
removedByHash[h] = cands[1:]
pairedOld[op] = true
renamedOldOf[np] = op
events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
}
// 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。
sortedCurrent := make([]string, 0, len(current))
for p := range current {
sortedCurrent = append(sortedCurrent, p)
}
sort.Strings(sortedCurrent)
addedEvent := func(p string) Event {
st := current[p]
size := st.size
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
}
continue
}
if _, existed := orig[p]; !existed {
events = append(events, addedEvent(p)) // 真新檔
} else if orig[p].IngestedHash == "" {
events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
}
}
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
e, existed := orig[p]
if !existed {
continue
}
if _, isRenamed := renamedOldOf[p]; isRenamed {
continue
}
if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
st := current[p]
size := st.size
events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
}
}
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
var finalRemoved []string
for _, p := range removedPaths {
if !pairedOld[p] {
finalRemoved = append(finalRemoved, p)
}
}
var warnings []Warning
guardTripped := manifestCountBefore > 0 &&
float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
if guardTripped {
warnings = append(warnings, Warning{
Code: "mass_delete_guard",
Message: fmt.Sprintf(
"本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。",
len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
RemovedCount: len(finalRemoved),
ManifestCount: manifestCountBefore,
ThresholdRatio: opts.MaxRemovedRatio,
})
} else {
for _, p := range finalRemoved {
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
}
}
// 7) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
newEntries := make(map[string]*ManifestEntry, len(current))
for p, st := range current {
ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
var carry *ManifestEntry
if op, isRenamed := renamedOldOf[p]; isRenamed {
carry = orig[op]
} else if e, ok := orig[p]; ok {
carry = e
}
if carry != nil {
ne.IngestedHash = carry.IngestedHash
ne.IngestedAt = carry.IngestedAt
// 🔴 t195:掃描每輪都**重建** entry,原本只 carry 上面兩欄 ⇒ 其餘欄位靜默歸零。
// 實撞:失敗退避(fail_count/next_retry)寫進去了,下一輪掃描卻被抹掉
// ⇒ 退避永遠停在「第 1 次失敗」,等同沒有退避(1387 輪的病根之一)。
// ExtractedBy(t73 記的「誰萃的」)原本也一樣悄悄丟失。
// ⚠️ 之後在 ManifestEntry 新增任何「跨輪要記住」的欄位,都必須加在這裡。
ne.ExtractedBy = carry.ExtractedBy
ne.FailCount = carry.FailCount
ne.LastFailAt = carry.LastFailAt
ne.NextRetry = carry.NextRetry
}
newEntries[p] = ne
}
if guardTripped {
for _, p := range finalRemoved {
newEntries[p] = orig[p]
}
}
m.Entries = newEntries
m.Root = root
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,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
Skipped: skipped,
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
}, nil
}