Watch at EaseHistorias. Respuestas. Perspectiva.

Guías prácticas · GUÍA PRÁCTICA

Copias en conflicto de Obsidian Sync: registre los bytes antes de borrar duplicados

Revise una copia local de la bóveda sin cambios activos, relacione solo los nombres de conflicto documentados y compare los bytes exactos de Markdown. La herramienta de solo lectura señala originales ausentes y nombres no admitidos; un hash idéntico nunca autoriza borrar.

Ilustración original que relaciona una copia de conflicto ficticia en Notes con el candidato exacto Notes/Meeting.md.
Ilustración editorial original: ejemplo ficticio de nombres en el mismo directorio, no una bóveda real ni la interfaz de Obsidian. Watch at Ease

Una copia en conflicto es una segunda versión que debe revisarse, no un duplicado desechable. Antes de eliminar nada, conserve una copia local de la bóveda y compare sus bytes exactos con el original candidato. Dos notas con el mismo nombre, o con casi todo el texto igual, pueden conservar cambios distintos.

Esta guía proporciona un inventario Markdown sin conexión con tres resultados separados: bytes iguales, bytes diferentes y original candidato ausente. También enumera nombres de conflicto no admitidos y otros grupos con bytes idénticos. No se conecta a Sync, modifica enlaces, fusiona notas ni elige cuál es la versión correcta.

1. Preserve las pruebas antes de limpiar

Cierre la bóveda y pause la edición en los dispositivos implicados mientras conserva cada copia local disponible mediante su método habitual de respaldo. Manténgalas fuera de directorios con sincronización activa. Si los dispositivos ya difieren, preserve cada uno por separado en lugar de permitir que una versión sustituya a otra durante la investigación.

La ayuda oficial actual documenta dos opciones desde Obsidian 1.9.7: fusionar automáticamente o crear un archivo de conflicto. La elección es específica de cada dispositivo. Una respuesta del foro de 2024 que decía que Sync no creaba archivos de conflicto es anterior a esa opción y no describe la regla actual. Un archivo común con sufijo numérico tampoco prueba una causa concreta de sincronización.

2. Entienda la relación exacta que reconoce el escáner

Para un nombre de conflicto documentado, la herramienta deriva el original candidato en el mismo directorio quitando el sufijo de conflicto y manteniendo .md. La etiqueta del dispositivo puede contener espacios. Los doce dígitos de fecha y hora del nombre deben formar una fecha válida; no demuestran cuál es la versión más reciente o mejor.

Los nombres con otra cantidad de dígitos, fechas inválidas o sufijos de conflicto anidados aparecen en unsupportedNames. El escáner no adivina una ruta alternativa en otra parte de la bóveda. Los archivos comunes con bytes iguales forman una lista aparte, sin atribuir su creación a Sync. Los adjuntos, enlaces, versiones remotas y párrafos duplicados dentro de una nota única quedan fuera del inventario.

3. Guarde y ejecute el script completo de solo lectura

Guarde todo el código siguiente como obsidian_conflict_audit.py fuera de la bóveda. Esta versión tiene 6292 bytes y SHA-256 79d0b961fe288a39e93f10af21200db69b6d163eb261137b80596c113571c2a2. Use una instalación de Python de confianza; las pruebas utilizaron Python 3.12.14 en Linux. Copie texto plano UTF-8 conservando la sangría.

```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()) ```

Ejecute el comando siguiente con la ruta absoluta de su copia de la bóveda sin cambios activos. Escriba el informe con un nombre nuevo fuera de ella. La redirección del shell crea el informe; el programa Python solo lee archivos y envía JSON a la salida estándar. Conserve cualquier error y el código de salida.

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

El código 0 significa que el inventario terminó, incluso si hay diferencias u originales ausentes. El código 2 indica INCONCLUSIVE tras rechazar una entrada. Ninguno permite borrar. Las rutas y etiquetas de dispositivos del informe pueden ser privadas; ocúltelas antes de compartirlo.

4. Lea los tres resultados como tareas de revisión

SAME_BYTES significa que el candidato y la copia en conflicto tienen el mismo SHA-256 en esta ejecución. No demuestra que sus enlaces, historiales remotos o funciones semánticas sean intercambiables. Antes de limpiar manualmente, compruebe los enlaces entrantes y el nombre que debe conservar el resultado.

DIFFERENT_BYTES significa que los bytes guardados difieren. Compare ambas copias con una herramienta de diferencias de texto y registre qué cambios deben sobrevivir. Un cambio en finales de línea también altera el hash; un archivo más corto o una fecha posterior no decide qué contenido es correcto.

ORIGINAL_MISSING significa que no estaba el candidato exacto del mismo directorio entre los Markdown examinados. Conserve la copia en conflicto. Un original movido o renombrado, una extensión en mayúsculas u otra convención de nombres también pueden producirlo; revise el historial y el contexto del directorio antes de declarar contenido perdido.

Para cada candidato conserve ruta, tamaño, hash, relación, decisión humana y ubicación de una copia preservada. Cada registro lleva deleteApproved:false. El registro aporta pruebas para la revisión y omite deliberadamente comandos para borrar o fusionar.

5. Deténgase ante rechazos o alcance insuficiente

El escáner rechaza una raíz con enlace simbólico, enlaces simbólicos en el árbol examinado, entradas Markdown que no sean archivos regulares y colisiones de rutas tras normalizar NFC y plegar mayúsculas. Omite directorios llamados .obsidian, .git y .trash, y calcula hashes solo de Markdown. Son límites de alcance, no un certificado de integridad de toda la bóveda.

Aplica los límites indicados de archivos, entradas y bytes, y compara identidad, tamaño y fecha de modificación alrededor de cada lectura. Un cambio observado causa rechazo. No es una instantánea atómica del sistema de archivos ni un entorno aislado contra cambios maliciosos en rutas superiores o sistemas hostiles. No lo ejecute en una bóveda activa que siga cambiando.

Si un límite, un error de permisos o un nombre no admitido impide la cobertura necesaria, conserve el rechazo. Utilice una copia menor preservada por separado o una herramienta especializada revisada; no quite controles ni considere completa una lista parcial. Investigar carpetas duplicadas comunes aún exige revisar directorios y la aplicación.

6. Recupere contenido mediante la revisión humana documentada

Use el filtro de conflictos del registro de Sync y el historial por archivo para investigar una nota. Previsualice antes de restaurar: la acción oficial sustituye la nota actual. Los archivos borrados o renombrados tienen otra ruta de historial en los ajustes de Sync. La retención depende del plan y del tipo de archivo; compruebe la política actual en lugar de asumir recuperación indefinida.

Fusione el texto necesario en una copia nueva para revisión, compruebe en Obsidian que los enlaces y adjuntos importantes siguen abriendo y respalde el resultado elegido antes de limpiar originales. Restaurar, renombrar o borrar son acciones humanas separadas; este script no realiza ninguna.

7. Sepa qué demuestran las pruebas

Veinte casos sintéticos verificaron bytes iguales y diferentes, originales ausentes sin adivinar otra carpeta, sufijos de segundos no admitidos, fechas inválidas, nombres anidados, grupos de duplicados comunes y exclusiones. También comprobaron rechazo de enlaces en raíz/archivo/directorio, colisiones de mayúsculas y Unicode, límites por archivo/recuento/entradas/total, cambios entre lstat y open, salidas CLI 0/2 y bytes originales intactos.

Los resultados prueban la implementación del inventario, no el motor de sincronización de Obsidian. No se ejercitaron Windows, macOS, estado remoto, móviles, interfaz gráfica ni recuperación de conflictos reales. Cierre el incidente solo después de revisar su contenido y las copias preservadas, registrando archivos o dispositivos aún no probados.

Preguntas frecuentes

¿Puedo borrar todas las copias SAME_BYTES? No. El hash prueba igualdad de bytes durante el examen, no la función del nombre, los enlaces entrantes ni el historial remoto.

¿Encuentra párrafos duplicados dentro de una nota? No. Enumera archivos Markdown completos; las duplicaciones internas requieren revisar diferencias o historial de versiones.

¿Por qué queda sin admitir un nombre con catorce dígitos? Esta versión solo reconoce el patrón oficial actual de doce dígitos. Conserva otros nombres con marca de conflicto para revisión manual en vez de adivinarlos.

¿ORIGINAL_MISSING prueba que se borró el original? No. Moverlo, renombrarlo, usar otra capitalización de extensión o excluir directorios también impide la coincidencia exacta.

¿El código de salida 0 significa que Sync está reparado? No. Significa que terminó un inventario local; el informe nunca autoriza fusionar, restaurar ni borrar.

Ilustración original de SAME_BYTES, DIFFERENT_BYTES, ORIGINAL_MISSING y la cola de nombres no admitidos.
Ilustración editorial original: tres resultados de revisión y nombres no admitidos. Los bytes iguales nunca autorizan borrar una nota. Watch at Ease