[feature][footnotes] Reuse сносок (много ссылок → одна сноска) + предупреждения о пустых/битых сносках при импорте #166

Closed
opened 2026-06-24 15:09:55 +03:00 by Ghost · 0 comments

Контекст

При генерации статьи агентом через create_page сноски ([^id]) получились битыми: внизу страницы появилось ~70 пустых определений, а повторно использованные ярлыки превратились в kowiki__2 … kowiki__14. Разбор показал, что это не баг конвейера, а сочетание модели «строго 1:1» и ошибок авторской разметки. Эта задача меняет модель так, как «привыкли агенты» (Pandoc-семантика), и добавляет диагностику.

Как работает сейчас (по коду)

  1. Импорт Markdown → ProseMirror. Определения сносок собираются только из строк [^id]: текст:

    • MCP: extractFootnotes()packages/mcp/src/lib/collaboration.ts:380
    • editor-ext: extractFootnoteDefinitions()packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts:66

    Inline-токенайзер превращает любой [^id] (в т.ч. в ячейках таблиц) в footnoteReference.

  2. Интеграционный проход редактора footnoteSyncPlugin / resolveCollisionspackages/editor-ext/src/lib/footnote/footnote-sync.ts:119:

    • переименовывает каждый повтор ссылки [^a]a__2, a__3 через deriveFootnoteId() (footnote-util.ts:70);
    • на каждую ссылку без определения синтезирует пустое определение (footnote-sync.ts:449-451).
  3. Нумерация уже поддерживает reuse: computeFootnoteNumbers() (footnote-util.ts:121) дедуплицирует по id, а buildFootnoteNumberingState() (footnote-numbering.ts:51) вешает один номер на все ссылки с этим id и на определение. Единственное, что мешает reuse — переименование дублей в resolveCollisions.

Важно: разные пути конверсии

  • create_page (client.ts:972) грузит сырой Markdown на серверный /pages/import → конверсия и резолв коллизий идут на сервере (сервер использует тот же FootnoteReference из editor-ext — apps/server/src/collaboration/collaboration.util.ts:121).
  • update_page / import_page_markdown / комментарии идут через MCP markdownToProseMirror() (collaboration.ts:452).

Вывод: логика reuse живёт в общем модуле editor-ext/footnote (его используют клиент, сервер и MCP-зеркало), а предупреждения считаются отдельным текстовым сканом в MCP — он не зависит от пути конверсии и работает одинаково для всех инструментов.


Цели

  1. Reuse: несколько [^id] ссылаются на одну сноску (как в Pandoc) — один номер, одно определение, без __N-пустышек.
  2. Диагностика: при импорте возвращать в результат инструмента предупреждения о проблемных сносках. Документ создаётся всегда; молча пустышки не плодим.
  3. UX редактора: корректная вставка (paste) при reuse и мульти-бэклинки (одно определение → возврат к каждой из своих ссылок).

Зафиксированные дизайн-решения

  • Дубликат определения (две строки [^a]: …): первое выигрывает, остальные отбрасываются + предупреждение (на агентском пути импорта). В живом редакторе сохраняем защитный принцип «никогда не терять данные молча».
  • Предупреждать обо всех 4 проблемах: dangling reference, пустой текст определения, дубликат определения, метки [^…] в таблицах.
  • UX: делаем и paste-reuse, и мульти-бэклинки.

Реализация

Фича 1 — Reuse (ядро)

packages/editor-ext/src/lib/footnote/footnote-sync.tsresolveCollisions()

  • Перестать минтить новые id для дубль-ссылок. referenceIds = различные id ссылок в порядке первого появления; убрать ветку, добавляющую refReids для повторов ссылок; seenRefIds лишь дедуплицирует.
  • На каждый различный id ссылки — ровно одно определение (существующее или, если нет, пустое — теперь это «пустая сноска», диагностируется Фичей 2).
  • Дубль-определения (один id у двух def-узлов): сохранить текущую защиту от потери данных в живом редакторе (re-id лишнего определения детерминированным deriveFootnoteId, оно становится сиротой по существующей orphan-политике). Это путь paste/collab, не импорт.
  • deriveFootnoteId и его golden-table тест (footnote-util.derive-id.test.ts) остаются — нужны для дубль-определений и paste-плагина.
  • Псевдокод нового цикла по ссылкам:
    for (const ref of scan.refOccurrences) {
      if (!seenRefIds.has(ref.id)) { seenRefIds.add(ref.id); referenceIds.push(ref.id); }
      // повтор того же id — это reuse: НИЧЕГО не делаем (без mintId/refReids)
    }
    
  • Инвариант сохраняется: один footnotesList, одно определение на различный id, сироты (id без ссылок) дропаются как раньше. Детерминизм Yjs только улучшается (для ссылок минтинга больше нет).

Зеркала импорта (first-wins для дубль-определений):

  • packages/editor-ext/src/lib/markdown/utils/footnote.marked.tsextractFootnoteDefinitions():
    • убрать переписывание маркеров ссылок (оно ломало reuse);
    • дубль-def: оставить первое определение для id, остальные отбросить, не эмитить d__2/d__3;
    • повторные [^a]-маркеры оставить как есть (все a).
  • packages/mcp/src/lib/collaboration.tsextractFootnotes(): те же изменения (держать в синхроне с editor-ext — есть явный комментарий «MUST stay in sync»).

Фича 2 — Предупреждения

Новая чистая функция analyzeFootnotes(markdown) (MCP, напр. packages/mcp/src/lib/footnote-analyze.ts)

Fence-aware скан (как в extractFootnotes), возвращает:

interface FootnoteDiagnostics {
  danglingReferences: string[];   // id ссылок без определения
  emptyDefinitions: string[];     // [^id]: с пустым текстом
  duplicateDefinitions: string[]; // id, определённый >= 2 раз
  referencesInTables: string[];   // [^id] в строках GFM-таблицы (trim начинается с '|')
  warnings: string[];             // человекочитаемые строки для ответа инструмента
}

Правила:

  • собрать все id ссылок (исключая строки внутри ``` / ~~~) и все id определений;
  • danglingReferences = ссылки, для которых нет определения;
  • duplicateDefinitions = id с >= 2 def-строками;
  • emptyDefinitions = def-строки с пустым (.*) после :;
  • referencesInTables = [^id] найден в строке, чей trimStart() начинается с | (эвристика, помечается как таковая в тексте предупреждения).

Плумбинг — поле footnoteWarnings: string[] в результат:

  • client.createPage() (client.ts:972): вызвать analyzeFootnotes(content), приложить к возвращаемому объекту (после getPage).
  • client.updatePage() (client.ts:1065): то же по content.
  • client.importPageMarkdown() (client.ts:1378): по body; уже есть поле warning — добавить рядом footnoteWarnings.
  • Обработчики в index.ts (create_page — 290, import_page_markdown — 367) прокидывают результат как есть (jsonContent), правок их сигнатур не требуется.
  • Комментарии (createComment/updateComment) — вне объёма (сноски в комментариях нетипичны).

Фича 3 — UX редактора

paste-reuse — footnote-sync.tsfootnotePastePlugin() (:557)

  • Сейчас регенерирует все конфликтующие id (refs + defs).
  • Новое: регенерировать id только если во вставляемом слайсе есть определение с конфликтующим id (чтобы не затереть текст существующей сноски). Если в слайсе только ссылка(и) на уже существующий id — оставить id (reuse).
  • Реализация: при сборе remap учитывать, есть ли в слайсе footnoteDefinition с этим id; ремапить только такие id (и парные ссылки), одиночные ссылки не трогать.

мульти-бэклинки

  • Helper нумерации: добавить в footnote-numbering.ts/footnote-util.ts карту id -> reference occurrences (порядок/счётчик), кэшируемую плагином рядом с numbers. Экспорт getter getFootnoteRefCount(state, id) (или список индексов).
  • Команда: расширить scrollToReference (footnote-reference.ts:318) до scrollToReference(id, index?)querySelectorAll('sup[data-footnote-ref][data-id="…"]')[index].
  • apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx: при числе ссылок > 1 рендерить ↩ + подметки a, b, c…, каждая ведёт к своей occurrence; при одной — текущее поведение (один ↩).
  • CSS: apps/client/src/features/editor/components/footnote/footnote.module.css — раскладка нескольких бэклинков.

Затрагиваемые файлы (чеклист)

  • packages/editor-ext/src/lib/footnote/footnote-sync.ts — reuse в resolveCollisions, paste-reuse в footnotePastePlugin
  • packages/editor-ext/src/lib/markdown/utils/footnote.marked.tsextractFootnoteDefinitions: убрать rewrite маркеров, first-wins для дубль-def
  • packages/mcp/src/lib/collaboration.tsextractFootnotes: зеркальные правки
  • packages/mcp/src/lib/footnote-analyze.ts — новый analyzeFootnotes()
  • packages/mcp/src/client.tsfootnoteWarnings в createPage / updatePage / importPageMarkdown
  • packages/editor-ext/src/lib/footnote/footnote-numbering.ts (+ footnote-util.ts) — карта occurrences по id
  • packages/editor-ext/src/lib/footnote/footnote-reference.tsscrollToReference(id, index?)
  • apps/client/.../footnote/footnote-definition-view.tsx — мульти-бэклинки
  • apps/client/.../footnote/footnote.module.css — стили бэклинков

Тесты

  • footnote.test.ts — переписать проверки переименования дубль-ссылок на reuse; добавить кейсы: N ссылок + 1 def → один номер/одно определение; ссылка без def → одна пустая сноска (не N штук)
  • footnote.marked.orphan.test.ts — обновить на first-wins (один def d, дубликаты отброшены)
  • новый тест analyzeFootnotes — 4 типа диагностик + fence-aware
  • paste-reuse тест — вставка одиночной ссылки на существующий id сохраняет id; вставка пары ref+def с конфликтом — регенерирует
  • footnote-views.structure.test.tsx — мульти-бэклинки рендерятся по числу ссылок
  • серверный round-trip (apps/server/.../footnote-superscript-roundtrip.spec.ts) — не сломан

Открытые пункты для проверки на этапе реализации

  • Точно подтвердить, где для create_page (серверный импорт) синтезируются пустышки и сейчас происходит __N: серверный pipeline импорта vs первое открытие в коллаб-редакторе. Рычаг всё равно resolveCollisions, но надо убедиться, что серверный путь его реально исполняет (а не только строит схему через generateJSON).
  • Расхождение поведения «импорт = first-wins+warn» vs «редактор = never-lose» для дубль-определений зафиксировать комментариями в коде.

Критерии приёмки (DoD)

  • N маркеров [^a] + одно [^a]: текст → одна сноска, один номер, N кликабельных ссылок, одно определение; никаких a__2.
  • Ссылка без определения → ровно одна пустая сноска (как и раньше создаётся), но create_page/update_page/import_page_markdown возвращают footnoteWarnings с указанием id.
  • Дубликат определения → используется первое, в footnoteWarnings есть запись.
  • [^…] в таблице → попадает в footnoteWarnings (referencesInTables).
  • В редакторе: вставка ссылки на существующую сноску делает reuse; определение показывает возврат ко всем своим ссылкам.
  • Все существующие и новые тесты зелёные; serverный round-trip сохранён.

Issue подготовлен по результатам разбора бага со сносками (статья про чокопай). Это задача-проектирование; код не менялся.

## Контекст При генерации статьи агентом через `create_page` сноски (`[^id]`) получились битыми: внизу страницы появилось ~70 **пустых** определений, а повторно использованные ярлыки превратились в `kowiki__2 … kowiki__14`. Разбор показал, что это не баг конвейера, а сочетание модели «строго 1:1» и ошибок авторской разметки. Эта задача меняет модель так, как «привыкли агенты» (Pandoc-семантика), и добавляет диагностику. ### Как работает сейчас (по коду) 1. **Импорт Markdown → ProseMirror.** Определения сносок собираются только из строк `[^id]: текст`: - MCP: `extractFootnotes()` — `packages/mcp/src/lib/collaboration.ts:380` - editor-ext: `extractFootnoteDefinitions()` — `packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts:66` Inline-токенайзер превращает **любой** `[^id]` (в т.ч. в ячейках таблиц) в `footnoteReference`. 2. **Интеграционный проход редактора** `footnoteSyncPlugin` / `resolveCollisions` — `packages/editor-ext/src/lib/footnote/footnote-sync.ts:119`: - переименовывает **каждый повтор ссылки** `[^a]` → `a__2`, `a__3` через `deriveFootnoteId()` (`footnote-util.ts:70`); - на каждую ссылку без определения **синтезирует пустое** определение (`footnote-sync.ts:449-451`). 3. **Нумерация** уже поддерживает reuse: `computeFootnoteNumbers()` (`footnote-util.ts:121`) дедуплицирует по id, а `buildFootnoteNumberingState()` (`footnote-numbering.ts:51`) вешает один номер на все ссылки с этим id и на определение. **Единственное, что мешает reuse — переименование дублей в `resolveCollisions`.** ### Важно: разные пути конверсии - `create_page` (`client.ts:972`) грузит **сырой Markdown** на серверный `/pages/import` → конверсия и резолв коллизий идут **на сервере** (сервер использует тот же `FootnoteReference` из editor-ext — `apps/server/src/collaboration/collaboration.util.ts:121`). - `update_page` / `import_page_markdown` / комментарии идут через MCP `markdownToProseMirror()` (`collaboration.ts:452`). Вывод: **логика reuse живёт в общем модуле `editor-ext/footnote`** (его используют клиент, сервер и MCP-зеркало), а **предупреждения считаются отдельным текстовым сканом в MCP** — он не зависит от пути конверсии и работает одинаково для всех инструментов. --- ## Цели 1. **Reuse:** несколько `[^id]` ссылаются на одну сноску (как в Pandoc) — один номер, одно определение, без `__N`-пустышек. 2. **Диагностика:** при импорте возвращать в результат инструмента предупреждения о проблемных сносках. Документ создаётся всегда; молча пустышки не плодим. 3. **UX редактора:** корректная вставка (paste) при reuse и мульти-бэклинки (одно определение → возврат к каждой из своих ссылок). ## Зафиксированные дизайн-решения - **Дубликат определения** (две строки `[^a]: …`): **первое выигрывает**, остальные отбрасываются + предупреждение (на агентском пути импорта). В живом редакторе сохраняем защитный принцип «никогда не терять данные молча». - **Предупреждать** обо всех 4 проблемах: dangling reference, пустой текст определения, дубликат определения, метки `[^…]` в таблицах. - **UX:** делаем и paste-reuse, и мульти-бэклинки. --- ## Реализация ### Фича 1 — Reuse (ядро) **`packages/editor-ext/src/lib/footnote/footnote-sync.ts` → `resolveCollisions()`** - Перестать минтить новые id для дубль-**ссылок**. `referenceIds` = различные id ссылок в порядке первого появления; убрать ветку, добавляющую `refReids` для повторов ссылок; `seenRefIds` лишь дедуплицирует. - На каждый различный id ссылки — ровно одно определение (существующее или, если нет, пустое — теперь это «пустая сноска», диагностируется Фичей 2). - Дубль-**определения** (один id у двух def-узлов): сохранить текущую защиту от потери данных в живом редакторе (re-id лишнего определения детерминированным `deriveFootnoteId`, оно становится сиротой по существующей orphan-политике). Это путь paste/collab, не импорт. - `deriveFootnoteId` и его golden-table тест (`footnote-util.derive-id.test.ts`) **остаются** — нужны для дубль-определений и paste-плагина. - Псевдокод нового цикла по ссылкам: ``` for (const ref of scan.refOccurrences) { if (!seenRefIds.has(ref.id)) { seenRefIds.add(ref.id); referenceIds.push(ref.id); } // повтор того же id — это reuse: НИЧЕГО не делаем (без mintId/refReids) } ``` - Инвариант сохраняется: один `footnotesList`, одно определение на различный id, сироты (id без ссылок) дропаются как раньше. Детерминизм Yjs только улучшается (для ссылок минтинга больше нет). **Зеркала импорта (first-wins для дубль-определений):** - `packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts` → `extractFootnoteDefinitions()`: - убрать переписывание маркеров ссылок (оно ломало reuse); - дубль-def: **оставить первое определение для id, остальные отбросить**, не эмитить `d__2/d__3`; - повторные `[^a]`-маркеры оставить как есть (все `a`). - `packages/mcp/src/lib/collaboration.ts` → `extractFootnotes()`: те же изменения (держать в синхроне с editor-ext — есть явный комментарий «MUST stay in sync»). ### Фича 2 — Предупреждения **Новая чистая функция `analyzeFootnotes(markdown)` (MCP, напр. `packages/mcp/src/lib/footnote-analyze.ts`)** Fence-aware скан (как в `extractFootnotes`), возвращает: ```ts interface FootnoteDiagnostics { danglingReferences: string[]; // id ссылок без определения emptyDefinitions: string[]; // [^id]: с пустым текстом duplicateDefinitions: string[]; // id, определённый >= 2 раз referencesInTables: string[]; // [^id] в строках GFM-таблицы (trim начинается с '|') warnings: string[]; // человекочитаемые строки для ответа инструмента } ``` Правила: - собрать все id ссылок (исключая строки внутри ``` / ~~~) и все id определений; - `danglingReferences` = ссылки, для которых нет определения; - `duplicateDefinitions` = id с >= 2 def-строками; - `emptyDefinitions` = def-строки с пустым `(.*)` после `:`; - `referencesInTables` = `[^id]` найден в строке, чей `trimStart()` начинается с `|` (эвристика, помечается как таковая в тексте предупреждения). **Плумбинг — поле `footnoteWarnings: string[]` в результат:** - `client.createPage()` (`client.ts:972`): вызвать `analyzeFootnotes(content)`, приложить к возвращаемому объекту (после `getPage`). - `client.updatePage()` (`client.ts:1065`): то же по `content`. - `client.importPageMarkdown()` (`client.ts:1378`): по `body`; уже есть поле `warning` — добавить рядом `footnoteWarnings`. - Обработчики в `index.ts` (`create_page` — 290, `import_page_markdown` — 367) прокидывают результат как есть (`jsonContent`), правок их сигнатур не требуется. - Комментарии (`createComment`/`updateComment`) — вне объёма (сноски в комментариях нетипичны). ### Фича 3 — UX редактора **paste-reuse — `footnote-sync.ts` → `footnotePastePlugin()` (`:557`)** - Сейчас регенерирует все конфликтующие id (refs + defs). - Новое: регенерировать id только если во вставляемом слайсе есть **определение** с конфликтующим id (чтобы не затереть текст существующей сноски). Если в слайсе только **ссылка(и)** на уже существующий id — оставить id (reuse). - Реализация: при сборе `remap` учитывать, есть ли в слайсе `footnoteDefinition` с этим id; ремапить только такие id (и парные ссылки), одиночные ссылки не трогать. **мульти-бэклинки** - Helper нумерации: добавить в `footnote-numbering.ts`/`footnote-util.ts` карту `id -> reference occurrences` (порядок/счётчик), кэшируемую плагином рядом с `numbers`. Экспорт getter `getFootnoteRefCount(state, id)` (или список индексов). - Команда: расширить `scrollToReference` (`footnote-reference.ts:318`) до `scrollToReference(id, index?)` → `querySelectorAll('sup[data-footnote-ref][data-id="…"]')[index]`. - `apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx`: при числе ссылок > 1 рендерить ↩ + подметки `a, b, c…`, каждая ведёт к своей occurrence; при одной — текущее поведение (один ↩). - CSS: `apps/client/src/features/editor/components/footnote/footnote.module.css` — раскладка нескольких бэклинков. --- ## Затрагиваемые файлы (чеклист) - [ ] `packages/editor-ext/src/lib/footnote/footnote-sync.ts` — reuse в `resolveCollisions`, paste-reuse в `footnotePastePlugin` - [ ] `packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts` — `extractFootnoteDefinitions`: убрать rewrite маркеров, first-wins для дубль-def - [ ] `packages/mcp/src/lib/collaboration.ts` — `extractFootnotes`: зеркальные правки - [ ] `packages/mcp/src/lib/footnote-analyze.ts` — новый `analyzeFootnotes()` - [ ] `packages/mcp/src/client.ts` — `footnoteWarnings` в `createPage` / `updatePage` / `importPageMarkdown` - [ ] `packages/editor-ext/src/lib/footnote/footnote-numbering.ts` (+ `footnote-util.ts`) — карта occurrences по id - [ ] `packages/editor-ext/src/lib/footnote/footnote-reference.ts` — `scrollToReference(id, index?)` - [ ] `apps/client/.../footnote/footnote-definition-view.tsx` — мульти-бэклинки - [ ] `apps/client/.../footnote/footnote.module.css` — стили бэклинков ## Тесты - [ ] `footnote.test.ts` — переписать проверки переименования дубль-ссылок на reuse; добавить кейсы: N ссылок + 1 def → один номер/одно определение; ссылка без def → одна пустая сноска (не N штук) - [ ] `footnote.marked.orphan.test.ts` — обновить на first-wins (один def `d`, дубликаты отброшены) - [ ] новый тест `analyzeFootnotes` — 4 типа диагностик + fence-aware - [ ] paste-reuse тест — вставка одиночной ссылки на существующий id сохраняет id; вставка пары ref+def с конфликтом — регенерирует - [ ] `footnote-views.structure.test.tsx` — мульти-бэклинки рендерятся по числу ссылок - [ ] серверный round-trip (`apps/server/.../footnote-superscript-roundtrip.spec.ts`) — не сломан ## Открытые пункты для проверки на этапе реализации - Точно подтвердить, где для `create_page` (серверный импорт) синтезируются пустышки и сейчас происходит `__N`: серверный pipeline импорта vs первое открытие в коллаб-редакторе. Рычаг всё равно `resolveCollisions`, но надо убедиться, что серверный путь его реально исполняет (а не только строит схему через `generateJSON`). - Расхождение поведения «импорт = first-wins+warn» vs «редактор = never-lose» для дубль-определений зафиксировать комментариями в коде. ## Критерии приёмки (DoD) - N маркеров `[^a]` + одно `[^a]: текст` → одна сноска, один номер, N кликабельных ссылок, одно определение; никаких `a__2`. - Ссылка без определения → ровно одна пустая сноска (как и раньше создаётся), но `create_page`/`update_page`/`import_page_markdown` возвращают `footnoteWarnings` с указанием id. - Дубликат определения → используется первое, в `footnoteWarnings` есть запись. - `[^…]` в таблице → попадает в `footnoteWarnings` (referencesInTables). - В редакторе: вставка ссылки на существующую сноску делает reuse; определение показывает возврат ко всем своим ссылкам. - Все существующие и новые тесты зелёные; serverный round-trip сохранён. --- _Issue подготовлен по результатам разбора бага со сносками (статья про чокопай). Это задача-проектирование; код не менялся._
Ghost added the feature label 2026-06-24 15:09:55 +03:00
Ghost closed this issue 2026-06-24 16:39:00 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#166