fix(drawio): content= — entity-XML вместо base64 (кириллица в редакторе) (#507) #521
Reference in New Issue
Block a user
Delete Branch "fix/507-drawio-cyrillic"
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?
Summary
Реviewer-filed баг #507: draw.io-редактор декодил наш base64
content=как Latin-1 → кириллические подписи → мойбейк, автосейв редактора персистил порчу и убивал превью. closes #507.Причина: для base64-значения
content=draw.io применяет atob (Latin-1, без UTF-8-декода). Нативная форма draw.io — entity-encoded mxfile-XML (content="<mxfile…"), которая DOM-декодится как UTF-8.Фикс двух write-путей на XML-escape вместо base64:
buildDrawioSvg(packages/mcp/src/lib/drawio-xml.ts) — все create/update MCP-пути.createDrawioSvg(import-attachment.service.ts) — Confluence-импорт (тот же латентный баг).Декодер (
decodeDrawioSvg/extractContentAttr) уже различалstartsWith("<")(entity XML) vs base64 — обе формы читаются, старые base64-файлы открываются.How verified
node --test test/unit test/mock: 836 pass (4 новых #507). Server import spec: 2 pass.content=нового.drawio.svg— entity-XML (не base64); byte-stable round-tripdrawioCreate→drawioGet; старая base64-форма читается (back-compat); негативный тест (кириллица в value + не-ASCII заголовок в<diagram name=…>).&<>" ,&первым; апостроф не нужен внутриcontent="…"); byte-stable через edge-контент (&,]]>,
, сырой<); других base64-писателей нет.Миграция
Старые агентские кириллические диаграммы (base64) — «мины», лечатся
drawioGet→drawioUpdateтем же XML (в CHANGELOG). Отдельный скрипт не нужен.Checklist
🤖 Авто-ревень · #521 (drawio content= entity-XML, #507) — голова
355bb81a. Вердикт: CHANGESФикс верный по сути (base64→entity-XML лечит мойибейк, escape инъекций-безопасен), но вводит новый класс потери данных, который нарушает собственный критерий приёмки #507 «byte-stable round-trip». Плюс 2 доводки.
Прогнал 9-аспектный веер + гейт (mcp 836/836, tsc 0, import-spec 2/2). Security/regressions/architecture/test-coverage LGTM: escape инъекций-безопасен (
&первым,"→", breakout невозможен), back-compat цел (старый base64 читается), baseHash format-agnostic (нет ложных конфликтов).🔧 Do — потом ре-ревью
drawio-xml.ts:340-346(xmlEscape) +import-attachment.service.ts(xmlEscapeAttr). Новая форма кладёт ВЕСЬ mxfile-XML в одноcontent="…"; escape покрывает только& < > ", но НЕ\t/\n/\r. По XML-спеке attribute-value normalization заменяет литеральные tab/newline/CR на пробел при DOM-чтении (extractContentAttr/decodeDrawioSvgчерез jsdom, и сам draw.io-DOM). base64 хранил байты как есть — эта форма их плющит. Перепроверил сам end-to-end через собранный модуль:value="col1⇥col2"→ decode"col1 col2"❌ (TAB→пробел)style="html=1;␊foo=bar"→"html=1; foo=bar"❌ (LF→пробел)value="a␍b"→"a b"❌ (CR→пробел); контроль без whitespace — ✅.Многострочная подпись (
value="Line1\nLine2") сливается в одну строку — видимая пользователю порча; тот же удар по draw.io-editor DOM. Реальный native-экспорт draw.io эскейпит их как , чегоxmlEscapeне делает. 4 новых #507-теста проходят ВАКУУМНО (ни один не содержит литеральный newline/tab). Fix: в ОБОИХxmlEscape/xmlEscapeAttrпосле четырёх replace'ов добавь.replace(/\t/g,"	").replace(/\n/g,"
").replace(/\r/g,"
")(char-refs переживают normalization). И в fallback-веткеextractContentAttr(стр. 199-205) добавь раскодирование	/
/
(+ decimal	/ / , case-insensitive) — иначе два decode-пути (DOM vs regex-fallback) РАСХОДЯТСЯ (DOM дропает control-chars, fallback сохраняет). Добавь round-trip тест с литеральными\tи\nв value.xmlEscapeAttrдублирует общийhtmlEscape.import-attachment.service.ts:892-902. В apps/server уже естьcommon/helpers/html-escaper.ts::htmlEscape(escapes& < > " '— строгий суперсет, уже юзается вshare-seo.util.ts), тот же пакет, без cross-package-cost. Сверил: helper существует, escape-набор — суперсет (лишний'→'безвреден в"-delimited content=). Fix: удали приватныйxmlEscapeAttr, импортниhtmlEscape, замени вызов. (mcp-путь корректно юзает свой module-localxmlEscape— его не трогать.) NB: если возьмёшьhtmlEscape— он тоже НЕ эскейпит tab/newline, так что п.1 всё равно нужен поверх.9 аспектов. Security/regressions/architecture/test-coverage — LGTM. Гейт зелёный (mcp 836/836, tsc 0, import 2/2), НО п.1 — реальная порча, тесты её не ловят (вакуумны на whitespace). База develop, мёржится чисто.
Хэндофф после доводки (
1edc8b8d+ мерж develop548ff856).Finding 1 [warning] — DATA-LOSS (литеральные TAB/LF/CR схлопывались attribute-value normalization), fixed. Ключевое:
xmlEscape(mcpdrawio-xml.ts) и серверный путь после& < > "кодируют\t/\n/\rкак числовые char-ref'ы	/
/
(переживают normalization).&эскейпится ПЕРВЫМ → двойного эскейпа нет.extractContentAttrдекод char-ref'ов (hex/gi+ decimal) стоит ПЕРЕД&→&, поэтому литеральный текст	(чей&был эскейпнут в&) читается обратно как 5 символов, НЕ как таб. DOM-путь (jsdom getAttribute) и fallback-путь согласованы на всех формах.	→ 5 символов;&+таб →&+таб без double-escape; заглавная	; decimal	. Мутации: убрать encode → оба новых теста краснеют; убрать только fallback-decode → краснеет ТОЛЬКО fallback-тест (DOM зелёный) — оба пути независимо задействованы. 4 старых #507-теста были вакуумны (без литеральных control-символов) — добавлены не-вакуумные round-trip'ы.Finding 2 [conventions] — дедуп, fixed. Удалил приватный серверный
xmlEscapeAttr, завёлxmlEscapeContent(s) = htmlEscape(s) + три control-replace(общийcommon/helpers/html-escaper.ts::htmlEscape, строгий суперсет; лишний'→'безвреден в"-делимитированном content= и симметрично декодится). mcp-стороннийxmlEscapeоставлен module-local.Finding 3 [documentation] — CHANGELOG сужен, fixed. «Repaired in place» — только для диаграмм, ещё хранящих исходный корректный UTF-8 base64 (не открытых/не автосохранённых в редакторе); уже испорченные at-rest перезаписью не лечатся.
Объективка: mcp full 838, drawio-xml 35 (+2), server drawio-svg spec 3 (+1). Внутренний цикл: 1 фикс-проход + адверсариальный ре-ревью (жёстко по encode/decode-симметрии) → APPROVE, must-fix нет. node_modules 0. Влил develop.
Follow-up (вне scope этого PR, на заметку — предлагаю отдельной ишьёй, если сочтёшь нужным): ревью нашло тот же класс бага в СОСЕДНЕЙ функции
drawio-graph.ts:622xmlEscapeAttr(сборка ВНУТРЕННИХ атрибутов ячеек вassembleModel) — она эскейпит\n/\r, но НЕ\t. Литеральный таб в label узла графа переживёт ВНЕШНИЙ content=-round-trip (внешнийxmlEscapeего закодирует), но при повторном разборе внутренней модели редактором draw.io схлопнется в пробел. Предсуществующий, не трогал в этом PR ради узкого scope — туда просится тот же\t→	.🤖 Ре-ревью · #521 (drawio content= entity-XML, #507) round 2 — голова
548ff856. Вердикт: PASS ✅Все 3 DO закрыты; главную (потерю данных) перепроверил своим эмпирическим round-trip'ом:
\t→	,\n→
,\r→
): mcpdrawio-xml.ts:362-364, serverimport-attachment.service.ts:906-908. Fallback-decode инвертирует (drawio-xml.ts:210-215, hex+decimal, case-insensitive) → DOM-путь и regex-fallback согласованы. ПерегонялbuildDrawioSvg→decodeDrawioSvgчерез собранный модуль:col1⇥col2,html=1;␊foo=bar,a␍b— ВСЕ ✅ round-trip (были ❌). Заперто тестами: mcp «#507 F1 literal tab/newline/CR round-trip» (452) + «regex fallback agrees with DOM» (471), server «encodes literal tab/newline/CR as char-refs» (79).xmlEscapeAttrзаменён на общийhtmlEscape(s).replace(char-refs).Гейт: mcp node:test 840/0 (было 836 — +4 whitespace-теста).
Фикс не внёс нового бага. Approve.
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1 (atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded mxfile-XML (content="<mxfile…"), DOM-декодится как UTF-8. Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content= вместо base64. Декодер уже различает startsWith("<") vs base64 — обе формы читаются, старые base64-файлы открываются (back-compat), byte-stable round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>548ff856d8to846341d7d4Ребейзнул на develop (по просьбе): сбросил на исходные 2 коммита, ребейз линейно поверх develop. Конфликт был только CHANGELOG (коллизия [Unreleased], взял оба набора). Narrowing healing-claim (finding 3 прошлого ревью) цел. 0 merge-коммитов, node_modules 0. Ребейз переписал SHA → review/needs для ре-подтверждения.
🤖 Ре-подтверждение · #521 — после ребейза на develop, голова
846341d7. Вердикт: PASS ✅Кодер ребейзнул на develop (2 коммита линейно, конфликт только CHANGELOG). Не новый код. Пере-верифицировал крюкс: char-ref data-loss фикс (
	/
/
на обоих путях) ПЕРЕЖИЛ ребейз (3 refs); перегнал свой эмпирический round-trip через собранный модуль: TAB/NEWLINE/CR в attribute values — все ✅ (потери нет). Все 3 DO были закрыты на предыдущем round 2. Ребейз чист. Approve.