Skip to content

feat(rich-markdown): expand Obsidian wikilinks in articles - #23

Merged
popstas merged 7 commits into
masterfrom
feat/rich-markdown-wikilinks
Aug 1, 2026
Merged

feat(rich-markdown): expand Obsidian wikilinks in articles#23
popstas merged 7 commits into
masterfrom
feat/rich-markdown-wikilinks

Conversation

@popstas

@popstas popstas commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Разворачивает Obsidian [[wikilinks]] в обычный текст перед отправкой rich-статьи в Telegram, на всех трёх поверхностях (CLI / HTTP / MCP).

Мотив: сообщение 408926 в Saved Messages уехало с 20 неразвёрнутыми ссылками — читатель видит сырые скобки и каноническое имя ноты вместо слова, которое написал автор.

Заменяет #22, который GitHub автоматически закрыл при удалении базовой ветки после сквоша #21. Ветка перебазирована на master, диф — только wikilink-работа.

Правило

Одно правило, без частных случаев: побеждает алиас, иначе цель, где #>, а ведущий # отбрасывается.

вход выход
[[Андрей Смирнов]] Андрей Смирнов
[[Станислав Попов|Стасу]] Стасу
[[#Спорные моменты]] Спорные моменты
[[tasks#Настроить statusline]] tasks > Настроить statusline
[[note#^blk]] note > ^blk

Разделяет только первый \| ([[A\|B\|C]]B\|C). Пустая половина падает на другую ([[Note\|]]Note). Обе пустые ([[]], [[\|]]) — не ссылка, уезжает дословно: молча удалить набранные автором символы хуже, чем оставить курьёз.

Что пасс не трогает

  • ![[…]] — это медиа, её владелец scan_media. Отсутствие ! и есть дискриминатор.
  • Инлайновые code spans и fenced-блоки. Переиспользован существующий _CODE_SPAN_RE; маскирование проверяет вложенность, а не пересечение — то же правило, что у iter_line_media_refs.

Где он стоит

Первым шагом normalize_rich_markdown(), до scan_blocks(). Пасс правит текст внутри строки, поэтому счёт блоков и откат по 500 блокам должны видеть тот текст, который реально уедет. Побочно чинится латентный баг: \| [[A\|B]] \| в таблице ломал ячейку о собственный пайп ссылки.

Не CLI-only — в отличие от strip_yaml_frontmatter и scan_media, которые отвечают на вопрос «это файл из vault». Wikilink бессмысленен в Telegram независимо от того, кто прислал текст.

Выключателя нет: литеральный [[…]] в статье — всегда дефект.

Инварианты

  • Возврат входа по identity, когда ничего не изменилось — так CRLF и хвостовой перевод строки переживают отправку байт-в-байт.
  • Идемпотентность: вложенные скобки разворачиваются до неподвижной точки, с потолком MAX_WIKILINK_PASSES = 8 (по образцу MAX_BLOCK_NESTING). Потолок нужен, потому что цикл без него квадратичен по глубине вложенности и вешает event loop до WRITE-гейта: 8000 уровней давали 3.76 с, сейчас 0.007 с на любой глубине.
  • Пасс обычно сокращает текст, но может и удлинить (#> это +2), поэтому grew_by в send_message теперь называет его — иначе переполнение из-за wikilink'ов приписывалось вызывающему.

Отчётность

rich_markdown_wikilinks в payload --dry-run (None для обычной отправки, как и все остальные rich_* маркеры).

Проверка

  • Тесты: 2232 → 2238, ruff check src tests чисто. Телеграм-трафика в тестах нет, только in-memory фейки.
  • Живой --dry-run по мотивирующей ноте «Планёрка 29.07.2026»: rich_markdown_wikilinks: 20, отправки не было.

🤖 Generated with Claude Code

popstas and others added 7 commits August 1, 2026 21:47
An Obsidian note sent as an article carries its [[wikilinks]] verbatim:
message 408926 went out with 19 of them, and the vault holds 554.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tasks: the pure strip_wikilinks() pass, wiring it first into
normalize_rich_markdown() for all surfaces, then the dry-run marker and
the CLAUDE.md invariant rewrite it makes necessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n grew_by

Code review fixes for the wikilink-strip branch:

- strip_wikilinks now loops to a fixpoint (renamed single-pass body to
  _strip_once): [[ [[a]] ]], [[a|[[b]]]] and [[[[a]]]] previously left
  literal [[/]] behind after one pass, contradicting the documented
  idempotency invariant and shipping raw brackets to the reader.
- send_message's post-normalisation over-limit error now names
  "wikilink expansion" in grew_by (a target with 3+ '#' grows the
  source via '#' -> ' > '), so an operator is no longer blamed on a
  spacing/grouping pass that never ran.
- Corrected the stale pre-check comment in service.py to match
  CLAUDE.md's already-accurate "deliberately conservative" wording.
- Docs: README/SKILL byte-for-byte recipe now notes wikilinks are
  always expanded (no knob); SKILL's second dry-run marker list is
  brought current (wikilinks, spaced, line_breaks, media_grouping,
  groups); design spec's "shrinks, never grows" claim corrected.
- Tests: nested-wikilink fixpoint cases, a service-level test pinning
  the grew_by wording, and a CLI real-send assertion that wikilinks
  expand independently of --no-spaced-paragraphs.

Full suite: 2237 passed. ruff check src tests: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixpoint loop added in the previous fix rescanned the whole document
once per nesting level with no bound, so a pathological run of nested
brackets ("["*16000 + "a" + "]"*16000, well under MAX_RICH_MARKDOWN_CHARS)
resolves one bracket pair per pass and turns one request into ~16 000
document rescans — measured at 3.76s, quadratic in nesting depth. That
scan runs inside normalize_rich_markdown, called from send_message before
the WRITE gate, which is exactly the pre-auth event-loop-blocking hazard
MAX_BLOCK_NESTING exists to prevent for scan_blocks.

Added MAX_WIKILINK_PASSES = 8 (real notes nest wikilinks one or two
levels deep) and bounded the loop by it, same trade-off _scan_nested makes
past MAX_BLOCK_NESTING: nesting past the cap stops and ships the leftover
[[/]] verbatim, like a degenerate [[]]. The three fixpoint repro cases and
test_is_idempotent still fully resolve (well within the cap).

Docs: qualified the "no [[ left" claims in CLAUDE.md, README.md and the
skill to "up to a bounded nesting depth" (one clause each). Skill re-synced
to ~/.claude/skills/telegram-assistant/SKILL.md.

Test: test_nesting_past_the_cap_stops_and_ships_the_remainder_verbatim
proves the cap structurally (pass count via length delta), no wall-clock
assertion.

Full suite: 2238 passed. ruff check src tests: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@popstas
popstas merged commit c7f6e41 into master Aug 1, 2026
2 checks passed
@popstas
popstas deleted the feat/rich-markdown-wikilinks branch August 1, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant