feat(footnotes): инлайновое авторство сносок + серверная канонизация (порядок/дедуп, без доступа агента к списку) #228

Closed
opened 2026-06-27 03:56:04 +03:00 by vvzvlad · 0 comments
Owner

Кратко

Сделать сноски авторскими «инлайн»: агент/инструмент вставляет сноску прямо в месте употребления (якорь + текст), а нумерация и список внизу выводятся детерминированно на стороне сервера. Агент при этом не имеет доступа к footnotesList и в принципе не может устроить рассинхрон. Плюс дедуп по содержимому (одинаковый текст сноски → один номер, одно определение, несколько ссылок).

Триггер: на странице docs.vvzvlad.xyz/.../...-NrQ7r7XrBt сноски в редакторе шли вразнобой (1, 4, 2, 3, 6, 5, 9, 8, 7…), плюс в конце висел сырой блок [^ccnet]: …, [^perplexity-…], [^brave-…], который никуда не сконвертировался, плюс сирота-определение без ссылки. Это не баг рендера — это следствие того, как контент записали мимо канонизатора.

Корневая причина (по коду)

Модель сносок (editor-ext + MCP-схема):

  • footnoteReference{ attrs.id } — инлайн-атом в теле; footnoteDefinition{ attrs.id, paragraph+ } внутри одного блока footnotesList. Связь — по attrs.id. См. packages/mcp/src/lib/docmost-schema.ts и packages/editor-ext/src/lib/footnote/*.
  • Номер нигде не хранится — он выводится из порядка появления ссылок в документе: computeFootnoteNumbers() / collectReferenceIds() в packages/editor-ext/src/lib/footnote/footnote-util.ts, а рисуется декорациями в footnote-numbering.ts (документ не мутируется).
  • Канонический инвариант (список в порядке ссылок, ровно один footnotesList после контента, сироты выброшены, дубликаты-определения переименованы и никогда не теряются, переиспользование одного id → один номер) поддерживает footnoteSyncPlugin в packages/editor-ext/src/lib/footnote/footnote-sync.ts.

Проблема: footnoteSyncPlugin — это appendTransaction редактора, он работает только в живом ProseMirror EditorView. А все серверные записи (update_page_json, docmost_transform, import_page_markdown в packages/mcp, а также collab-запись в git-sync) идут через TiptapTransformer/updateYFragmentплагины редактора при этом не запускаются, и канонизация не происходит. Дополнительно импорт markdown собирает список в порядке определений в markdown, а не в порядке ссылок: extractFootnotes() в packages/mcp/src/lib/collaboration.ts (итерируется firstById в порядке вставки). Отсюда «номера вразнобой».

Сырой блок [^id]: … в конце — это определения, дописанные текстовым редактированием (минуя инпут-правило [^...] и extractFootnotes), поэтому они остались литералом, а их инлайн-маркеры [^id] в теле — обычным текстом.

Что предлагается

1. Чистый канонизатор canonicalizeFootnotes(docJSON) -> docJSON

Серверный порт «желаемого end-state» из footnoteSyncPlugin, но как чистая функция над ProseMirror-JSON (без EditorView/транзакций), идемпотентная и детерминированная:

  1. собрать id ссылок в порядке документа (collectReferenceIds);
  2. построить список определений: по одному на упомянутый id, в порядке ссылок, переиспользуя существующий узел определения или создавая пустой; сироты (определение без ссылки) — выбросить;
  3. ровно один footnotesList, размещён после значимого контента (перед хвостовыми пустыми параграфами); лишние списки — слить;
  4. дубликаты-определения переименовать детерминированно (deriveFootnoteId), никогда не терять (как в редакторе);
  5. переиспользование (один id в нескольких ссылках) → один номер/одно определение.

Единый источник истины: разместить функцию в @docmost/editor-ext, и footnoteSyncPlugin должен использовать её же (адаптер транзакции поверх чистого end-state). Golden-тест: canonicalizeFootnotes(doc) ≡ результат footnoteSyncPlugin на том же документе для всего тест-корпуса editor-ext — чтобы серверный вывод был байт-в-байт тем, что сделал бы редактор.

Куда воткнуть на стороне MCP:

  • прогонять canonicalizeFootnotes в конце markdownToProseMirror() (packages/mcp/src/lib/collaboration.ts) — это сразу чинит «вразнобой» для всех будущих импортов;
  • экспонировать как хелпер песочницы docmost_transform рядом с commentsToFootnotes (packages/mcp/src/lib/transforms.ts, регистрация в index.ts).

2. Высокоуровневый инструмент «вставить сноску» (агент не трогает список)

Новый MCP-тул (рабочее имя insert_footnote): spec в packages/mcp/src/tool-specs.ts, хендлер в packages/mcp/src/index.ts, метод клиента в client.ts поверх mutatePageContent. Параметры:

  • pageId;
  • anchorText (или anchorNodeId) — куда поставить маркер; переиспользуем механику insertMarkerAfter (packages/mcp/src/lib/transforms.ts), уже mark-safe;
  • text — содержимое сноски (markdown → инлайн-узлы через mdToInlineNodes).

Поведение: вычислить id (см. дедуп ниже), вставить footnoteReference{id} у якоря, создать/переиспользовать footnoteDefinition, прогнать canonicalizeFootnotes, записать. Агент не задаёт id, не видит и не редактирует footnotesList — список генерируется. Сироты/перепутанный порядок/сырой markdown становятся структурно невозможны.

3. Дедуп по содержимому

Ключ дедупа = нормализованный текст определения (плейнтекст + сигнатура инлайн-марок). При вставке: если у существующего определения ключ совпадает — переиспользовать его id (новая ссылка указывает на него), иначе создать новое определение со свежим uuid-id. Итог: одинаковый текст → один номер, несколько ссылок (это ровно «reuse»-семантика, которую система уже поддерживает). Консервативно: только точное совпадение.

4. (Ремонт) свёртка сырого markdown в нативные сноски

Опционально — режим/тул normalize_footnotes, который сворачивает оставшиеся текстовые [^id] маркеры и хвостовые [^id]: … строки в нативные узлы (через тот же лексер footnote-lex.ts/extractFootnotes), затем канонизирует. Это и чинит уже существующий мусор, и предотвращает будущий. (Чинить конкретную страницу из триггера — вне рамок этого issue.)

Совместимость (чтобы ничего не сломать)

  • Форма узлов не меняется и номер не сохраняется в атрибутах. Хранимый номер сломал бы выводимую нумерацию, экспорт в markdown и декорации редактора — не делаем.
  • Markdown-сериализация без изменений: [^id] / [^id]: text, ключ — id (packages/mcp/src/lib/markdown-converter.ts). Сноски в markdown адресуются по id, порядок при реимпорте неважен → переупорядочивание списка безопасно для round-trip. (Побочно: экспортированный markdown поменяет порядок строк определений — это разовая нормализация, не поломка.)
  • Импорт-инварианты сохранить: extractFootnotes (first-wins по дублям, fence-aware) и диагностика analyzeFootnotes (footnote-analyze.ts) остаются как есть; канонизатор работает после извлечения и только переупорядочивает/дедупит. Тесты packages/mcp/test/unit/footnotes.test.mjs и footnote-warnings-import.test.mjs должны остаться зелёными.
  • PR #119 / git-sync — не затрагивается. Вендорный конвертер packages/git-sync/build/lib/markdown-converter.js вообще не обрабатывает узлы сносок (нет case "footnoteReference"/"footnotesList"/"footnoteDefinition"), а canonicalize.js их игнорирует — ломать там нечего. Поддержка сносок в git-sync — отдельная будущая работа; когда появится, она должна переиспользовать тот же canonicalizeFootnotes. Фича живёт в слоях @docmost/editor-ext и packages/mcp, файлы PR #119 (apps/server/src/integrations/git-sync/*) не трогает.
  • diff-превью docmost_transform использует footnoteMarkers (packages/mcp/src/lib/diff.ts) — переупорядочивание отобразится как изменение маркеров; это ожидаемо.
  • Запись по-прежнему через mutatePageContent/collab (single-writer, page-lock) — путь персистенса не меняется.

Фазы

  • Фаза 1. Чистый canonicalizeFootnotes в @docmost/editor-ext + golden-тест против footnoteSyncPlugin. Подключить в markdownToProseMirror (импорт) и как хелпер docmost_transform. → чинит «вразнобой» для импортов, закладывает фундамент.
  • Фаза 2. Тул insert_footnote (якорь + текст → ref+def, канонизация), без доступа агента к списку. + дедуп по содержимому.
  • Фаза 3. Свёртка сырого [^id]/[^id]: … в нативные сноски (normalize_footnotes), и авто-прогон канонизатора во всех путях записи — решение принято, см. ниже.
  • Фаза 4 (отдельно). Поддержка сносок в git-sync-конвертере поверх того же канонизатора.

Критерии приёмки

  • Вставка N сносок в произвольном порядке → список рендерится 1..N в порядке ссылок; экспорт markdown стабилен; реимпорт идемпотентен.
  • Одинаковое содержимое → один номер.
  • Сироты-определения выброшены; дубликаты-определения сохранены (переименованы) — поведение совпадает с редактором.
  • Golden: canonicalizeFootnotes(doc)footnoteSyncPlugin(doc) на тест-корпусе editor-ext.
  • Существующие тесты остаются зелёными: footnotes.test.mjs, footnote-warnings-import.test.mjs, footnote.marked.orphan.test.ts.
  • git-sync converter-gate (PR #119) не затронут.

Решение (принято): авто-канонизация во всех путях записи

canonicalizeFootnotes прогоняется автоматически во всех путях записиimport_page_markdown, update_page_json, docmost_transform-apply и новый тул insert_footnote. Так сломанные сноски нельзя оставить никаким способом записи.

Это дёшево, потому что канонизатор идемпотентен: если сноски уже каноничны — он no-op и документ не меняет (никаких «лишних» мутаций и git-sync-событий на ровном месте). Реальная правка происходит только когда есть что чинить.

Единственный нюанс: в dryRun-превью docmost_transform могут всплывать правки сносок, которые автор скрипта сам не писал (их подчистил канонизатор). Это ожидаемо; стоит помечать такие правки в превью отдельно (напр. «footnote canonicalization») для прозрачности.

## Кратко Сделать сноски **авторскими «инлайн»**: агент/инструмент вставляет сноску прямо в месте употребления (якорь + текст), а нумерация и список внизу **выводятся детерминированно** на стороне сервера. Агент при этом **не имеет доступа к `footnotesList`** и в принципе не может устроить рассинхрон. Плюс **дедуп по содержимому** (одинаковый текст сноски → один номер, одно определение, несколько ссылок). Триггер: на странице `docs.vvzvlad.xyz/.../...-NrQ7r7XrBt` сноски в редакторе шли вразнобой (`1, 4, 2, 3, 6, 5, 9, 8, 7…`), плюс в конце висел сырой блок `[^ccnet]: …`, `[^perplexity-…]`, `[^brave-…]`, который никуда не сконвертировался, плюс сирота-определение без ссылки. Это не баг рендера — это следствие того, как контент **записали** мимо канонизатора. ## Корневая причина (по коду) Модель сносок (editor-ext + MCP-схема): - `footnoteReference{ attrs.id }` — инлайн-атом в теле; `footnoteDefinition{ attrs.id, paragraph+ }` внутри одного блока `footnotesList`. Связь — по `attrs.id`. См. `packages/mcp/src/lib/docmost-schema.ts` и `packages/editor-ext/src/lib/footnote/*`. - **Номер нигде не хранится** — он выводится из порядка появления ссылок в документе: `computeFootnoteNumbers()` / `collectReferenceIds()` в `packages/editor-ext/src/lib/footnote/footnote-util.ts`, а рисуется декорациями в `footnote-numbering.ts` (документ не мутируется). - Канонический инвариант (список в порядке ссылок, ровно один `footnotesList` после контента, сироты выброшены, дубликаты-определения переименованы и **никогда не теряются**, переиспользование одного id → один номер) поддерживает `footnoteSyncPlugin` в `packages/editor-ext/src/lib/footnote/footnote-sync.ts`. Проблема: `footnoteSyncPlugin` — это `appendTransaction` редактора, он работает **только в живом ProseMirror EditorView**. А все серверные записи (`update_page_json`, `docmost_transform`, `import_page_markdown` в `packages/mcp`, а также collab-запись в git-sync) идут через `TiptapTransformer`/`updateYFragment` — **плагины редактора при этом не запускаются**, и канонизация не происходит. Дополнительно импорт markdown собирает список в порядке определений в markdown, а не в порядке ссылок: `extractFootnotes()` в `packages/mcp/src/lib/collaboration.ts` (итерируется `firstById` в порядке вставки). Отсюда «номера вразнобой». Сырой блок `[^id]: …` в конце — это определения, дописанные текстовым редактированием (минуя инпут-правило `[^...]` и `extractFootnotes`), поэтому они остались литералом, а их инлайн-маркеры `[^id]` в теле — обычным текстом. ## Что предлагается ### 1. Чистый канонизатор `canonicalizeFootnotes(docJSON) -> docJSON` Серверный порт «желаемого end-state» из `footnoteSyncPlugin`, но как **чистая функция над ProseMirror-JSON** (без EditorView/транзакций), идемпотентная и детерминированная: 1. собрать id ссылок в порядке документа (`collectReferenceIds`); 2. построить список определений: по одному на упомянутый id, **в порядке ссылок**, переиспользуя существующий узел определения или создавая пустой; сироты (определение без ссылки) — выбросить; 3. ровно один `footnotesList`, размещён после значимого контента (перед хвостовыми пустыми параграфами); лишние списки — слить; 4. дубликаты-определения переименовать детерминированно (`deriveFootnoteId`), **никогда не терять** (как в редакторе); 5. переиспользование (один id в нескольких ссылках) → один номер/одно определение. **Единый источник истины:** разместить функцию в `@docmost/editor-ext`, и `footnoteSyncPlugin` должен использовать **её же** (адаптер транзакции поверх чистого end-state). Golden-тест: `canonicalizeFootnotes(doc)` ≡ результат `footnoteSyncPlugin` на том же документе для всего тест-корпуса editor-ext — чтобы серверный вывод был **байт-в-байт** тем, что сделал бы редактор. Куда воткнуть на стороне MCP: - прогонять `canonicalizeFootnotes` в конце `markdownToProseMirror()` (`packages/mcp/src/lib/collaboration.ts`) — **это сразу чинит «вразнобой» для всех будущих импортов**; - экспонировать как хелпер песочницы `docmost_transform` рядом с `commentsToFootnotes` (`packages/mcp/src/lib/transforms.ts`, регистрация в `index.ts`). ### 2. Высокоуровневый инструмент «вставить сноску» (агент не трогает список) Новый MCP-тул (рабочее имя `insert_footnote`): spec в `packages/mcp/src/tool-specs.ts`, хендлер в `packages/mcp/src/index.ts`, метод клиента в `client.ts` поверх `mutatePageContent`. Параметры: - `pageId`; - `anchorText` (или `anchorNodeId`) — куда поставить маркер; переиспользуем механику `insertMarkerAfter` (`packages/mcp/src/lib/transforms.ts`), уже mark-safe; - `text` — содержимое сноски (markdown → инлайн-узлы через `mdToInlineNodes`). Поведение: вычислить id (см. дедуп ниже), вставить `footnoteReference{id}` у якоря, создать/переиспользовать `footnoteDefinition`, прогнать `canonicalizeFootnotes`, записать. **Агент не задаёт id, не видит и не редактирует `footnotesList`** — список генерируется. Сироты/перепутанный порядок/сырой markdown становятся структурно невозможны. ### 3. Дедуп по содержимому Ключ дедупа = нормализованный текст определения (плейнтекст + сигнатура инлайн-марок). При вставке: если у существующего определения ключ совпадает — **переиспользовать его id** (новая ссылка указывает на него), иначе создать новое определение со свежим uuid-id. Итог: одинаковый текст → один номер, несколько ссылок (это ровно «reuse»-семантика, которую система уже поддерживает). Консервативно: только точное совпадение. ### 4. (Ремонт) свёртка сырого markdown в нативные сноски Опционально — режим/тул `normalize_footnotes`, который сворачивает оставшиеся текстовые `[^id]` маркеры и хвостовые `[^id]: …` строки в нативные узлы (через тот же лексер `footnote-lex.ts`/`extractFootnotes`), затем канонизирует. Это и чинит уже существующий мусор, и предотвращает будущий. (Чинить конкретную страницу из триггера — вне рамок этого issue.) ## Совместимость (чтобы ничего не сломать) - **Форма узлов не меняется** и **номер не сохраняется** в атрибутах. Хранимый номер сломал бы выводимую нумерацию, экспорт в markdown и декорации редактора — не делаем. - **Markdown-сериализация без изменений**: `[^id]` / `[^id]: text`, ключ — `id` (`packages/mcp/src/lib/markdown-converter.ts`). Сноски в markdown адресуются по id, порядок при реимпорте неважен → **переупорядочивание списка безопасно для round-trip**. (Побочно: экспортированный markdown поменяет порядок строк определений — это разовая нормализация, не поломка.) - **Импорт-инварианты сохранить**: `extractFootnotes` (first-wins по дублям, fence-aware) и диагностика `analyzeFootnotes` (`footnote-analyze.ts`) остаются как есть; канонизатор работает **после** извлечения и только переупорядочивает/дедупит. Тесты `packages/mcp/test/unit/footnotes.test.mjs` и `footnote-warnings-import.test.mjs` должны остаться зелёными. - **PR #119 / git-sync — не затрагивается.** Вендорный конвертер `packages/git-sync/build/lib/markdown-converter.js` **вообще не обрабатывает узлы сносок** (нет `case "footnoteReference"/"footnotesList"/"footnoteDefinition"`), а `canonicalize.js` их игнорирует — ломать там нечего. Поддержка сносок в git-sync — отдельная будущая работа; когда появится, она должна переиспользовать тот же `canonicalizeFootnotes`. Фича живёт в слоях `@docmost/editor-ext` и `packages/mcp`, файлы PR #119 (`apps/server/src/integrations/git-sync/*`) не трогает. - **diff-превью** `docmost_transform` использует `footnoteMarkers` (`packages/mcp/src/lib/diff.ts`) — переупорядочивание отобразится как изменение маркеров; это ожидаемо. - **Запись по-прежнему через `mutatePageContent`/collab** (single-writer, page-lock) — путь персистенса не меняется. ## Фазы - **Фаза 1.** Чистый `canonicalizeFootnotes` в `@docmost/editor-ext` + golden-тест против `footnoteSyncPlugin`. Подключить в `markdownToProseMirror` (импорт) и как хелпер `docmost_transform`. → чинит «вразнобой» для импортов, закладывает фундамент. - **Фаза 2.** Тул `insert_footnote` (якорь + текст → ref+def, канонизация), без доступа агента к списку. + дедуп по содержимому. - **Фаза 3.** Свёртка сырого `[^id]`/`[^id]: …` в нативные сноски (`normalize_footnotes`), и **авто-прогон канонизатора во всех путях записи** — решение принято, см. ниже. - **Фаза 4 (отдельно).** Поддержка сносок в git-sync-конвертере поверх того же канонизатора. ## Критерии приёмки - Вставка N сносок в произвольном порядке → список рендерится `1..N` в порядке ссылок; экспорт markdown стабилен; реимпорт идемпотентен. - Одинаковое содержимое → один номер. - Сироты-определения выброшены; дубликаты-определения сохранены (переименованы) — поведение совпадает с редактором. - Golden: `canonicalizeFootnotes(doc)` ≡ `footnoteSyncPlugin(doc)` на тест-корпусе editor-ext. - Существующие тесты остаются зелёными: `footnotes.test.mjs`, `footnote-warnings-import.test.mjs`, `footnote.marked.orphan.test.ts`. - git-sync converter-gate (PR #119) не затронут. ## Решение (принято): авто-канонизация во всех путях записи `canonicalizeFootnotes` прогоняется **автоматически во всех путях записи** — `import_page_markdown`, `update_page_json`, `docmost_transform`-apply и новый тул `insert_footnote`. Так сломанные сноски нельзя оставить никаким способом записи. Это дёшево, потому что канонизатор **идемпотентен**: если сноски уже каноничны — он no-op и документ не меняет (никаких «лишних» мутаций и git-sync-событий на ровном месте). Реальная правка происходит только когда есть что чинить. Единственный нюанс: в `dryRun`-превью `docmost_transform` могут всплывать правки сносок, которые автор скрипта сам не писал (их подчистил канонизатор). Это ожидаемо; стоит помечать такие правки в превью отдельно (напр. «footnote canonicalization») для прозрачности.
vvzvlad added the featureenhancement labels 2026-06-27 03:56:04 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#228