Files

714 lines
32 KiB
Go
Raw Permalink 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,
// 2026-08-15 InkStoneCo#44 ④:《llm-wiki-作業規範》洞 6 的掃描白名單——
// .featureGherkin 規格)、.yaml/.yml、.org、.rst 都是知識文件(真實 repo 實測
// 34 個非 md 檔裡 9 個是 .feature)。它們是純文字,走 passthrough(見 IsPlainText)。
".feature": true,
".yaml": true,
".yml": true,
".org": true,
".rst": 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:"-"`
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
DuplicateFormats []FormatDuplicate `json:"-"`
// PlanExcludedByPlan=這一輪用了什麼收檔策略、據此擋掉幾個檔(arcrun-rag#104)。
// 同上,`json:"-"`:給本機使用者看的,不送雲端。
// 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們,
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
Plan IngestPlan `json:"-"`
ExcludedByPlan int `json:"-"`
// ExcludedDirsExcludedDirCount=整棵被剪掉的目錄與理由(2026-08-16 補)。
// 🔴 ExcludedByPlan 只數得到「走進去了才被逐檔擋下」的檔;整棵剪掉的子樹
// 一個都數不到 ⇒ 拿 leo 真實的 pms 跑一輪,2,127 個檔裡絕大多數被排除,
// 而畫面上的數字是 **0**。講一個 0 跟安靜地少收,對使用者是同一件事。
ExcludedDirs []ExcludedDir `json:"-"` // 已排序,上限 MaxExcludedDirsListed
ExcludedDirCount int `json:"-"` // 總數(可能大於清單長度)
// ── InkStoneCo#44 線 Aportal 的資料夾樹(leo 2026-08-17)─────────────────
// DirStats=走訪時**逐目錄**數出來的分母(key=相對監看根的路徑,`""`=根)。
// AllExcludedDirs=整棵被剪掉的目錄,**未裁切**(上面那個 ExcludedDirs 為了畫面
// 只留 20 筆;樹要畫得完整需要全量,不然「哪些沒收」會缺角)。
//
// 🔴 為什麼分母一定要在這裡數、不能另外走一趟:判斷「支不支援/收不收」的那一整套
// 判準就活在這個 WalkDir 裡(allowedExtdocLikeExtPlan.KeepsFileTemplateOwns)。
// 另外走一趟=同一件事第二份實作,必然漂移 ⇒ 畫面上的數字與實際收的檔對不起來,
// 而 leo 要的正是「兩個數字的差額解釋得了」。
// 同 `json:"-"`:給本機與 portal 用的,不進 collector-trigger schema。
DirStats map[string]*dirStat `json:"-"`
AllExcludedDirs []ExcludedDir `json:"-"`
}
// dirStat=一個目錄「這一層直接放的檔案」的分類計數(不含子目錄;子樹合計由畫面自己疊,
// 存兩套就是同一件事兩份實作,遲早對不起來)。
//
// 🔴 不變式:`total == supported + unsupported + excluded`。
// 少一類就會出現「兩個數字的差額解釋不了」,而那正是 leo 這條規格要解掉的病。
type dirStat struct {
total int // 這一層看得到的檔案總數(分母)
supported int // 通過所有閘、進了 manifest 的(=分子的候選)
unsupported int // 副檔名還讀不了的(docLikeExt 與其他)
excluded int // 收檔策略/範本身分決定不收的(程式碼多半落這裡)
// excludeWhy=這一層的檔被排除的理由(產品文案,第一個排除的檔說了算)。
// #180:以前不收就是「不走進去」,理由掛在被剪掉的目錄上;現在走進去了,
// 理由得跟著檔案的排除一起記下來,不然畫面只剩一個沒有解釋的 `0/6`。
excludeWhy string
}
// MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount
// (同 MaxSkippedListed 的道理:狀態檔不該被撐成一面看不完的清單牆)。
const MaxExcludedDirsListed = 20
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
//
// 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集
// 27,164 檔=9,045 個 md9,044 個 json9,043 個 html,是同一批來源轉出的三種格式
// `markdown/160-00F3_001.md` 與 `json/160-00F3_001.json` 逐一對應,同錯誤碼、同變體號)。
// 現行設計不知道這件事,會把三種格式各當一份新檔,萃三次、吃三倍額度——
// 使用者不會知道要挑一種,這是我們該擋的,不是他該懂的。
//
// 判準:檔名主幹(去副檔名、轉小寫)在整個看守根內相同 → 視為同一份內容的不同格式匯出,
// 只留優先序最高的一份進事件管線,其餘標記在這裡(不產生 added/modified/renamed 事件)。
//
// ⚠️ 已知的取捨:純靠檔名主幹比對,不比對內容——兩個不相干的檔恰好同名不同副檔名
// (如兩個專案各自的 `README.md``README.pdf`)會被誤判成同一份。刻意接受這個風險,
// 因為:①這不是靜默丟棄——DuplicateFormats 會被 direct.go 收進 status.json 讓使用者看到
// 「跳過:與 X 視為同一格式」,看起來不對可以改檔名破解誤判;②不比對,就是 leo 實據的
// md/json 案例(不同目錄、不同副檔名,唯一共同點正是檔名主幹)根本擋不掉。
//
// 🔴 刻意不寫進 manifest(同 Skipped 的理由,見上方 SkippedFile 註解):每輪由檔案系統
// 重算,勝出者若之後消失,下一輪換另一份自然遞補,不留跨輪狀態要維護。
type FormatDuplicate struct {
Path string `json:"path"` // 被跳過的那份
KeptPath string `json:"kept_path"` // 真正進事件管線的那份
Stem string `json:"stem"` // 判定依據:去副檔名、轉小寫後的檔名主幹
}
// dedupFormatPriority:格式去重時「留誰」的優先序(越前面越優先留下)。
// 原則:越接近「使用者原始編輯」的格式排越前面(docx/pptx 是可編輯原稿),
// md/markdown 常是**從別的格式轉出的產物**(leo 實據的資料集正是 html→chm 轉出 md/json),
// 排在轉檔產物之前但在原生辦公格式之後。不在表內的副檔名排最後(理論上不會發生,
// current 只收 allowedExt)。
var dedupFormatPriority = []string{".docx", ".pptx", ".xlsx", ".csv", ".pdf", ".md", ".markdown", ".txt"}
func formatPriority(relPath string) int {
ext := strings.ToLower(filepath.Ext(relPath))
for i, e := range dedupFormatPriority {
if e == ext {
return i
}
}
return len(dedupFormatPriority)
}
// dedupStemOf 回傳去重判準:檔名主幹(basename 去副檔名),轉小寫(跨平台大小寫不敏感)。
// 刻意只看 basename、不看目錄——leo 實據的 md/json 恰好活在不同的兄弟目錄
// markdown/160-00F3_001.md vs json/160-00F3_001.json),只有 basename 主幹相同。
func dedupStemOf(relPath string) string {
base := filepath.Base(relPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
return strings.ToLower(stem)
}
// detectFormatDuplicates 在 current(本輪掃到、通過 allowedExt 的檔)裡找出同檔名主幹的分組,
// 每組留優先序最高的一份,其餘回報為 loserpath -> winner path)。
// 走訪用 stem 字母序=確定性輸出(map 迭代順序不穩定)。
func detectFormatDuplicates(current map[string]fileState) (map[string]string, []FormatDuplicate) {
byStem := map[string][]string{}
for p := range current {
stem := dedupStemOf(p)
byStem[stem] = append(byStem[stem], p)
}
stems := make([]string, 0, len(byStem))
for s := range byStem {
stems = append(stems, s)
}
sort.Strings(stems)
loserOf := map[string]string{}
var dups []FormatDuplicate
for _, stem := range stems {
group := byStem[stem]
if len(group) < 2 {
continue
}
sort.Slice(group, func(i, j int) bool {
pi, pj := formatPriority(group[i]), formatPriority(group[j])
if pi != pj {
return pi < pj
}
return group[i] < group[j] // 同優先序時字母序,確定性
})
winner := group[0]
for _, loser := range group[1:] {
loserOf[loser] = winner
dups = append(dups, FormatDuplicate{Path: loser, KeptPath: winner, Stem: stem})
}
}
return loserOf, dups
}
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
// Plan:收檔策略(arcrun-rag#104)。
//
// 🔴 2026-08-16 改成「不填就自己算」(Mode == "" ⇒ Scan 自己呼叫 PlanIngest)。
// 原本的註解寫著「要拿到 #104 的效果就傳 PlanIngest(root)」——也就是**排除規則
// 要不要生效,取決於呼叫端記不記得傳**。那正是這一票(以及同一天 #88、#46、
// Arcrun#125)的共同形狀:**能力做好了,而它不在會被執行的那條路上**。
// 讓走訪器自己裝上判準,「忘了接」這個失敗模式就不存在了。
// 呼叫端仍可覆寫(測試要造特定情境時照傳即可)。
Plan IngestPlan
}
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
}
// 🔴 呼叫端沒給策略就自己算——見 ScanOptions.Plan 的說明。
// 這一行就是「排除規則不可能不在執行路徑上」的保證本身。
if opts.Plan.Mode == "" {
opts.Plan = PlanIngest(root)
}
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
// arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
// 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。
excludedByPlan := 0
// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數
// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
var excludedDirs []ExcludedDir
// InkStoneCo#44 線 A:逐目錄的分母。與上面幾個計數器同一趟走訪算出來——
// 判準只有這一份,畫面上的數字才可能與實際收的檔對得起來。
dirStats := map[string]*dirStat{"": {}} // 根一定存在(空資料夾也要有節點,arcrun-rag#106
statOf := func(relSlash string) *dirStat {
dir := folderOfRel(relSlash)
st, ok := dirStats[dir]
if !ok {
st = &dirStat{}
dirStats[dir] = st
}
return st
}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
relOf := func() (string, bool) {
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return "", false
}
return filepath.ToSlash(rel), true
}
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(呼叫端自訂)整棵跳過
}
// #104:依策略整棵剪掉(使用者的 .gitignore/依賴/建置產物/
// worktree/巢狀 repo)。
// 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見,
// 而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。
//
// 🔴 inkstone/Arcrun#180leo 2026-08-28):**「不收」不准再實作成「不走進去」。**
// leo 原話:「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現,去遍歷就好了」
// 「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」。
// 實測差距:`youlinhsieh-test1` 地端 `find -type d` 有 13 個目錄,
// 雲端的樹只有 3 個節點——樹在 `system-dev` 那一節就斷了。
// ⇒ 收檔範圍那幾條(curated-wiki 只走 wikidocs-only 只走文件區/
// 範本目錄)已從 SkipsDirWhy 搬到 CollectsDirWhy**走訪照走、節點照生、
// 數字照算**,只是標明「這一層我不收」。
if p != root {
if rel, ok := relOf(); ok {
if skip, why := opts.Plan.SkipsDirWhy(rel, p); skip {
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
return filepath.SkipDir
}
// #44 線 A:走得進去的目錄一律登記,**就算它一個檔都沒有**
// ——leo 的規格是「不管那層有沒有文件,整棵樹都要採」。
if _, ok := dirStats[rel]; !ok {
dirStats[rel] = &dirStat{}
}
// #180:走進來了,但這一層的檔這次不收 ⇒ 節點照生、子節點照長,
// 只是把理由掛上去(畫面仍然講得出「為什麼這裡沒收」)。
if collect, why := opts.Plan.CollectsDirWhy(rel); !collect {
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
}
}
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
excludedByPlan++
// #44 線 A:這是「使用者看得到、但我們決定不收」的檔(程式碼多半落在這裡)。
// 它**要算進分母**——leo 的規格就是「兩個數字不相等是正常的,差額=不支援」,
// 而分母裡沒有它,差額就永遠解釋不了。
s := statOf(rel)
s.total++
s.excluded++
if s.excludeWhy == "" {
if _, why := opts.Plan.CollectsDirWhy(folderOfRel(rel)); why != "" {
s.excludeWhy = why
} else {
s.excludeWhy = "這一層的檔案不在這次的收檔範圍裡"
}
}
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
// 🔴 2026-08-06 leotemplate 的東西不是使用者的知識,一律不收。
// **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
// 我們自己鋪的東西不該佔用他的注意力。
//
// 🔴 arcrun-rag#104 例外:curated-wiki 模式下,`system-dev/wiki/` **正是要收的那一份**。
// 上面那條規則(2026-08-06)與 leo 2026-08-14 的規格直接對撞,衝突由身分化解:
// · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收
// · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的
// 判準只有 PlanIngest 一個地方,見 ingestplan.go。
//
// 🔴 inkstone/Arcrun#180**不收照舊,但不准再從畫面上消失。**
// leo 要的是「知道資料夾下有幾個檔案被萃」——以前這裡直接 `return nil`
// 那些檔連分母都沒進去,於是 `system-dev/wiki`(實際 6 個檔)在樹上是 `0/0`、
// `scripts`(實際 1 個檔)也是 `0/0`。**兩個 0 都是我們自己編的數字。**
// 現在照樣不收,但算進 total/excluded,使用者看得到「這裡有 6 個,我一個都沒收」。
//
// 🔴 inkstone/Arcrun#180 驗收條件 23:判準從**路徑前綴**換成**內容**
// templateuntouched.go)。舊的 `TemplateOwns` 把 `system-dev/` 整棵當範本,
// 分不出同一棵樹底下的兩種東西——`system-dev/wiki/status.md`(安裝器鋪的空殼)
// 與 `system-dev/wiki/cards/*.md`9 張真的卡)於是一起被丟掉。
// 現在逐檔比對:和快照逐字相同=空殼,不收;其餘照常判斷。
if rel, rerr := filepath.Rel(root, p); rerr == nil {
relSlash := filepath.ToSlash(rel)
if TemplateUntouched(relSlash, p) {
s := statOf(relSlash)
s.total++
s.excluded++
if s.excludeWhy == "" {
s.excludeWhy = "這是安裝範本時鋪下來的空白樣板,你還沒在上面寫過東西"
}
return nil
}
}
ext := strings.ToLower(filepath.Ext(name))
// #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔(隱藏檔與 SkipPaths 的
// 機器檔刻意不算進分母:**我們自己寫的東西不該佔用他的注意力**)。
if rel, ok := relOf(); ok {
s := statOf(rel)
s.total++
if allowedExt[ext] {
s.supported++
} else {
s.unsupported++
}
}
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
}
// 1.5) 同內容多格式去重(2026-08-07):先決定誰是 loser,事件管線全程跳過它們。
dupLoser, duplicateFormats := detectFormatDuplicates(current)
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
// loser 不進候選——它不該被當成新檔,也不該被當成 rename 的另一端。
var addedPaths, removedPaths []string
for p := range current {
if dupLoser[p] != "" {
continue
}
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 dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
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 {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
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
// 🔴 2026-08-08Evan 封測回報「20 份沒送進知識庫」但展開看不到真因):
// LastError 是 t195 同一批加的欄位,卻**漏在這個 carry 裡** ⇒
// 每輪掃描(預設 5 秒)就把失敗真因抹掉一次,下一輪退避訊息組不出「|原因:」
// ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。
// 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。
ne.LastError = carry.LastError
// 🔴 #1402026-08-26):雲端對帳的三個欄位同樣要 carry,而且漏了會**很貴**:
// - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批(請求風暴)
// - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章
// → 再補送…把使用者的 AI 額度燒光(票上明文禁止的「把一個 bug 換成另一個」)
// - NoCloudCard 歸零 ⇒ 沒卡可送的檔每天被重萃一次,永遠停不下來
// 這正是上面那句警告(t195LastError 5fcc139)的第三次實例,所以照著它走。
ne.CloudCheckedAt = carry.CloudCheckedAt
ne.CloudMissingAt = carry.CloudMissingAt
ne.NoCloudCard = carry.NoCloudCard
}
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 })
// 排序=畫面每輪穩定;裁切前先記總數,不然「等 N 個」會少報。
sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path })
excludedDirCount := len(excludedDirs)
// #44 線 A:樹要畫得完整,需要**未裁切**的全量(下面那個裁切是給狀態列用的)。
// 先複製再裁切——共用同一個底層陣列的話,裁切會讓樹跟著少一截。
allExcludedDirs := make([]ExcludedDir, len(excludedDirs))
copy(allExcludedDirs, excludedDirs)
if len(excludedDirs) > MaxExcludedDirsListed {
excludedDirs = excludedDirs[:MaxExcludedDirsListed]
}
return &TriggerPayload{
SchemaVersion: 1,
FolderID: m.FolderID,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
Skipped: skipped,
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
DuplicateFormats: duplicateFormats,
Plan: opts.Plan,
ExcludedByPlan: excludedByPlan,
ExcludedDirs: excludedDirs,
ExcludedDirCount: excludedDirCount,
DirStats: dirStats,
AllExcludedDirs: allExcludedDirs,
}, nil
}