commit d5cd1bba02a96abbf4225ef23337a2e4275bc605 Author: vvzvlad Date: Tue Jun 16 18:30:04 2026 +0300 docs: add project spec and VS Code workspace diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ba74806 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,307 @@ +# docmost-sync — ТЗ + +Двусторонняя синхронизация статей Docmost с локальной папкой Markdown, где +**state store — git**. Поменяли в Docmost → приехало в `.md`; поменяли `.md` → +уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git. + +Статус: спецификация (design fixed). Реализации ещё нет. + +--- + +## 1. Цель и границы + +- **Цель:** держать в файловой системе живую копию страниц Docmost в Markdown с + непрерывной двусторонней синхронизацией тела страниц. +- **Что синхронизируется двусторонне:** тело страницы (контент) + структура + дерева (иерархия, перемещения, переименования) + удаления. +- **Что НЕ синхронизируется:** комментарии — ни в какую сторону (см. §3). +- **Не входит в первую версию:** права/ACL, версии истории Docmost как отдельная + сущность, вложения как отдельный поток (едут как ссылки внутри контента), + realtime-подписка (Фаза 3). + +### Опора на существующий код +Переиспользуем проект `docmost-mcp` (Node/TS) как библиотеку, НЕ как обязательный +слой: +- `DocmostClient` (логин, REST-вызовы) — основа клиента к Docmost; +- lossless-конвертер `convertProseMirrorToMarkdown` / `markdownToProseMirror`; +- collab-путь записи `replacePageContent` / `mutatePageContent` (Hocuspocus/Yjs). + +**Важно:** MCP-инструменты — это тонкая обёртка над HTTP API Docmost. Синк-движок +ходит в REST Docmost **напрямую** и волен использовать любые эндпойнты, которых +нет в MCP (в частности — листинг корзины и restore, см. §8). + +--- + +## 2. Как Docmost хранит контент (контекст) + +- Контент страницы — **ProseMirror/TipTap JSON** в колонке `content` (jsonb) + таблицы `pages`. Его отдаёт REST `POST /pages/info`. +- Параллельно живёт **Yjs/CRDT бинарь (ydoc)** — состояние совместного + редактирования (Hocuspocus). `content` — это дебаунс-снимок ydoc. +- **Запись обратно делаем через collab/Yjs-канал**, а не прямой перезаписью + jsonb-колонки — чтобы CRDT и снимок оставались согласованными и параллельные + правки людей не затирались. +- Комментарии-треды лежат в отдельной таблице `comments`; внутри контента живут + только *марки-якоря* (`span[data-comment-id]`). + +--- + +## 3. Комментарии — не синкаются, только якоря + +- Синк-движок **никогда не обращается к `/comments`** — ни на чтение, ни на + запись. +- В синхронизируемом файле **нет блока тредов**. Остаются только инлайновые + якоря `` внутри тела — чтобы подсветки + переживали round-trip и не терялись. +- Это отличие от `export_page_markdown` в `docmost-mcp` (тот кладёт треды): + синку нужен режим экспорта **«тело + якоря, без comments-блока»** (флаг + `includeCommentThreads: false`). +- Якоря — обычные марки внутри body. Если комментарий в Docmost + удалили/resolved, марка меняется → это легитимное изменение тела, приедет на + pull. Наверх такие вещи не пушим никогда (треды на сервере неприкосновенны). + +--- + +## 4. Формат файла + +Самодостаточный `.md` с кастомными Docmost-расширениями (как в +`markdown-document.ts` из `docmost-mcp`, но без comments-блока): + +```markdown + + +# Заголовок +Тело в Markdown с прокомментированным куском, +диаграммами (
), таблицами, callout'ами и т.д. +``` + +- Метаблок — HTML-комментарий (его выкидывает `marked`, в документ не протекает). +- `pageId` — стабильный якорь связи файл↔страница; по нему различаем move и + delete. +- Файл без `pageId` (новый файл от человека) → создать страницу, записать + присвоенный `pageId` обратно в meta. +- **Опциональные sync-маркеры в meta** (lastSyncedHash / lastSyncedUpdatedAt) — + НЕ обязательны при git-модели: базлайн держит git (§5). Идентичность (`pageId`) + — единственное, что обязано лежать в файле. + +--- + +## 5. State store = git + +Локальная папка — git-репозиторий. Базлайн, история, 3-way merge, +rename-detection, тумбстоны удалений и перенос между машинами — всё из git. + +### Модель веток +- **`main`** — то, что правит человек в ФС и куда вливаются изменения из Docmost. +- **`docmost`** — зеркало текущего состояния Docmost; пишет **только движок**, + руками не трогают (аналог `origin/main`). +- **`merge-base(main, docmost)`** — последняя точка совпадения сторон. Это и есть + state store; git ведёт её сам, отдельная БД базлайнов не нужна. +- **`refs/docmost/last-pushed`** — маркер «что из `main` уже отражено в Docmost» + (для направления ФС→Docmost). + +### Маппинг страница↔файл +- По `pageId` из meta. Перемещения путей разруливает git rename-detection, + сверяясь с `pageId`. +- Дерево Docmost (`parentPageId`) зеркалится в папки: + `Space/Родитель/Дочерняя.md`. Имя файла — из title (санитизация), но истина + связи — `pageId`, не путь. + +--- + +## 6. Циклы синхронизации (в терминах git) + +### Docmost → ФС (pull) +1. Изменилась страница (поллинг REST `list_pages` по `updatedAt`, позже — + websocket) → export (тело + якоря, **без комментариев**) → запись файла на + ветке `docmost` → commit `docmost: update "Title" (pageId)`. +2. `merge docmost → main`: git делает настоящий **3-way merge** от реального + merge-base. Непересекающиеся правки сливаются сами; настоящее пересечение → + конфликт-маркеры в файле (см. §9). +3. `git push` в remote. + +### ФС → Docmost (push) +1. Человек сохранил файл → (с дебаунсом) commit на `main` → `git push`. +2. diff `main` против `refs/docmost/last-pushed` → для каждого + added/modified/deleted/renamed транслируем в: + - modified → `import_page_markdown` (через collab-путь); + - added (нет pageId) → `create_page`, записать pageId в meta; + - deleted → `delete_page` (в Trash, обратимо, §8); + - renamed/moved → `move_page` / `rename_page`. +3. Двигаем `refs/docmost/last-pushed`; фастфорвардим `docmost` (Docmost это уже + содержит), записываем полученный `updatedAt` (§10). + +--- + +## 7. Политика «push в репу после каждого изменения» + +Коммит + `git push` в git-remote сразу после каждого *устаканившегося* изменения +с любой стороны. Обязательные оговорки: + +1. **Дебаунс, а не на каждый keystroke.** Коалесцировать быстрые правки за окно + тишины (N секунд), иначе история и сеть захлёбываются. Docmost и сам отдаёт + дебаунс-снимок, так что с его стороны это естественно. +2. **Push = pull-rebase-push с ретраем.** Если синкает больше одной машины — пуши + в git-remote конкурируют (non-fast-forward); нужен цикл + «подтянуть-перебазировать-запушить». Рекомендация: **один авторитетный демон + на воркспейс Docmost**, чтобы не писать в Docmost вдвоём. +3. **Провенанс в коммитах.** Разные committer-identity / трейлеры для `docmost:` + и `local:` — чтобы по истории была видна сторона и чтобы loop-guard отличал + свою запись от чужой. + +--- + +## 8. Удаления и перемещения + +Docmost имеет **Trash**: `delete_page` → `POST /pages/delete` — это soft-delete +(ставится `deletedAt`), страница лежит в корзине, восстанавливается, авто-чистка +через ~30 дней. Значит удаления **обратимы с обеих сторон**. + +- **Файл удалили локально** → `delete_page` (уезжает в Trash, не в небытие). + - Удалять только **отслеживаемые** файлы (был `pageId` в meta) — чтобы глюк + watcher'а / мусор не трактовался как удаление. + - Порог/подтверждение только на **массовое** удаление. +- **Страница в Trash в Docmost** → локальный файл удаляется коммитом на `main` + (восстановим из git-истории; опц. зеркалить в локальный `.trash/`). +- **Restore в Docmost** (сбросился `deletedAt`) → файл возвращается. +- **Move vs delete** различаем по `pageId`: страница со сменившимся + `parentPageId` всё ещё присутствует → это move (двигаем файл), а не delete. + +### Детекция удалений со стороны Docmost +Не ограничиваемся MCP — ходим в REST напрямую к эндпойнту корзины/restore (тому, +что дёргает фронт Docmost во вкладке Trash). Точный путь подтвердить из +Network-таба UI или из исходников pages-контроллера (TODO §12). Тогда детекция — +**точный запрос к trash-API** (видим `deletedAt`/restore), а не вывод «pageId +пропал из активного дерева». + +Симметрия двух корзин (git-история + Docmost Trash) делает синк удалений +безопасным. + +--- + +## 9. Конфликты: маркеры НИКОГДА не уезжают в Docmost + +При merge-конфликте `main`↔`docmost`: +- коммит с маркерами остаётся **в git** (бэкап на remote — ок); +- **push в Docmost для этой страницы блокируется** до ручного разрешения; +- конфликт показывается **локально** (git status / нотификация / conflict-копия); +- в Docmost разблокируем push только после чистого резолва. + +### Почему маркеры нельзя пушить в Docmost +Docmost — структурированный, общий и живой источник правды, а не текстовый файл: +1. **ProseMirror, не текст:** `<<<<<<<`/`=======`/`>>>>>>>` станут литеральными + абзацами, видимыми всем читателям. +2. **Дублирование:** конфликт-блок несёт обе версии → в живой документ попадают + оба противоречащих куска сразу. +3. **Порча структуры:** маркеры могут расколоть таблицу/callout/код-блок/ + span-якорь комментария → битые ноды, потерянные якоря. +4. **Laundering на round-trip:** запушенные маркеры вернутся следующим export как + «настоящий контент», другие могли поправить вокруг них — чисто разрешить уже + нельзя. +5. **Общий и живой:** локальное приватное «я ещё не решил» не должно мгновенно + протекать всем; до резолва в вики остаётся последний хороший контент. + +Инвариант: **в общий источник правды пишем только намеренные, разрешённые +состояния — никогда «мы ещё не определились».** + +--- + +## 10. Предотвращение петель (loop-guard) + +- Свою же запись файла watcher не должен принимать за правку человека: сравнение + **хэша тела** (не байтов файла) + провенанс коммита. +- После push в Docmost записать полученный `updatedAt`, чтобы следующий поллинг + не утянул собственную запись обратно как «удалённое изменение». +- Изменение считается «новым», только если отличается от последнего + синхронизированного коммита (git как референс). + +--- + +## 11. Жёсткий пререквизит: идемпотентность round-trip + +git диффает побайтово. Если export недетерминирован, каждый pull рожает ложный +дифф → бесконечные коммиты/конфликты. До включения авто-двустороннего режима: + +- **Block id'ы:** `markdownToProseMirror` сейчас их регенерирует. Решить одним из: + (а) вшивать block id'ы в md, либо (б) сравнивать нормализованную форму + (ProseMirror JSON со снятыми id), а не сырые байты. +- **Нормализация Markdown:** прогнать `export → import → export` на реальном + контенте и добиться **пустой** разницы (whitespace, экранирование, хвостовые + `\n` в код-блоках и т.п.). +- Сравнение состояний синка делать по **семантике** (канонизированный контент), + не по сырым байтам. + +Это **Задача №0** перед Фазой 2. + +--- + +## 12. Безопасность и эксплуатация + +- **git-remote = доступ ко всей вики.** Защищать не слабее Docmost; токены + Docmost / стейт с креденшелами в репу **не коммитить** (gitignore + внешний + secret store). +- Один авторитетный демон на воркспейс (см. §7.2). +- Идемпотентность и **возобновляемость**: операции должны быть идемпотентны, + повторный прогон синка — сходиться. При краше посреди push восстанавливаемся + сверкой `main` / `docmost` / реального состояния Docmost. + +### Открытые вопросы / TODO +- [ ] Точный REST-эндпойнт листинга корзины и restore в Docmost (§8). +- [ ] Модель commit-attribution (отдельные identity vs трейлеры) (§7.3). +- [ ] Эффективный «changes since T» для больших пространств (пагинация + `list_pages` vs другой механизм). +- [ ] Стратегия имён файлов при коллизиях title / спецсимволах. +- [ ] Поведение при >30 днях офлайна (авто-чистка Trash затирает копию). + +--- + +## 13. Компоненты (скелет) + +- **git repo** (vault): ветки `main` + `docmost`, ref `refs/docmost/last-pushed`. +- **Docmost→git:** детектор изменений/трэша (REST напрямую) → export + (тело+якоря) → commit на `docmost` → merge в `main` → push. +- **git→Docmost:** FS-watcher (chokidar) + дебаунс → commit на `main` → push → + diff против `last-pushed` → import/create/delete/move. +- **Conflict handler:** маркеры в git, Docmost-push страницы на паузе, + нотификация. +- **Loop-guard:** подавление self-write (хэш тела + провенанс), запись + `updatedAt` после push. +- **Converter:** `export_page_markdown(includeCommentThreads:false)` / + `import_page_markdown` из `docmost-mcp`. + +Стек: Node/TS, переиспользование `docmost-mcp` (DocmostClient, конвертер, +collab-write), `chokidar` (watch), прямой REST Docmost для корзины. + +--- + +## 14. План по фазам + +- **Фаза 0 — идемпотентность (§11).** Стабильный детерминированный round-trip. + Блокирует авто-двусторонний режим. +- **Фаза 1 — зеркало + ручной push (низкий риск).** `pull` всего пространства в + файлы по дереву (по `pageId`), ручной `push` правленых файлов, конфликт-копии, + поллинг. Тело двусторонне в ручном режиме, комментарии read-only. +- **Фаза 2 — непрерывный двусторонний git-режим.** Ветки `main`/`docmost`, + merge-base как базлайн, FS-watcher + поллинг Docmost, commit+push после каждого + устаканившегося изменения, conflict-gating, удаления через Trash + git. +- **Фаза 3 — realtime и доводка.** Подписка на Hocuspocus вместо поллинга, + git-история как UX конфликтов, опц. полная реконсиляция комментариев (если + когда-нибудь понадобится — сейчас явно вне scope). + +--- + +## 15. Зафиксированные решения (резюме) + +1. **State store = git**: две ветки `main`/`docmost`, `merge-base` как базлайн. +2. **Commit + push в git-remote после каждого устаканившегося изменения** с обеих + сторон (с дебаунсом, pull-rebase-push, один демон, провенанс). +3. **Комментарии не синкаются** ни в какую сторону; в файле — только якоря, без + тредов; `/comments` не дёргается. +4. **Конфликт-маркеры никогда не уезжают в Docmost**; push конфликтной страницы + блокируется до резолва. +5. **Удаления — через Docmost Trash** (soft, обратимо) + git-история; move/delete + различаем по `pageId`; детекция трэша — прямым REST мимо MCP. +6. **Запись в Docmost — через collab/Yjs-путь**, не прямой перезаписью jsonb. +7. **Идемпотентный round-trip — пререквизит (Задача №0)** до авто-режима. diff --git a/docmost-sync.code-workspace b/docmost-sync.code-workspace new file mode 100644 index 0000000..1b65e6f --- /dev/null +++ b/docmost-sync.code-workspace @@ -0,0 +1,29 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#7236ce", + "activityBar.background": "#7236ce", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#d3814c", + "activityBarBadge.foreground": "#15202b", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#7236ce", + "titleBar.activeBackground": "#5b29a8", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#5b29a899", + "titleBar.inactiveForeground": "#e7e7e799", + "statusBar.background": "#5b29a8", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#7236ce", + "statusBarItem.remoteBackground": "#5b29a8", + "statusBarItem.remoteForeground": "#e7e7e7" + }, + "peacock.color": "#5b29a8" + } +} \ No newline at end of file