docs: add project spec and VS Code workspace
This commit is contained in:
307
SPEC.md
Normal file
307
SPEC.md
Normal file
@@ -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`** — ни на чтение, ни на
|
||||||
|
запись.
|
||||||
|
- В синхронизируемом файле **нет блока тредов**. Остаются только инлайновые
|
||||||
|
якоря `<span data-comment-id="…">…</span>` внутри тела — чтобы подсветки
|
||||||
|
переживали round-trip и не терялись.
|
||||||
|
- Это отличие от `export_page_markdown` в `docmost-mcp` (тот кладёт треды):
|
||||||
|
синку нужен режим экспорта **«тело + якоря, без comments-блока»** (флаг
|
||||||
|
`includeCommentThreads: false`).
|
||||||
|
- Якоря — обычные марки внутри body. Если комментарий в Docmost
|
||||||
|
удалили/resolved, марка меняется → это легитимное изменение тела, приедет на
|
||||||
|
pull. Наверх такие вещи не пушим никогда (треды на сервере неприкосновенны).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Формат файла
|
||||||
|
|
||||||
|
Самодостаточный `.md` с кастомными Docmost-расширениями (как в
|
||||||
|
`markdown-document.ts` из `docmost-mcp`, но без comments-блока):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- docmost:meta
|
||||||
|
{"version":1,"pageId":"…","slugId":"…","title":"…","spaceId":"…","parentPageId":"…"}
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Заголовок
|
||||||
|
Тело в Markdown с <span data-comment-id="abc">прокомментированным</span> куском,
|
||||||
|
диаграммами (<div data-type="drawio" …>), таблицами, 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)** до авто-режима.
|
||||||
29
docmost-sync.code-workspace
Normal file
29
docmost-sync.code-workspace
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user