fix(web): «!» в тексте ленты не пропадают — без списка вложений !имя! только для имени файла (#124, N-1, R-20)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 01:48:36 +04:00
parent 6327c7cf57
commit fa1ceb4868
3 changed files with 17 additions and 4 deletions

View File

@ -211,3 +211,8 @@ vitest, для окна карточки дополнительно e2e. Сер
- R-19: N-1 (пере-ревью финальной волны) — в режиме `anyAttachment` ветка `!имя!` принимает любую пару «!», поэтому в ленте
«Срочно! Проверьте! Ок» теряет восклицательные знаки — отложено в follow-up (требовать расширение файла в имени);
второй волны правок нет. Цена ошибки — искажённые «!» в тексте строки ленты, данные не страдают.
- R-20 (design #6, 26.10.2026, решение владельца по R-19): N-1 исправлена до PR, а не отложена. В режиме `anyAttachment`
`!имя!` без списка вложений считается вложением, только если имя — имя файла с расширением без пробелов по краям
(`FILE_NAME` в `inline.ts`); `[^имя]` — явная ссылка на вложение, проверка к ней не применяется. Обоснование: ветка
сама внесла искажение «!» в строке ленты, владелец не хочет таких ошибок. Цена ошибки — `!слово.слово!` в обычном
тексте ленты («Ура!Сделано.ру!») прочитается как имя вложения без «!»; данные не страдают.

View File

@ -26,6 +26,8 @@ const URL = /https?:\/\/[^\s\]|<>"]+/y;
const URL_TAIL = /[.,;:!?)]+$/;
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
// имя файла с расширением, без пробелов по краям: только такое `!имя!` без списка вложений — вложение (N-1)
const FILE_NAME = /^\S(?:.*\S)?\.[\p{L}\p{N}]{1,10}$/u;
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
function safeHref(raw: string): string | null {
@ -33,10 +35,11 @@ function safeHref(raw: string): string | null {
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
}
// вложение по имени: несколько с одним именем — первое в порядке хаба
function findAttachment(opts: WikiOptions, name: string): string | null {
// вложение по имени: несколько с одним именем — первое в порядке хаба; image — `!имя!`, без списка только имя файла,
// иначе «Срочно! Проверьте!» в ленте теряет «!» (N-1)
function findAttachment(opts: WikiOptions, name: string, image = false): string | null {
const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id;
return found ?? (opts.anyAttachment ? "" : null);
return found ?? (opts.anyAttachment && (!image || FILE_NAME.test(name)) ? "" : null);
}
function keyPattern(projectKey: string | undefined): RegExp | null {
@ -86,7 +89,7 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false)
if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) {
IMAGE.lastIndex = i;
const m = IMAGE.exec(line);
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null;
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1], true) : null;
if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
}

View File

@ -17,6 +17,11 @@ describe("wikiPlain", () => {
it("вложение, которого нет в списке, — именем (I-1)", () => {
expect(wikiPlain("!скрин.png|width=50! [текст|^отчёт.pdf] [^а.txt]").trim()).toBe("скрин.png текст а.txt");
});
it("«!» в обычном тексте без списка вложений остаются (N-1)", () => {
expect(wikiPlain("Срочно! Проверьте! Ок").trim()).toBe("Срочно! Проверьте! Ок");
expect(wikiPlain("Готово! см. лог.txt!").trim()).toBe("Готово! см. лог.txt!");
expect(wikiPlain("Ура!Сделано!").trim()).toBe("Ура!Сделано!");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");