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