From a91405632ef7c175ee415159ffbc4326be97269c Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 04:25:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(git-sync):=20native-Obsidian=20format=20?= =?UTF-8?q?=E2=80=94=20phase=201=20=3D=20page-file=20(frontmatter=20gitmos?= =?UTF-8?q?t=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels with the file so identity survives any move); folders mirror the page tree with the parent's body as a folder-note `/.md` (LostPaul Folder Notes convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/` and non-page files left untouched (no .gitignore). Verified the conventions against the Obsidian/Folder-Notes docs. Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts. Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine suite green. Not yet wired into pull/push — no behavior change. Design doc rewritten to the native-Obsidian format. Co-Authored-By: Claude Opus 4.8 --- docs/backlog/git-sync-thin-meta.md | 238 ++++++++------------ packages/git-sync/src/engine/vault-index.ts | 154 ------------- packages/git-sync/src/index.ts | 15 +- packages/git-sync/src/lib/index.ts | 1 + packages/git-sync/src/lib/page-file.ts | 86 +++++++ packages/git-sync/test/page-file.test.ts | 43 ++++ packages/git-sync/test/vault-index.test.ts | 78 ------- 7 files changed, 226 insertions(+), 389 deletions(-) delete mode 100644 packages/git-sync/src/engine/vault-index.ts create mode 100644 packages/git-sync/src/lib/page-file.ts create mode 100644 packages/git-sync/test/page-file.test.ts delete mode 100644 packages/git-sync/test/vault-index.test.ts diff --git a/docs/backlog/git-sync-thin-meta.md b/docs/backlog/git-sync-thin-meta.md index 5236fe95..6882e5d6 100644 --- a/docs/backlog/git-sync-thin-meta.md +++ b/docs/backlog/git-sync-thin-meta.md @@ -1,180 +1,132 @@ -# git-sync: thin meta + third-party-editor support +# git-sync: native-Obsidian vault format Статус: **дизайн (согласован с владельцем 2026-06-24), к реализации.** -## Зачем +## Цель -Сейчас каждый `.md` в волте несёт служебный блок -`` и -``. Сторонние редакторы (Obsidian + obsidian-git — -основной кейс) про это не знают: они кладут «голые» markdown-файлы и папки, а -движок их МОЛЧА игнорирует (нет pageId → не страница), плюс мета сорит в каждом -файле. - -Цель: `.md` остаются **чистым markdown** (любой редактор доволен), вся служебка -живёт в одном сайдкаре `.gitmost/index.json`, а дерево страниц **зеркалится -структурой папок**. Голые файлы/папки от стороннего редактора **адоптируются** в +Волт спейса должен быть **настоящим Obsidian-волтом**: владелец открывает папку в +Obsidian (с плагином Folder Notes) и получает ровно ту же структуру страниц, не +замечая разницы. Никаких служебных артефактов, которые бы выглядели чужеродно. +Сторонние редакторы кладут «голые» файлы/папки — движок их **адоптирует** в страницы Docmost. -## Решённые принципы +Сейчас каждый `.md` несёт жирный `` блок — это уезжает. -1. **Идентичность — стабильный id, не имя файла и не путь.** (Подпёрто фиксом - 5133bb34: страница с известным id, чей файл переехал, никогда не удаляется — - только move.) `pageId` нужен как durable мост файл↔Docmost-страница: title, - путь, parent — всё мутабельно, id — нет; без него re-clone/rename плодят дубли. -2. **id ЕДЕТ ВМЕСТЕ С ФАЙЛОМ.** Критично: path-keyed сайдкар (`index.json` по - пути) НЕ годится источником истины — если git не распознал rename (контент - менялся вместе с именем, наш кейс), путь-ключ протухает и страница теряется. - Поэтому id хранится так, что переживает любой move: - - **Вариант B** — slugId суффиксом в ИМЕНИ файла (`Заметка ~Cj7YX7.md`). - Ноль меты в контенте; имена «грязные»; адопция переименовывает файл юзера. - - **Вариант C** — минимальный YAML-frontmatter `id:` в начале файла. Имена - чистые; frontmatter скрыт в Obsidian; адопция имя не трогает (только дописывает). - - **ОТКРЫТО:** B или C (см. ниже). Обе устойчивы. Сайдкар может остаться как - опциональный КЭШ path→id, но не источник истины. - Всё остальное (title/parent/spaceId/version) из файла убираем — выводимо. -3. **Папка = страница-родитель**, дерево зеркальное 1:1. -4. **Контент родителя — внутри его папки**, файлом-индексом (folder-note). - -## Открытое решение: B vs C (носитель id) - -Различие свелось к UX, устойчивость равная (id в обоих едет с файлом): -- **B (id в имени):** проще в реализации (парс суффикса), ноль меты в контенте, но - Obsidian показывает ` ~slug` как заголовок заметки, и адопция переименовывает - голый файл (→ может поехать `[[ссылка]]`). -- **C (frontmatter `id:`):** чистые имена, скрытый нативный YAML, адопция имя не - трогает (дописывает строку). Цена — одна строка «меты» в файле. -- Рекомендация автора — **C** (чистые имена важнее, frontmatter идиоматичен), но - это вкусовщина, решает владелец. -- NB: ссылки между заметками ломаются при rename в ЛЮБОМ варианте (title=имя - файла) — это не различает B/C, это отдельная фаза (см. ниже). - -## Формат волта +## Формат ``` / - .gitmost/ - index.json # вся служебная мета (см. ниже) - Заметка.md # лист без детей: .md, чистый markdown - Проект/ # страница-родитель = папка - index.md # ТЕЛО самой страницы «Проект» (folder-note) + Заметка.md # лист: чистый markdown + frontmatter id + Проект/ # страница-родитель = ПАПКА + Проект.md # folder-note: ТЕЛО самой страницы «Проект» Задача.md # ребёнок Подпроект/ - index.md # тело «Подпроект» + Подпроект.md # тело «Подпроект» ... + .obsidian/ # конфиг Obsidian — движок НЕ ТРОГАЕТ +``` + +Каждый файл страницы: +``` +--- +gitmost_id: 019ef6fc-2638-7ce1-9ce3-2756ce038480 +--- +<чистый markdown — тело страницы (wiki-ссылки, всё как в Obsidian)> ``` - **Лист** (нет детей) → `<title>.md`. -- **Родитель** (есть дети) → папка `<title>/`, тело в `<title>/index.md`. - Когда у листа появляется первый ребёнок — `<title>.md` превращается в - `<title>/index.md` (это безопасный move по pageId). -- **Имя файла** = санитизированный title + `.md`. Родитель чьё имя = имя папки. -- **Коллизии** (2+ сиблинга с одним title): всем коллидирующим — суффикс - ` ~<slugId>` (`Заметка ~Cj7YX7.md`). slugId короткий и стабильный (из Docmost), - суффикс детерминирован по самой странице, не по порядку. Смена суффикса — - безопасный rename (идентичность в индексе по pageId). - - `index.md` внутри папки «занят» телом родителя; ребёнок с title «index» - получает `index ~slug.md`. +- **Родитель** (есть дети) → папка `<title>/`, его тело в `<title>/<title>.md` + (folder-note по конвенции плагина LostPaul Folder Notes — заметка с именем + папки внутри неё). Лист, у которого появился первый ребёнок, превращается из + `<title>.md` в `<title>/<title>.md` (безопасный move по id). +- **title** = имя файла (для папки — имя папки). **parentPageId** = ближайшая + родительская папка (её folder-note). **spaceId** = эта репа. Всё выводимо. +- **Идентичность** — `gitmost_id` (= Docmost pageId) во frontmatter. Невыводима, + едет ВМЕСТЕ с файлом → переживает любой move, даже не распознанный git как + rename. (Ключ namespaced `gitmost_id`, не голый `id`, чтобы не конфликтовать с + пользовательскими frontmatter-полями. Имя ключа — последнее на подтверждении.) +- **Коллизии имён** (2+ сиблинга с одним title): как делает сам Obsidian — + добавляем натуральный суффикс ` 2`, ` 3`. id во frontmatter, так что имя файла + чисто косметическое; смена суффикса — безопасный rename (идентичность по id). -### `.gitmost/index.json` +Никакого `.gitmost/index.json` (сайдкар отвергнут: path-keyed индекс хрупок к +rename; id во frontmatter самодостаточен). Никаких `docmost:meta`/`docmost:comments` +блоков (комменты и так живут инлайн-марками `<span data-comment-id>` в теле). -```jsonc -{ - "version": 1, - "spaceId": "019…", // спейс = эта репа (одна на спейс) - "pages": { - // ключ — vault-relative путь файла (forward-slash) - "Проект/index.md": { "pageId": "019…", "slugId": "Ab12Cd" }, - "Проект/Задача.md": { "pageId": "019…", "slugId": "Ef34Gh" }, - "Заметка ~Cj7YX7.md": { "pageId": "019…", "slugId": "Cj7YX7" } - } -} -``` +## Ссылки между заметками (`[[wikilinks]]`) -- `title` — выводим из имени файла (stem), для папки — из имени папки. -- `parentPageId` — выводим из ПУТИ (родитель = `index.md` ближайшей родительской - папки; корневой файл → parent=null). Это уже path-as-truth на пуше сегодня. -- `version`/`updatedAt` — не храним: факт правки берётся из git. -- В индексе — только то, что НЕ выводимо: `pageId` (идентичность) и `slugId` - (дизамбигуация коллизий, write-back при create). +Obsidian резолвит `[[Заметка]]` по **basename** (не по полному пути), нормализуя +пробелы/`-`/`_`, с приоритетом короткого пути при неоднозначности. + +- В Docmost ссылки — по pageId (mention/reference node), rename переживают. +- В волте — обсидиановские `[[basename]]`. +- Следствие: **reparent (смена папки) ссылку НЕ ломает** (basename тот же), + ломает только **retitle**. Значит переписывать `[[…]]` надо только при смене + имени страницы — узкий случай. (Obsidian сам умеет «update links on rename».) +- Конвертер Docmost-mention ↔ `[[wikilink]]` (обе стороны) + переписывание при + retitle — **отдельная фаза** (см. план), не блокирует формат. ## PULL (Docmost → vault) -1. Прочитать дерево спейса (как сейчас) + текущий `.gitmost/index.json`. -2. Построить layout: лист→`<t>.md`, родитель→`<t>/index.md`, коллизии→` ~slug`. -3. Записать ЧИСТЫЕ `.md` (только тело) + обновить `index.json` (path→{pageId,slugId}). -4. Старые `index.md`/файлы переехавших страниц — move (по pageId), не delete. -5. Коммит на `docmost`, merge в `main` (как сейчас). +1. Прочитать дерево спейса. +2. Layout: лист→`<t>.md`, родитель→`<t>/<t>.md`, коллизии→` 2`/` 3`. +3. Записать `---\ngitmost_id: …\n---\n<тело>` (чистый markdown). +4. Переехавшие файлы — move (по id), не delete. +5. Коммит на `docmost`, merge в `main`. ## PUSH (vault → Docmost) -1. Дифф `last-pushed..main` (как сейчас). -2. Идентичность файла берём из `index.json` (path→pageId), НЕ из меты в файле. -3. Родитель — из пути (enclosing `<folder>/index.md` → его pageId из индекса). -4. Классификация: - - файл/папка ЕСТЬ в индексе → update/move/rename по pageId (как сейчас, но - идентичность из индекса). - - файла НЕТ в индексе (новый голый файл) → **adopt**: create page - (title=stem, parent=из пути), записать pageId/slugId в индекс + (если - коллизия) переименовать файл с суффиксом. - - голая папка с детьми, но без своей записи → создать страницу-родитель - (пустое тело), завести `<folder>/index.md`, добавить в индекс. - - файл пропал, а pageId ещё в дереве/индексе под другим путём → move (фикс уже - есть). Реально пропал отовсюду → delete (как сейчас, под delete-cap). +1. Дифф `last-pushed..main`. +2. Идентичность файла — из frontmatter `gitmost_id`. Родитель — из пути (folder-note + родительской папки). +3. Классификация: + - есть `gitmost_id` в дереве → update/move/rename по id (страховка 5133bb34). + - нет id (новый голый файл от Obsidian) → **adopt**: create page (title=имя, + parent=папка), дописать `gitmost_id` во frontmatter. + - голая папка с детьми без folder-note → создать страницу-родитель, завести + `<folder>/<folder>.md`. + - файл пропал, а id ещё в дереве под другим путём → move. Реально пропал → + delete (под delete-cap). + +## Адопция (третья-сторона → Docmost) + +- голый `.md` без frontmatter id → create page. +- голая папка с `.md` внутри без folder-note → create страницу-родитель + folder-note. +- `.obsidian/`, аттачменты, dot-файлы, любые не-`.md` → **игнор** (не страницы), + лежат в гите как есть, Obsidian ими владеет. Без `.gitignore`. ## Миграция со старого формата -Существующие волты несут `docmost:meta` в файлах и НЕ имеют `.gitmost/index.json`. +Существующие волты несут `docmost:meta` в файлах. -- На первом цикле нового движка: если `.gitmost/index.json` нет — построить его из - `docmost:meta`, прочитанных по файлам, затем **переписать** файлы без меты - (один разовый «normalize» коммит) и разложить в folder-note layout. -- **Фолбэк навсегда**: если у файла нашлась `docmost:meta`, а в индексе записи нет - — уважаем мету (на случай файла, принесённого со старой системой). +- На первом цикле нового движка: если у файла нет frontmatter, но есть + `docmost:meta` → читаем pageId оттуда, переписываем файл в native-формат + (frontmatter id + чистое тело + folder-note layout), разовый «normalize» коммит. +- **Фолбэк навсегда**: `docmost:meta` всё ещё парсится как источник id, если + frontmatter нет (файл со старой системы). (Реализовано в `parsePageFile`.) ## Краевые случаи -- Git не хранит пустые папки → «омит файла родителя» работает только пока есть - дети (папка жива за счёт детей). Childless пустая страница → свой `<t>.md`. -- `.gitmost/` и `.obsidian/` (и любые dot-папки) — игнор движком; добавить - дефолтный `.gitignore` для `.obsidian/` при инициализации волта. -- Конфликт `index.md` (folder-note) с ребёнком title «index» → ребёнку суффикс. -- Переименование папки (= rename родителя) → move всего поддерева, идентичность по - pageId из индекса, без delete+create. +- Git не хранит пустые папки → «родитель без своего файла» невозможен: тело + родителя — это folder-note `<t>/<t>.md`, он и держит папку (плюс дети). Childless + пустая страница → просто `<t>.md`. +- Конфликт folder-note `Папка/Папка.md` с ребёнком title «Папка» → ребёнку суффикс. +- Переименование папки (= rename родителя) → move всего поддерева по id, без + delete+create; ссылки `[[…]]` на сам родитель переписать (basename сменился). -## План фаз (каждая — тесты на одноразовом стенде + юниты движка) +## План фаз (каждая — юниты движка + браузерный e2e + изолированные shell-e2e) -1. **Индекс-модуль**: чтение/запись `.gitmost/index.json` (pure + IO-инъекция), - юниты. Без смены поведения. -2. **PULL пишет чистые файлы + индекс** (folder-note layout), миграция со старого - формата. Юниты layout + e2e: Docmost→git даёт чистый markdown, folder-note. -3. **PUSH читает индекс** для идентичности/родителя (вместо меты в файле). e2e: - push-правка/move/rename без потерь. -4. **Адопция**: голый `.md`/папка → страница. e2e: положить файл/папку через - Obsidian-подобный клиент → появляются страницы. -5. **Чистка**: удалить `docmost:meta` из формата (оставить только фолбэк-парсер), - `.gitignore` для `.obsidian/`. -6. **Синк ссылок между заметками** (см. ниже) — отдельный кусок, после формата. - -## Ссылки между заметками (фаза 6) - -Поднял владелец: `title = имя файла`, значит при переименовании страницы ссылки на -неё в волте протухают. - -- **В Docmost** ссылки — по pageId (mention/reference node редактора), переименование - ПЕРЕЖИВАЮТ, не ломаются. Эта сторона безопасна. -- **В волте** markdown-ссылки — по пути/имени (`[t](rel/path.md)` или `[[Имя]]`), - при rename/move файла протухают. -- Решение: при rename/move файла движок ПЕРЕПИСЫВАЕТ цель ссылок во всех файлах, - что на него ссылались (Obsidian «update links on rename»). Альтернатива — - хранить ссылки в волте по стабильному id (но это уже не человекочитаемый md; - отвергаем для дружбы со сторонними редакторами). -- Конвертер Docmost-mention ↔ markdown-link (обе стороны) — часть этой фазы. -- Не влияет на выбор B/C и на фазы формата 1–5; выносим отдельно. +1. ✅ Формат файла: `parsePageFile`/`serializePageFile` (frontmatter id + тело, + фолбэк на legacy `docmost:meta`). Юниты. Без смены поведения. (готово) +2. PULL пишет native-формат (frontmatter + folder-note layout) + миграция. +3. PUSH берёт идентичность из frontmatter, родителя из пути. +4. Адопция голых файлов/папок. +5. Чистка: убрать `docmost:meta` из генерации (оставить фолбэк-парсер). +6. Ссылки: конвертер Docmost-mention ↔ `[[wikilink]]` + переписывание при retitle. ## Риски -Это смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня уже ловили -тяжёлый баг с трашем живых страниц). Поэтому: каждая фаза — за фиче-флагом/инкрем., -с юнит-тестами движка И браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированные -shell-e2e на одноразовом спейсе. Никаких «миграций на месте» без бэкапа волта. +Смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня ловили тяжёлый баг +с трашем живых страниц). Каждая фаза — за инкрементом, с юнит-тестами движка И +браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированными shell-e2e на +одноразовом спейсе. Без in-place миграций без бэкапа волта. diff --git a/packages/git-sync/src/engine/vault-index.ts b/packages/git-sync/src/engine/vault-index.ts deleted file mode 100644 index b18f8481..00000000 --- a/packages/git-sync/src/engine/vault-index.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * The vault SIDECAR index — `.gitmost/index.json`. It holds the ONLY service - * metadata that is not derivable from the vault itself: a page's stable identity - * (`pageId`) and its collision-disambiguation token (`slugId`), keyed by the - * file's vault-relative (forward-slash) path. Everything else is derived: - * - title -> the file/folder name (stem), - * - parentPageId-> the enclosing folder's `index.md` (path-as-truth), - * - spaceId -> the vault is the space, - * - updatedAt -> git history. - * - * Keeping identity here (not in a `docmost:meta` block inside every file) lets - * the `.md` files stay CLEAN markdown that any third-party editor (Obsidian, …) - * reads and writes directly. This module is PURE (parse/serialize/lookup); all - * file IO is the caller's (injected), matching the rest of the engine. - */ - -/** Where the sidecar lives inside a space vault (vault-relative, forward-slash). */ -export const VAULT_INDEX_PATH = ".gitmost/index.json"; - -/** Per-file identity record. `slugId` is optional (a freshly adopted file has - * none until Docmost assigns one on create). */ -export interface VaultIndexEntry { - pageId: string; - slugId?: string; -} - -export interface VaultIndex { - version: number; - /** The space this vault mirrors (one repo per space). Informational. */ - spaceId?: string; - /** file path (forward-slash, vault-relative) -> identity. */ - pages: Map<string, VaultIndexEntry>; -} - -const CURRENT_VERSION = 1; - -export function emptyVaultIndex(spaceId?: string): VaultIndex { - return { version: CURRENT_VERSION, spaceId, pages: new Map() }; -} - -/** - * Parse `.gitmost/index.json`. TOLERANT by construction — a missing file - * (`null`), invalid JSON, or a malformed entry must never crash a sync cycle, so - * those degrade to an empty index / skipped entries (the engine then treats the - * affected files as un-tracked and re-derives identity, rather than losing data). - */ -export function parseVaultIndex(text: string | null | undefined): VaultIndex { - if (text == null || text.trim() === "") return emptyVaultIndex(); - let raw: unknown; - try { - raw = JSON.parse(text); - } catch { - return emptyVaultIndex(); - } - if (typeof raw !== "object" || raw === null) return emptyVaultIndex(); - const obj = raw as Record<string, unknown>; - const index = emptyVaultIndex( - typeof obj.spaceId === "string" ? obj.spaceId : undefined, - ); - if (typeof obj.version === "number") index.version = obj.version; - const pages = obj.pages; - if (typeof pages === "object" && pages !== null) { - for (const [path, value] of Object.entries(pages as Record<string, unknown>)) { - if (typeof value !== "object" || value === null) continue; - const entry = value as Record<string, unknown>; - if (typeof entry.pageId !== "string" || entry.pageId === "") continue; - index.pages.set(path, { - pageId: entry.pageId, - ...(typeof entry.slugId === "string" ? { slugId: entry.slugId } : {}), - }); - } - } - return index; -} - -/** - * Serialize to STABLE JSON: object keys sorted so the file produces minimal, - * deterministic git diffs (a re-sync that changes nothing yields byte-identical - * output — no churn, which the loop-guard relies on). Trailing newline. - */ -export function serializeVaultIndex(index: VaultIndex): string { - const pages: Record<string, VaultIndexEntry> = {}; - for (const path of [...index.pages.keys()].sort()) { - const e = index.pages.get(path)!; - pages[path] = e.slugId - ? { pageId: e.pageId, slugId: e.slugId } - : { pageId: e.pageId }; - } - const out: Record<string, unknown> = { version: index.version }; - if (index.spaceId) out.spaceId = index.spaceId; - out.pages = pages; - return JSON.stringify(out, null, 2) + "\n"; -} - -// --- lookups (pure) -------------------------------------------------------- - -/** The pageId tracked at `path`, or undefined. */ -export function pageIdAt(index: VaultIndex, path: string): string | undefined { - return index.pages.get(path)?.pageId; -} - -/** The slugId tracked at `path`, or undefined. */ -export function slugIdAt(index: VaultIndex, path: string): string | undefined { - return index.pages.get(path)?.slugId; -} - -/** - * Reverse lookup: the CURRENT path of a pageId, or undefined. Used by push to - * decide identity — a vanished file whose pageId still resolves to a (different) - * tracked path is a MOVE, not a delete. - */ -export function pathForPageId( - index: VaultIndex, - pageId: string, -): string | undefined { - for (const [path, entry] of index.pages) { - if (entry.pageId === pageId) return path; - } - return undefined; -} - -/** The set of all pageIds currently tracked in the index. */ -export function trackedPageIds(index: VaultIndex): Set<string> { - const ids = new Set<string>(); - for (const entry of index.pages.values()) ids.add(entry.pageId); - return ids; -} - -// --- mutations (in place; the index is a builder during a cycle) ----------- - -export function setEntry( - index: VaultIndex, - path: string, - entry: VaultIndexEntry, -): void { - index.pages.set(path, entry); -} - -export function removeAt(index: VaultIndex, path: string): void { - index.pages.delete(path); -} - -/** Move a tracked entry from one path to another (a rename/reparent), keeping - * its identity. No-op if `fromPath` is not tracked. */ -export function moveEntry( - index: VaultIndex, - fromPath: string, - toPath: string, -): void { - const entry = index.pages.get(fromPath); - if (!entry) return; - index.pages.delete(fromPath); - index.pages.set(toPath, entry); -} diff --git a/packages/git-sync/src/index.ts b/packages/git-sync/src/index.ts index 582f132d..c6038d06 100644 --- a/packages/git-sync/src/index.ts +++ b/packages/git-sync/src/index.ts @@ -120,17 +120,4 @@ export type { CycleFs, } from "./engine/cycle"; -export { - VAULT_INDEX_PATH, - emptyVaultIndex, - parseVaultIndex, - serializeVaultIndex, - pageIdAt, - slugIdAt, - pathForPageId, - trackedPageIds, - setEntry, - removeAt, - moveEntry, -} from "./engine/vault-index"; -export type { VaultIndex, VaultIndexEntry } from "./engine/vault-index"; +export { parsePageFile, serializePageFile } from "./lib/page-file"; diff --git a/packages/git-sync/src/lib/index.ts b/packages/git-sync/src/lib/index.ts index 52b6cc4a..a3d9fd32 100644 --- a/packages/git-sync/src/lib/index.ts +++ b/packages/git-sync/src/lib/index.ts @@ -25,3 +25,4 @@ export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize"; +export { parsePageFile, serializePageFile } from "./page-file"; diff --git a/packages/git-sync/src/lib/page-file.ts b/packages/git-sync/src/lib/page-file.ts new file mode 100644 index 00000000..94fcd8ed --- /dev/null +++ b/packages/git-sync/src/lib/page-file.ts @@ -0,0 +1,86 @@ +import { parseDocmostMarkdown } from "./markdown-document"; + +/** + * The THIN page-file format (design: docs/backlog/git-sync-thin-meta.md, option + * C). A page file is CLEAN markdown with a minimal YAML frontmatter carrying ONLY + * the page's durable identity: + * + * --- + * id: 019ef6fc-2638-7ce1-9ce3-2756ce038480 + * --- + * <clean markdown body> + * + * Everything else is derived (title = filename, parentPageId = enclosing folder, + * spaceId = the vault, updatedAt = git). The `id` (a Docmost pageId) is the only + * non-derivable bit and travels WITH the file so identity survives any move, + * even one git's rename detection misses. Third-party editors (Obsidian, …) see + * clean markdown; the frontmatter is hidden in their preview. + * + * MIGRATION: a file may still carry the LEGACY `<!-- docmost:meta {…} -->` block + * (the pre-thin format). `parsePageFile` reads the id from the frontmatter first, + * then falls back to the legacy meta — so old vaults keep working and a re-sync + * rewrites them into the thin format. + */ + +/** + * The frontmatter key carrying the Docmost pageId. NAMESPACED (not a bare `id`) + * so it never collides with a user's own frontmatter fields. + */ +export const ID_KEY = "gitmost_id"; + +/** Leading YAML frontmatter block: `---\n…\n---` at the very start of the file. */ +const FRONTMATTER_RE = /^?---\n([\s\S]*?)\n---\n?/; + +/** The top-level `<ID_KEY>: <value>` line inside the frontmatter (quotes optional). */ +function readIdFromYaml(yaml: string): string | null { + const re = new RegExp(`^${ID_KEY}:\\s*(.+?)\\s*$`); + for (const line of yaml.split("\n")) { + const m = line.match(re); + if (m) { + const v = m[1].trim().replace(/^["']|["']$/g, ""); + return v === "" ? null : v; + } + } + return null; +} + +/** + * Parse a page file into its identity (`id`) and clean markdown `body`. Tolerant: + * a file with neither frontmatter nor legacy meta (a hand-written third-party + * file) returns `id: null` and the whole text as the body — the caller then + * ADOPTS it (creates a page, writes the id back). + */ +export function parsePageFile(full: string): { + id: string | null; + body: string; +} { + const text = (full ?? "").replace(/\r\n/g, "\n"); + + // 1. Thin format: YAML frontmatter. + const fm = text.match(FRONTMATTER_RE); + if (fm) { + return { id: readIdFromYaml(fm[1]), body: text.slice(fm[0].length).trim() }; + } + + // 2. Legacy format: `<!-- docmost:meta -->` block (migration fallback). + if (/^\s*<!--\s*docmost:meta/.test(text)) { + try { + const { meta, body } = parseDocmostMarkdown(text); + return { id: meta?.pageId ?? null, body }; + } catch { + // a corrupt legacy block -> treat as an un-tracked plain file (adopt). + } + } + + // 3. Plain markdown — un-tracked (no identity yet). + return { id: null, body: text.trim() }; +} + +/** + * Serialize a page into the thin format: `id` frontmatter + a blank line + the + * clean body + a trailing newline. Deterministic so an unchanged page re-syncs to + * byte-identical output (no churn — the loop-guard relies on it). + */ +export function serializePageFile(id: string, body: string): string { + return `---\n${ID_KEY}: ${id}\n---\n\n${body.trim()}\n`; +} diff --git a/packages/git-sync/test/page-file.test.ts b/packages/git-sync/test/page-file.test.ts new file mode 100644 index 00000000..5417ab98 --- /dev/null +++ b/packages/git-sync/test/page-file.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { parsePageFile, serializePageFile } from "../src/lib/page-file"; +import { serializeDocmostMarkdownBody } from "../src/lib/index"; + +describe("page-file thin format", () => { + it("round-trips id frontmatter + clean body", () => { + const text = serializePageFile("019ef6fc-2638", "# Hello\n\nbody text"); + expect(text.startsWith("---\ngitmost_id: 019ef6fc-2638\n---\n")).toBe(true); + const { id, body } = parsePageFile(text); + expect(id).toBe("019ef6fc-2638"); + expect(body).toBe("# Hello\n\nbody text"); + }); + + it("serialization is deterministic (byte-identical for the same input)", () => { + expect(serializePageFile("p", "x")).toBe(serializePageFile("p", "x")); + }); + + it("reads id from frontmatter with quotes / extra fields", () => { + expect(parsePageFile('---\ngitmost_id: "abc"\ntitle: ignored\n---\nbody').id).toBe("abc"); + expect(parsePageFile("---\ngitmost_id: 'xyz'\n---\nbody").id).toBe("xyz"); + }); + + it("MIGRATION: falls back to a legacy docmost:meta block for the id", () => { + const legacy = serializeDocmostMarkdownBody( + { version: 1, pageId: "legacy-1", title: "T", spaceId: "sp" }, + "old body", + ); + const { id, body } = parsePageFile(legacy); + expect(id).toBe("legacy-1"); + expect(body).toContain("old body"); + }); + + it("ADOPT: a plain hand-written file has no id and keeps its whole body", () => { + const { id, body } = parsePageFile("# Just a note\n\nwritten in Obsidian"); + expect(id).toBeNull(); + expect(body).toBe("# Just a note\n\nwritten in Obsidian"); + }); + + it("tolerates empty / whitespace input", () => { + expect(parsePageFile("").id).toBeNull(); + expect(parsePageFile(" \n ").body).toBe(""); + }); +}); diff --git a/packages/git-sync/test/vault-index.test.ts b/packages/git-sync/test/vault-index.test.ts deleted file mode 100644 index c37800c0..00000000 --- a/packages/git-sync/test/vault-index.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - emptyVaultIndex, - parseVaultIndex, - serializeVaultIndex, - pageIdAt, - slugIdAt, - pathForPageId, - trackedPageIds, - setEntry, - removeAt, - moveEntry, -} from "../src/engine/vault-index"; - -describe("vault-index parse/serialize", () => { - it("round-trips a populated index", () => { - const idx = emptyVaultIndex("sp1"); - setEntry(idx, "Проект/index.md", { pageId: "p1", slugId: "Ab12" }); - setEntry(idx, "Заметка.md", { pageId: "p2" }); - const text = serializeVaultIndex(idx); - const back = parseVaultIndex(text); - expect(back.spaceId).toBe("sp1"); - expect(pageIdAt(back, "Проект/index.md")).toBe("p1"); - expect(slugIdAt(back, "Проект/index.md")).toBe("Ab12"); - expect(pageIdAt(back, "Заметка.md")).toBe("p2"); - expect(slugIdAt(back, "Заметка.md")).toBeUndefined(); - }); - - it("serializes deterministically (sorted keys -> stable diffs)", () => { - const a = emptyVaultIndex("s"); - setEntry(a, "b.md", { pageId: "2" }); - setEntry(a, "a.md", { pageId: "1" }); - const b = emptyVaultIndex("s"); - setEntry(b, "a.md", { pageId: "1" }); - setEntry(b, "b.md", { pageId: "2" }); - // insertion order differs; serialized output must be identical. - expect(serializeVaultIndex(a)).toBe(serializeVaultIndex(b)); - // keys are sorted in the output - expect(serializeVaultIndex(a).indexOf('"a.md"')).toBeLessThan( - serializeVaultIndex(a).indexOf('"b.md"'), - ); - }); - - it("is tolerant: null / garbage / bad entries -> empty or skipped", () => { - expect(parseVaultIndex(null).pages.size).toBe(0); - expect(parseVaultIndex("").pages.size).toBe(0); - expect(parseVaultIndex("not json{").pages.size).toBe(0); - expect(parseVaultIndex("[1,2,3]").pages.size).toBe(0); - // a page entry missing pageId is skipped, valid ones kept - const idx = parseVaultIndex( - JSON.stringify({ version: 1, pages: { "ok.md": { pageId: "p" }, "bad.md": { slugId: "x" } } }), - ); - expect(idx.pages.size).toBe(1); - expect(pageIdAt(idx, "ok.md")).toBe("p"); - }); -}); - -describe("vault-index lookups + mutations", () => { - it("reverse lookup + tracked set", () => { - const idx = emptyVaultIndex(); - setEntry(idx, "x.md", { pageId: "px" }); - setEntry(idx, "y/index.md", { pageId: "py" }); - expect(pathForPageId(idx, "py")).toBe("y/index.md"); - expect(pathForPageId(idx, "missing")).toBeUndefined(); - expect([...trackedPageIds(idx)].sort()).toEqual(["px", "py"]); - }); - - it("moveEntry relocates identity; removeAt drops it", () => { - const idx = emptyVaultIndex(); - setEntry(idx, "Old.md", { pageId: "p", slugId: "s" }); - moveEntry(idx, "Old.md", "New/index.md"); - expect(pageIdAt(idx, "Old.md")).toBeUndefined(); - expect(pageIdAt(idx, "New/index.md")).toBe("p"); - expect(slugIdAt(idx, "New/index.md")).toBe("s"); // identity preserved - removeAt(idx, "New/index.md"); - expect(pageIdAt(idx, "New/index.md")).toBeUndefined(); - }); -});