feat(footnotes): инлайновое авторство сносок + серверная канонизация (порядок/дедуп, без доступа агента к списку) #228
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Кратко
Сделать сноски авторскими «инлайн»: агент/инструмент вставляет сноску прямо в месте употребления (якорь + текст), а нумерация и список внизу выводятся детерминированно на стороне сервера. Агент при этом не имеет доступа к
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/транзакций), идемпотентная и детерминированная:collectReferenceIds);footnotesList, размещён после значимого контента (перед хвостовыми пустыми параграфами); лишние списки — слить;deriveFootnoteId), никогда не терять (как в редакторе);Единый источник истины: разместить функцию в
@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.)Совместимость (чтобы ничего не сломать)
[^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должны остаться зелёными.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/*) не трогает.docmost_transformиспользуетfootnoteMarkers(packages/mcp/src/lib/diff.ts) — переупорядочивание отобразится как изменение маркеров; это ожидаемо.mutatePageContent/collab (single-writer, page-lock) — путь персистенса не меняется.Фазы
canonicalizeFootnotesв@docmost/editor-ext+ golden-тест противfootnoteSyncPlugin. Подключить вmarkdownToProseMirror(импорт) и как хелперdocmost_transform. → чинит «вразнобой» для импортов, закладывает фундамент.insert_footnote(якорь + текст → ref+def, канонизация), без доступа агента к списку. + дедуп по содержимому.[^id]/[^id]: …в нативные сноски (normalize_footnotes), и авто-прогон канонизатора во всех путях записи — решение принято, см. ниже.Критерии приёмки
1..Nв порядке ссылок; экспорт markdown стабилен; реимпорт идемпотентен.canonicalizeFootnotes(doc)≡footnoteSyncPlugin(doc)на тест-корпусе editor-ext.footnotes.test.mjs,footnote-warnings-import.test.mjs,footnote.marked.orphan.test.ts.Решение (принято): авто-канонизация во всех путях записи
canonicalizeFootnotesпрогоняется автоматически во всех путях записи —import_page_markdown,update_page_json,docmost_transform-apply и новый тулinsert_footnote. Так сломанные сноски нельзя оставить никаким способом записи.Это дёшево, потому что канонизатор идемпотентен: если сноски уже каноничны — он no-op и документ не меняет (никаких «лишних» мутаций и git-sync-событий на ровном месте). Реальная правка происходит только когда есть что чинить.
Единственный нюанс: в
dryRun-превьюdocmost_transformмогут всплывать правки сносок, которые автор скрипта сам не писал (их подчистил канонизатор). Это ожидаемо; стоит помечать такие правки в превью отдельно (напр. «footnote canonicalization») для прозрачности.Ghost referenced this issue2026-06-27 17:11:59 +03:00
Ghost referenced this issue2026-06-27 21:24:34 +03:00
Ghost referenced this issue2026-06-27 21:42:23 +03:00
Ghost referenced this issue2026-06-27 22:06:07 +03:00
Ghost referenced this issue2026-06-27 23:27:00 +03:00
Ghost referenced this issue2026-06-28 22:18:11 +03:00