diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index eee4757..b0014e3 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -55,6 +55,15 @@ describe("текст события", () => { expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6 }); + it("комментарий в ленте — без вики-разметки (С6)", () => { + expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))) + .toBe("комментарий: Итог Готово, см. протокол и @ivanov"); + }); + + it("пустой комментарий — по-прежнему «—»", () => { + expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: " " }))).toBe("комментарий: —"); + }); + it("прочие поля — подпись из словаря или имя поля", () => { expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»"); expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»"); diff --git a/web/src/modules/jira/feed/events.ts b/web/src/modules/jira/feed/events.ts index 14a42d0..72587dd 100644 --- a/web/src/modules/jira/feed/events.ts +++ b/web/src/modules/jira/feed/events.ts @@ -1,4 +1,5 @@ import type { ChangeEvent, ChangeKind } from "../api/types.ts"; +import { wikiPlain } from "../issues/wiki/plain.ts"; // период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») export type Period = "2h" | "today" | "yesterday" | "7d"; @@ -70,7 +71,7 @@ export function eventText(e: ChangeEvent): string { case "description": return "описание изменено"; case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`; case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6) - const s = oneLine(e.to); + const s = oneLine(wikiPlain(e.to ?? "")); return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…"); } default: { diff --git a/web/src/modules/jira/issues/wiki/plain.test.ts b/web/src/modules/jira/issues/wiki/plain.test.ts new file mode 100644 index 0000000..9b9b2d6 --- /dev/null +++ b/web/src/modules/jira/issues/wiki/plain.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { wikiPlain } from "./plain.ts"; + +const norm = (s: string) => s.replace(/\s+/g, " ").trim(); + +describe("wikiPlain", () => { + it("выделение, ссылки, упоминание, заголовок", () => { + expect(norm(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]"))).toBe("Итог Готово, см. протокол и @ivanov"); + }); + it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => { + expect(norm(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}"))).toBe("https://x.ru a_b x = 1"); + }); + it("ключ задачи и вложение — текстом", () => { + const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] }; + expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png"); + }); + it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => { + expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца"); + expect(wikiPlain("")).toBe(""); + }); + it("списки, таблицы, цитаты — содержимое через пробел", () => { + expect(norm(wikiPlain("* a\n** b\n# c\n||h1||h2||\n|x|y|\nbq. цит"))).toBe("a b c h1 h2 x y цит"); + }); +}); diff --git a/web/src/modules/jira/issues/wiki/plain.ts b/web/src/modules/jira/issues/wiki/plain.ts new file mode 100644 index 0000000..693bd44 --- /dev/null +++ b/web/src/modules/jira/issues/wiki/plain.ts @@ -0,0 +1,35 @@ +// Текст вики-разметки Jira без разметки — как читается отрендеренным (лента изменений, С6). +// Блоки соединены пробелом; «одну строку» и обрезку делает вызывающий. +import type { Inline, WikiOptions } from "./inline.ts"; +import { type Block, type ListBlock, parseWiki } from "./parse.ts"; + +function inline(nodes: Inline[]): string { + return nodes.map((n): string => { + switch (n.t) { + case "text": case "code": return n.text; + case "mention": return "@" + n.login; + case "br": return " "; + case "issue": return n.children.length ? inline(n.children) : n.key; + default: return inline(n.children); + } + }).join(""); +} + +function list(l: ListBlock): string[] { + return l.items.flatMap((it) => [inline(it.children), ...it.lists.flatMap(list)]); +} + +function block(b: Block): string[] { + switch (b.t) { + case "heading": case "p": return [inline(b.children)]; + case "list": return list(b); + case "table": return b.rows.flatMap((r) => r.map((c) => inline(c.children))); + case "pre": return [b.text]; + case "quote": return b.children.flatMap(block); + case "hr": return []; + } +} + +export function wikiPlain(text: string, opts: WikiOptions = {}): string { + return parseWiki(text, opts).flatMap(block).join(" "); +}