Watch at Ease看見世界,讀懂生活。

實用指南 · 實用指南

Obsidian Sync 衝突副本:刪除重複筆記前先建立位元組台帳

在靜止的本機儲存庫副本上盤點,只比對官方記載的衝突檔名,並比較 Markdown 原始位元組。唯讀工具標出原檔缺失與不支援的名稱;雜湊相同也不批准刪除。

原創檔名示意圖:將 Notes 中的模擬衝突副本與準確候選 Notes/Meeting.md 配對。
原創編輯示意圖:同目錄檔名模擬案例,並非真實儲存庫或 Obsidian 介面。 Watch at Ease

衝突副本是需要審查的第二個版本,並非可以隨手丟棄的重複檔案。刪除前先保存本機儲存庫副本,再將其原始位元組與候選原檔比較。兩篇筆記即使同名,或大部分文字相同,也可能各自保留不同修改。

本指南提供離線 Markdown 盤點,分別記錄位元組相同、位元組不同及候選原檔缺失三種結果,也列出不支援的衝突名稱和其他位元組完全相同的檔案群組。它不連接 Sync、不改寫連結、不合併筆記,也不判斷哪個版本正確。

1. 清理之前先固定證據

保存各台裝置現有本機副本時,關閉儲存庫並暫停相關裝置上的編輯,使用平時的備份方法。副本應位於正在同步的目錄之外。若裝置之間已經不同,分別保留,不要在調查期間讓一個版本覆蓋另一個。

目前官方說明記載,自 Obsidian 1.9.7 起可選擇自動合併或建立衝突檔案,而且設定按裝置分別生效。2024年論壇中 Sync 不建立衝突檔案的回答早於這項功能,不是目前規則。普通帶數字後綴的檔案仍不能證明某種同步原因。

2. 了解工具識別的準確關係

對符合官方格式的衝突名稱,工具移除衝突後綴、保留 .md,推導同目錄內的候選原檔。裝置名稱可包含空格;十二位時間戳記須為有效的日曆日期與時間,卻不能據此認定哪個版本較新或較好。

位數不同、日期無效或巢狀衝突後綴的名稱進入 unsupportedNames。工具不猜測儲存庫其他位置的替代路徑。普通同位元組檔案另外列組,不歸因於 Sync。附件、連結、遠端版本,以及單篇筆記內的重複段落皆不在盤點範圍內。

3. 保存並執行完整唯讀腳本

將下方完整程式碼保存為儲存庫之外的 obsidian_conflict_audit.py。本版為 6292 位元組,SHA-256 為 79d0b961fe288a39e93f10af21200db69b6d163eb261137b80596c113571c2a2。使用可信的 Python 安裝;測試環境為 Linux、Python 3.12.14。以 UTF-8 純文字複製並保留縮排。

```python #!/usr/bin/env python3 """Inventory an idle local vault copy; stdout only, never merge or delete.""" import argparse import hashlib import json import os import re import stat import sys import unicodedata from datetime import datetime from pathlib import Path MAX_FILES = 5000 MAX_ENTRIES = 20000 MAX_FILE = 2 * 1024 * 1024 MAX_TOTAL = 64 * 1024 * 1024 SKIP = {'.obsidian', '.git', '.trash'} MARKER = '(Conflicted copy' PATTERN = re.compile(r'^(.+) \(Conflicted copy (.+) ([0-9]{12})\)\.md$') def signature(s): return (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns) def read_record(path, root, remaining): before = path.lstat() if not stat.S_ISREG(before.st_mode): raise ValueError('Markdown input is not a regular file: ' + str(path)) if before.st_size > MAX_FILE or before.st_size > remaining: raise ValueError('file or total byte limit exceeded') flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) with os.fdopen(descriptor, 'rb') as stream: opened = os.fstat(stream.fileno()) if signature(before) != signature(opened): raise ValueError('input changed before reading') data = stream.read(min(MAX_FILE, remaining) + 1) after = os.fstat(stream.fileno()) current = path.lstat() if len(data) > MAX_FILE or len(data) > remaining: raise ValueError('file or total byte limit exceeded') if len(data) != before.st_size or not ( signature(before) == signature(after) == signature(current) ): raise ValueError('input changed while reading') return {'path': path.relative_to(root).as_posix(), 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()} def audit(root): root = Path(os.path.abspath(root)) if root.is_symlink() or not root.is_dir(): raise ValueError('vault root must be an existing directory, not a symlink') paths = [] entries_seen = 0 def walk_error(error): raise error for folder, dirs, files in os.walk(root, followlinks=False, onerror=walk_error): dirs[:] = sorted(d for d in dirs if d not in SKIP) entries_seen += len(dirs) + len(files) if entries_seen > MAX_ENTRIES: raise ValueError('scanned directory-entry limit exceeded') for name in dirs + files: child = Path(folder) / name if child.is_symlink(): raise ValueError('symlink in scanned tree: ' + str(child)) for name in sorted(files): if name.lower().endswith('.md'): paths.append(Path(folder) / name) if len(paths) > MAX_FILES: raise ValueError('Markdown file count limit exceeded') paths.sort(key=lambda p: p.relative_to(root).as_posix()) folded = {} for path in paths: relative = path.relative_to(root).as_posix() key = unicodedata.normalize('NFC', relative).casefold() if key in folded: raise ValueError('case/Unicode path collision: ' + relative) folded[key] = relative records = [] total = 0 for path in paths: item = read_record(path, root, MAX_TOTAL - total) records.append(item) total += item['bytes'] indexed = {item['path']: item for item in records} groups = [] unsupported = [] for item in records: path = Path(item['path']) if MARKER.casefold() not in path.name.casefold(): continue match = PATTERN.fullmatch(path.name) if not match: unsupported.append({'path': item['path'], 'reason': 'unsupported name'}) continue original_name, device, stamp = match.groups() try: datetime.strptime(stamp, '%Y%m%d%H%M') except ValueError: unsupported.append({'path': item['path'], 'reason': 'invalid timestamp'}) continue if MARKER.casefold() in original_name.casefold(): unsupported.append({'path': item['path'], 'reason': 'nested conflict name'}) continue original_path = (path.parent / (original_name + '.md')).as_posix() original = indexed.get(original_path) relation = ('ORIGINAL_MISSING' if original is None else 'SAME_BYTES' if original['sha256'] == item['sha256'] else 'DIFFERENT_BYTES') groups.append({'copy': item, 'candidateOriginalPath': original_path, 'candidateOriginal': original, 'deviceLabel': device, 'filenameTimestamp': stamp, 'relation': relation, 'deleteApproved': False}) hashes = {} for item in records: hashes.setdefault(item['sha256'], []).append(item['path']) identical = [{'sha256': digest, 'paths': names} for digest, names in sorted(hashes.items()) if len(names) > 1] return {'status': 'INVENTORY_ONLY', 'writesPerformed': False, 'deleteApproved': False, 'mergeApproved': False, 'scope': 'Markdown files in an idle local vault copy', 'skippedDirectoryNames': sorted(SKIP), 'fileCount': len(records), 'bytesHashed': total, 'files': records, 'conflictCandidates': groups, 'unsupportedNames': unsupported, 'identicalByteGroups': identical, 'limits': {'maxFiles': MAX_FILES, 'maxEntries': MAX_ENTRIES, 'maxFileBytes': MAX_FILE, 'maxTotalBytes': MAX_TOTAL}, 'notVerified': ['remote Sync state', 'note meaning or completeness', 'attachments and links', 'concurrent changes', 'parent-path races or hostile filesystems']} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('vault', type=Path, help='idle local vault copy') args = parser.parse_args() try: result = audit(args.vault) except (OSError, ValueError) as error: print(json.dumps({'status': 'INCONCLUSIVE', 'writesPerformed': False, 'deleteApproved': False, 'reason': str(error)})) return 2 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) return 0 if __name__ == '__main__': raise SystemExit(main()) ```

將下方指令的路徑替換為靜止儲存庫副本的絕對路徑,並將報告寫入該副本之外的新檔名。Shell 重新導向建立報告;Python 程式本身只讀取檔案並向標準輸出列印 JSON。保留錯誤及結束碼。

```sh python3 obsidian_conflict_audit.py '/path/to/idle-vault-copy' > '/path/outside-vault/audit-01.json' printf '%s\n' "$?" ```

結束碼 0 表示盤點結束,即使有差異或原檔缺失也一樣。結束碼 2 表示輸入遭拒後回傳 INCONCLUSIVE。兩者皆不授權刪除。報告路徑與裝置名稱可能涉及隱私,對外分享前應去識別化。

4. 將三種結果當作審查任務

SAME_BYTES 表示本次候選原檔與衝突副本具有相同 SHA-256,不證明它們的連結、遠端歷史或語意角色可以互換。手動清理前,先檢查反向連結和最終希望保留的檔名。

DIFFERENT_BYTES 表示保存的位元組不同。以文字差異工具比較兩份副本,記錄必須保留的修改。換行符號變化也會改變雜湊;檔案較短或時間戳記較晚,都不能決定哪個內容正確。

ORIGINAL_MISSING 表示掃描到的 Markdown 檔案中沒有準確的同目錄候選原檔。保留衝突副本。原檔移動、改名、大寫副檔名或其他命名規則皆可能造成此結果;認定內容遺失前,檢查檔案歷史與目錄背景。

逐項保留路徑、大小、雜湊、關係、人工決定及已保存副本的位置。每筆紀錄皆帶 deleteApproved:false。台帳為人工審查提供證據,刻意不包含刪除或合併指令。

5. 遇到拒絕與涵蓋缺口時停止

工具拒絕符號連結根目錄、掃描樹中的符號連結、非一般 Markdown 檔案及 NFC 正規化加大小寫折疊後衝突的路徑。它跳過名為 .obsidian、.git、.trash 的目錄,且只為 Markdown 計算雜湊。這些排除項限定範圍,不證明儲存庫其他內容正常。

工具執行上述檔案數、項目數與位元組上限,並在每次讀取前後比較檔案身分、大小和修改時間。發現變化即拒絕。掃描不是不可分割的檔案系統快照,也不是防禦惡意父路徑變化或不可信檔案系統的沙箱,不要對持續變化的線上儲存庫執行。

若上限、權限錯誤或不支援的名稱使涵蓋不足,保留拒絕結果。可使用另存的小範圍副本或經審查的專用工具,不要移除檢查或將部分清單當成完整結果。普通重複資料夾仍須目錄及應用層面的審查。

6. 依官方人工審查途徑復原內容

使用 Sync 紀錄的衝突篩選與逐檔版本歷史調查筆記。復原前先預覽內容:官方復原操作會取代目前筆記。刪除或改名檔案在 Sync 設定中有獨立歷史入口。歷史保留期隨方案和檔案類型改變,應確認目前規則,不要假設可無限期復原。

將需要的文字合併至新的審查副本,在 Obsidian 中確認重要連結和附件仍可開啟,備份選定結果後再清理原件。復原、改名或刪除皆為獨立的人工操作,本腳本一項也不執行。

7. 明確測試證明的範圍

20 項模擬案例驗證位元組相同或不同、原檔缺失且不跨目錄猜測、不支援的秒級後綴、無效日期、巢狀名稱、普通重複檔案群組與排除項,也測試根目錄/檔案/目錄符號連結拒絕、大小寫與 Unicode 衝突、單檔/檔案數/項目數/總位元組上限、lstat 與 open 之間的變化、CLI 結束碼 0/2 及來源位元組不變。

這些結果測試的是盤點工具實作,不是 Obsidian 同步引擎。未測試 Windows、macOS、遠端狀態、行動裝置、介面呈現或真實衝突復原。只有完成自己的內容審查和副本保全核對後,才能結束排查,並記錄仍未測試的檔案與裝置。

常見問題

可以刪除所有 SAME_BYTES 副本嗎? 不可以。雜湊說明掃描時位元組相同,不說明檔名的作用、反向連結或遠端歷史。

它能找出單篇筆記內的重複段落嗎? 不能。它盤點完整 Markdown 檔案;內部重複須用內容差異或版本歷史審查。

為何十四位衝突名稱不受支援? 本版只識別目前官方十二位格式。其他含衝突標記的名稱保留供人工審查,不猜測。

ORIGINAL_MISSING 能證明原件已刪除嗎? 不能。移動、改名、副檔名大小寫和排除目錄也會阻止準確比對。

結束碼 0 表示 Sync 已修復嗎? 不是。它只表示本機盤點完成,報告從不授權合併、復原或刪除。

原創示意圖:SAME_BYTES、DIFFERENT_BYTES、ORIGINAL_MISSING 及不支援名稱的審查佇列。
原創編輯示意圖:三種審查結果與不支援的名稱;位元組相同也不批准刪除筆記。 Watch at Ease