From e5db848b6b3485be26fa4d5b92c8b305b1ed0ddd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:55:52 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раздел «Вложения (N)», плашки под комментарием и ссылки !имя!, [^имя] в тексте; хук useAttachmentDownload: fetch, опрос раз в 2 с, сохранение через Blob (api.blob). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- web/src/api/client.test.ts | 12 ++ web/src/api/client.ts | 20 +++- web/src/api/types.ts | 15 ++- web/src/issues/IssueCard.test.tsx | 92 +++++++++++++++ web/src/issues/IssueCard.tsx | 43 +++++-- .../issues/attachments/AttachmentChips.tsx | 33 ++++++ web/src/issues/attachments/AttachmentList.tsx | 56 +++++++++ web/src/issues/attachments/attachments.css | 24 ++++ web/src/issues/attachments/format.test.ts | 43 +++++++ web/src/issues/attachments/format.ts | 31 +++++ .../useAttachmentDownload.test.tsx | 109 ++++++++++++++++++ .../attachments/useAttachmentDownload.ts | 85 ++++++++++++++ web/src/issues/wiki/WikiText.tsx | 68 +++++++---- web/src/issues/wiki/inline.test.ts | 38 ++++++ web/src/issues/wiki/inline.ts | 31 ++++- web/src/ui/icons.tsx | 5 + 17 files changed, 666 insertions(+), 41 deletions(-) create mode 100644 web/src/issues/attachments/AttachmentChips.tsx create mode 100644 web/src/issues/attachments/AttachmentList.tsx create mode 100644 web/src/issues/attachments/attachments.css create mode 100644 web/src/issues/attachments/format.test.ts create mode 100644 web/src/issues/attachments/format.ts create mode 100644 web/src/issues/attachments/useAttachmentDownload.test.tsx create mode 100644 web/src/issues/attachments/useAttachmentDownload.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f10125..65ff8dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,7 +28,7 @@ `web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`), «Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует -страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку +страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Вложения в карточке — `web/src/issues/attachments/`: `format.ts` (размер, адрес в Jira, значок по типу, предел 10 МБ), `useAttachmentDownload.ts` (хук карточки: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}` раз в 2 с до `ready`, затем `api.blob` на `/content` и сохранение через временную ссылку; одно состояние на вложение для всех кнопок; опрос стоит при закрытии окна и смене задачи), `AttachmentList.tsx` (раздел «Вложения»), `AttachmentChips.tsx` (плашки под комментарием); ссылки `!имя!`, `[^имя]` в тексте разбирает `issues/wiki/inline.ts` по списку вложений задачи (узел `attachment`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index d24f776..74a7e33 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -67,4 +67,16 @@ describe("api", () => { expect((fetch.mock.calls[0][1]!.headers as Record).Authorization).toBe("Bearer candidate"); expect(fetch.mock.calls[0][0]).toBe("/settings"); }); + + it("blob: тот же токен, тело как есть без разбора JSON; ошибки как у call", async () => { + const fetch = mockFetch((_m, url) => url.pathname === "/ok" ? new Response("не JSON", { status: 200 }) : json(404, { error: "not_fetched", detail: "нет файла" })); + const blob = await api.blob("/ok"); + expect(await blob.text()).toBe("не JSON"); + expect((fetch.mock.calls[0][1]!.headers as Record).Authorization).toBe("Bearer tok"); + const e = await api.blob("/no").catch((err: unknown) => err) as ApiError; + expect(e).toBeInstanceOf(ApiError); + expect(e.detail).toBe("нет файла"); + mockFetch(() => json(401, {})); + await expect(api.blob("/x")).rejects.toBeInstanceOf(AuthError); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 41e74e1..2910491 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -40,11 +40,12 @@ function withParams(path: string, params?: Params): string { return s ? `${path}?${s}` : path; } -async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { +// общая часть запросов: токен, сеть, 401/5xx/4xx; успешный Response возвращается нетронутым +async function send(method: string, path: string, accept: string, body?: unknown, token = getToken()): Promise { if (!token) throw new AuthError("нет токена", token); // токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает» if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token); - const headers: Record = { Authorization: "Bearer " + token, Accept: "application/json" }; + const headers: Record = { Authorization: "Bearer " + token, Accept: accept }; if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8"; let resp: Response; try { @@ -53,13 +54,18 @@ async function call(method: string, path: string, body?: unknown, token = get throw new HubDownError("хаб не отвечает"); } if (resp.status === 401) throw new AuthError("токен не подошёл", token); + if (resp.ok) return resp; let data: unknown = null; try { data = await resp.json(); } catch { /* тело не JSON */ } if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`); - if (!resp.ok) { - const d = (data ?? {}) as { error?: string; detail?: unknown }; - throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail)); - } + const d = (data ?? {}) as { error?: string; detail?: unknown }; + throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail)); +} + +async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { + const resp = await send(method, path, "application/json", body, token); + let data: unknown = null; + try { data = await resp.json(); } catch { /* тело не JSON */ } return data as T; } @@ -67,6 +73,8 @@ export const api = { get: (path: string, params?: Params) => call("GET", withParams(path, params)), post: (path: string, body: unknown = {}) => call("POST", path, body), put: (path: string, body: unknown) => call("PUT", path, body), + // тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается + blob: async (path: string) => (await send("GET", path, "*/*")).blob(), }; // проверка токена до сохранения: экран входа diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6920881..23cf8ac 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -40,7 +40,18 @@ export interface IssueCommand { export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } // комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira -export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } +export interface IssueComment { + comment_id: string; author: string | null; created: string | null; updated: string | null; body: string; + attachment_ids?: string[]; // id вложений, на которые ссылается текст (хаб с вложениями, схема v6) +} +// вложение задачи (хаб со схемой v6): state — ready (файл у хаба) / fetching / failed / none; error — причина при failed +export type AttachmentStateName = "ready" | "fetching" | "failed" | "none"; +export interface IssueAttachment { + attachment_id: string; filename: string; size: number | null; mime_type: string | null; author: string | null; + created: string | null; state: AttachmentStateName; error: string | null; +} +// ответ GET /attachments/{id} и POST /attachments/{id}/fetch +export interface AttachmentState extends IssueAttachment { key: string; command_id: number | null; batch_id: number | null } // событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status) export type ChangeKind = "status" | "description" | "dates" | "comment" | "other"; export interface ChangeEvent { @@ -56,6 +67,8 @@ export interface IssueDetail extends IssueRow { note: Note | null; commands: IssueCommand[]; comments: IssueComment[]; + attachments?: IssueAttachment[]; // хаб до вложений поля не шлёт + description_attachment_ids?: string[]; changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 8455af3..f3cb9af 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -5,6 +5,7 @@ import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "reac import { beforeEach, describe, expect, it, vi } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; +import type { IssueAttachment, IssueDetail } from "../api/types.ts"; import { setToken } from "../auth/token.ts"; import baseSettings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; @@ -482,3 +483,94 @@ describe("история в карточке", () => { expect(screen.queryByTestId("history")).toBeNull(); }); }); + +describe("вложения в карточке", () => { + beforeEach(() => setToken("tok")); + const att = (id: string, filename: string, over: Partial = {}): IssueAttachment => ({ + attachment_id: id, filename, size: 240000, mime_type: "image/png", author: "Иванов Иван", created: "2026-10-15T12:00:00.000+0300", + state: "none", error: null, ...over, + }); + const comment = { comment_id: "101", author: "Петров", created: null, updated: null, body: "см. !скрин 1.png|thumbnail!", attachment_ids: ["31337"] }; + const base = { description: "текст", comments: [comment], attachments: [att("31337", "скрин 1.png"), att("31338", "ОСВ 60.xlsx", { mime_type: null, size: 1024, created: "2026-10-16T12:00:00.000+0300" })] }; + function hub(over: Partial, extra?: (m: string, url: URL) => Response | undefined) { + return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over })))); + } + + it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { + hub({}); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument(); + const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); + expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1); + const rows = sec.getAllByRole("listitem"); + expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx"); + expect(rows[0]).toHaveTextContent("1,0 КБ"); + expect(rows[1]).toHaveTextContent("скрин 1.png"); + expect(rows[1]).toHaveTextContent("234,4 КБ"); + expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10"); + expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + }); + + it("без поля attachments (старый хаб) раздела нет", async () => { + hub({ attachments: undefined, comments: [] }); + open("/issues/AURORA-1650"); + await screen.findByText("Описание"); + expect(screen.queryByTestId("attachments")).toBeNull(); + }); + + it("состояние ready — отметка «у хаба»", async () => { + hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] }); + open("/issues/AURORA-1650"); + expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument(); + }); + + it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => { + hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] }); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + const a = sec.getByRole("link", { name: "Открыть в Jira" }); + expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4"); + expect(a).toHaveAttribute("target", "_blank"); + expect(a).toHaveAttribute("title", "больше 10 МБ — только в Jira"); + expect(sec.queryByRole("button", { name: "Скачать" })).toBeNull(); + }); + + it("размер неизвестен — подпись и обычная кнопка", async () => { + hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] }); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument(); + expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + }); + + it("плашка под комментарием и кнопка в тексте", async () => { + hub({}); + open("/issues/AURORA-1650"); + const c = within(await screen.findByTestId("comments")); + expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument(); + expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument(); + expect(c.queryByText(/thumbnail/)).toBeNull(); + }); + + it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => { + hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337" + ? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined); + open("/issues/AURORA-1650"); + await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" })); + expect(await screen.findAllByText("Загружается…")).toHaveLength(3); + for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled(); + expect(within(screen.getByTestId("attachments")).getByRole("button", { name: "Скачать" })).toBeEnabled(); // ОСВ 60.xlsx + }); + + it("ошибка хаба — текст и «Повторить» под вложением", async () => { + const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" + ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); + expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); + await userEvent.click(sec.getByRole("button", { name: "Повторить" })); + expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2); + }); +}); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 1827131..673c81a 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -2,7 +2,7 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; -import type { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts"; +import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; import { EventList } from "../feed/EventList.tsx"; @@ -12,6 +12,10 @@ import { IssueLink } from "./IssueLink.tsx"; import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../ui/index.ts"; import { Notes } from "./Notes.tsx"; import { dueOf } from "./options.ts"; +import { AttachmentChips } from "./attachments/AttachmentChips.tsx"; +import { AttachmentList, type Download } from "./attachments/AttachmentList.tsx"; +import { isTooBig, jiraAttachmentUrl } from "./attachments/format.ts"; +import { useAttachmentDownload } from "./attachments/useAttachmentDownload.ts"; import { WikiText } from "./wiki/WikiText.tsx"; import "./issues.css"; @@ -21,6 +25,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos const issue = useIssue(key); const settings = useSettings(); const closed = useClosedStatuses(); + const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки const close = } onClick={onClose} />; if (issue.isPending) { return
{key}{close}

загрузка {key}…

; @@ -49,6 +54,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos i.sprint && {i.sprint}, i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, ].filter(Boolean); + const atts = i.attachments; + const wiki = wikiAttachments(atts, dl); return (
@@ -68,8 +75,9 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
} /> - {i.description && } - + {i.description && } + {atts && atts.length > 0 && } + {i.changes && }
@@ -82,6 +90,22 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos ); } +// ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в Jira) +interface WikiAtt { attachments?: { id: string; filename: string; created: string | null }[]; onAttachment?: (id: string) => void; attachmentView?: (id: string) => { busy: boolean; href?: string } } +function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): WikiAtt { + if (!atts?.length) return {}; + const byId = new Map(atts.map((a) => [a.attachment_id, a])); + return { + attachments: atts.map((a) => ({ id: a.attachment_id, filename: a.filename, created: a.created })), + onAttachment: (id) => { const a = byId.get(id); if (a) dl.start(a); }, + attachmentView: (id) => { + const a = byId.get(id); + if (a && isTooBig(a.size)) return { busy: false, href: jiraAttachmentUrl(a.attachment_id, a.filename) }; + return { busy: dl.status(id).busy }; + }, + }; +} + function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { const groups = groupLinks(issue, linkTypes); return ( @@ -113,7 +137,7 @@ function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => v return ; } -function Description({ text, projectKey }: { text: string; projectKey?: string }) { +function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) { const ref = useRef(null); const [open, setOpen] = useState(false); const [source, setSource] = useState(false); @@ -141,13 +165,13 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
{source ?
} className={cls}>{text}
- :
} className={cls}>
} + :
} className={cls}>
} {(long || open) && } ); } -function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) { +function Comment({ c, projectKey, wiki, atts, dl }: { c: IssueComment; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) { const [source, setSource] = useState(false); return (
  • @@ -157,12 +181,13 @@ function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
  • {source ?
    {c.body}
    - :
    } + :
    } + atts.filter((a) => a.attachment_id === id))} dl={dl} /> ); } -function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) { +function Comments({ items, projectKey, wiki, atts, dl }: { items: IssueComment[]; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) { const [hidden, setHidden] = useCommentsHidden(); return (
    @@ -173,7 +198,7 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s {!items.length &&

    комментариев нет

    } {items.length > 0 && !hidden && (
      - {items.map((c) => )} + {items.map((c) => )}
    )}
    diff --git a/web/src/issues/attachments/AttachmentChips.tsx b/web/src/issues/attachments/AttachmentChips.tsx new file mode 100644 index 0000000..6b74128 --- /dev/null +++ b/web/src/issues/attachments/AttachmentChips.tsx @@ -0,0 +1,33 @@ +import type { IssueAttachment } from "../../api/types.ts"; +import { IconExternal, IconPaperclip } from "../../ui/index.ts"; +import type { Download } from "./AttachmentList.tsx"; +import { fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; + +// плашки под комментарием: «имя · размер», щелчок — скачивание (большой файл — ссылка в Jira) +export function AttachmentChips({ items, dl }: { items: IssueAttachment[]; dl: Download }) { + if (!items.length) return null; + return ( +
      + {items.map((a) => { + const text = `${a.filename} · ${fmtSize(a.size)}`; + if (isTooBig(a.size)) { + return ( +
    • + + {text} + +
    • + ); + } + const { busy } = dl.status(a.attachment_id); + return ( +
    • + +
    • + ); + })} +
    + ); +} diff --git a/web/src/issues/attachments/AttachmentList.tsx b/web/src/issues/attachments/AttachmentList.tsx new file mode 100644 index 0000000..bd74627 --- /dev/null +++ b/web/src/issues/attachments/AttachmentList.tsx @@ -0,0 +1,56 @@ +import type { IssueAttachment } from "../../api/types.ts"; +import { fmtStamp } from "../../board/model/util.ts"; +import { Badge, Button, IconDownload, IconExternal, IconFile, IconImage, IconSheet } from "../../ui/index.ts"; +import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; +import type { useAttachmentDownload } from "./useAttachmentDownload.ts"; +import "./attachments.css"; + +export type Download = ReturnType; + +const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile }; +const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" }; + +// новые сверху: по времени Jira, затем по числовому id +export function newestFirst(items: IssueAttachment[]): IssueAttachment[] { + const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0); + return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0)); +} + +// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания +export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) { + return ( +
    +

    Вложения ({items.length})

    +
      + {newestFirst(items).map((a) => )} +
    +
    + ); +} + +function Row({ a, dl }: { a: IssueAttachment; dl: Download }) { + const kind = fileKind(a.mime_type, a.filename); + const Icon = KIND_ICON[kind]; + const { busy, error: local } = dl.status(a.attachment_id); + // ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки + const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null); + const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · "); + return ( +
  • + +
    + {a.filename} + + {fmtSize(a.size)}{who && ` · ${who}`} + {a.state === "ready" && <>{" "}у хаба} + + {error &&

    {error}

    } +
    + {isTooBig(a.size) + ? Открыть в Jira + : } +
  • + ); +} diff --git a/web/src/issues/attachments/attachments.css b/web/src/issues/attachments/attachments.css new file mode 100644 index 0000000..e739288 --- /dev/null +++ b/web/src/issues/attachments/attachments.css @@ -0,0 +1,24 @@ +/* вложения в карточке (web-app «Скачивание вложений в карточке»): цвета только через токены */ +.issue .att-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +.issue .att-row{display:flex;align-items:flex-start;gap:var(--space-3);padding:var(--space-3) 0;border-top:1px solid var(--color-border-default)} +.issue .att-row:first-child{border-top:0;padding-top:0} +.issue .att-icon{flex:none;display:flex;align-items:center;justify-content:center;width:var(--control-h);height:var(--control-h);color:var(--color-text-secondary)} +.issue .att-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:var(--space-1)} +.issue .att-name{font-weight:600;overflow-wrap:anywhere} +.issue .att-meta{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} +.issue .att-error{margin:0;color:var(--color-status-error)} +.issue .att-row>.ui-btn{flex:none} +/* плашки под комментарием и ссылка-кнопка в тексте */ +.issue .att-chips{list-style:none;margin:var(--space-2) 0 0;padding:0;display:flex;flex-wrap:wrap;gap:var(--space-2)} +.issue .att-chip,.issue .att-ref{display:inline-flex;align-items:center;gap:var(--space-1);font:inherit;color:var(--color-action-primary);cursor:pointer;text-decoration:none;overflow-wrap:anywhere} +.issue .att-chip{min-height:var(--control-h);padding:0 var(--space-3);background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-round)} +.issue .att-chip:hover:not(:disabled){background:var(--color-bg-hover)} +.issue .att-ref{padding:0;background:none;border:0;text-align:left} +.issue .att-ref:hover:not(:disabled){text-decoration:underline} +.issue .att-chip:disabled,.issue .att-ref:disabled{cursor:default;color:var(--color-text-secondary)} +.issue .att-chip:focus-visible,.issue .att-ref:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px} +.issue .att-busy{color:var(--color-text-secondary)} +@media (max-width:700px){ + .issue .att-row{flex-wrap:wrap} + .issue .att-row>.ui-btn{margin-left:calc(var(--control-h) + var(--space-3))} +} diff --git a/web/src/issues/attachments/format.test.ts b/web/src/issues/attachments/format.test.ts new file mode 100644 index 0000000..d9e322a --- /dev/null +++ b/web/src/issues/attachments/format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { JIRA_URL } from "../../board/constants.ts"; +import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl } from "./format.ts"; + +describe("fmtSize", () => { + it("как в хабе: Б, КБ, МБ с запятой", () => { + expect(fmtSize(500)).toBe("500 Б"); + expect(fmtSize(1023)).toBe("1023 Б"); + expect(fmtSize(1024)).toBe("1,0 КБ"); + expect(fmtSize(240000)).toBe("234,4 КБ"); + expect(fmtSize(1024 ** 2)).toBe("1,0 МБ"); + expect(fmtSize(25 * 1024 ** 2)).toBe("25,0 МБ"); + expect(fmtSize(null)).toBe("размер неизвестен"); + }); +}); + +describe("jiraAttachmentUrl", () => { + it("имя в URL-кодировке", () => { + expect(jiraAttachmentUrl("31337", "скрин 1.png")).toBe(`${JIRA_URL}/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`); + }); +}); + +describe("isTooBig", () => { + it("больше 10 МБ — только в Jira; ровно 10 МБ и неизвестный размер — нет", () => { + expect(isTooBig(10 * 1024 * 1024)).toBe(false); + expect(isTooBig(10 * 1024 * 1024 + 1)).toBe(true); + expect(isTooBig(null)).toBe(false); + }); +}); + +describe("fileKind", () => { + it("тип по mime, иначе по расширению", () => { + expect(fileKind("image/png", "a.bin")).toBe("image"); + expect(fileKind(null, "Скрин.PNG")).toBe("image"); + expect(fileKind("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "x")).toBe("sheet"); + expect(fileKind(null, "ОСВ 60.xlsx")).toBe("sheet"); + expect(fileKind("text/csv", "d.txt")).toBe("sheet"); + expect(fileKind("application/pdf", "x")).toBe("pdf"); + expect(fileKind(null, "отчёт.pdf")).toBe("pdf"); + expect(fileKind("video/mp4", "запись.mp4")).toBe("other"); + expect(fileKind(null, "без расширения")).toBe("other"); + }); +}); diff --git a/web/src/issues/attachments/format.ts b/web/src/issues/attachments/format.ts new file mode 100644 index 0000000..dc608e5 --- /dev/null +++ b/web/src/issues/attachments/format.ts @@ -0,0 +1,31 @@ +import { JIRA_URL } from "../../board/constants.ts"; + +// предел скачивания через хаб — files.MAX_SIZE (10 485 760 байт); больше — только в Jira +export const MAX_SIZE = 10 * 1024 * 1024; +export const TOO_BIG_HINT = "больше 10 МБ — только в Jira"; + +// формат размера как в хабе (attachments.fmt_size): «234,4 КБ», «23,8 МБ»; null — «размер неизвестен» +export function fmtSize(size: number | null): string { + if (size === null) return "размер неизвестен"; + if (size < 1024) return `${size} Б`; + if (size < 1024 ** 2) return `${(size / 1024).toFixed(1).replace(".", ",")} КБ`; + return `${(size / 1024 ** 2).toFixed(1).replace(".", ",")} МБ`; +} + +export const isTooBig = (size: number | null): boolean => size !== null && size > MAX_SIZE; + +export function jiraAttachmentUrl(id: string, filename: string): string { + return `${JIRA_URL}/secure/attachment/${encodeURIComponent(id)}/${encodeURIComponent(filename)}`; +} + +export type FileKind = "image" | "sheet" | "pdf" | "other"; + +// значок по типу файла: сначала mime, затем расширение +export function fileKind(mime: string | null, filename: string): FileKind { + const m = (mime ?? "").toLowerCase(); + const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : ""; + if (m.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"].includes(ext)) return "image"; + if (m.includes("spreadsheet") || m.includes("ms-excel") || m === "text/csv" || ["xls", "xlsx", "xlsm", "ods", "csv"].includes(ext)) return "sheet"; + if (m === "application/pdf" || ext === "pdf") return "pdf"; + return "other"; +} diff --git a/web/src/issues/attachments/useAttachmentDownload.test.tsx b/web/src/issues/attachments/useAttachmentDownload.test.tsx new file mode 100644 index 0000000..99fa7db --- /dev/null +++ b/web/src/issues/attachments/useAttachmentDownload.test.tsx @@ -0,0 +1,109 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "../../api/queryClient.ts"; +import type { IssueAttachment } from "../../api/types.ts"; +import { setToken } from "../../auth/token.ts"; +import { json, mockFetch } from "../../test/fetchMock.ts"; +import { useAttachmentDownload } from "./useAttachmentDownload.ts"; + +const att: IssueAttachment = { + attachment_id: "31337", filename: "скрин 1.png", size: 240000, mime_type: "image/png", author: "Иванов", created: null, state: "none", error: null, +}; +const st = (state: string, extra: object = {}) => ({ attachment_id: "31337", state, error: null, command_id: 7, batch_id: 3, ...extra }); +const wrapper = ({ children }: { children: ReactNode }) => {children}; +const flush = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); + +describe("useAttachmentDownload", () => { + let clicked: { download: string; href: string }[]; + beforeEach(() => { + setToken("tok"); + vi.useFakeTimers(); + clicked = []; + URL.createObjectURL = vi.fn(() => "blob:fake"); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push({ download: this.download, href: this.href }); }); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + + it("fetching → опрос раз в 2 с → ready → файл скачан один раз под своим именем", async () => { + let polls = 0; + const f = mockFetch((m, url) => { + if (m === "POST") return json(200, st("fetching")); + if (url.pathname.endsWith("/content")) return new Response("PNG", { status: 200 }); + polls++; + return json(200, st(polls < 2 ? "fetching" : "ready")); + }); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(polls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(polls).toBe(2); + expect(clicked).toEqual([{ download: "скрин 1.png", href: "blob:fake" }]); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); + expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(polls).toBe(2); + expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1); + }); + + it("ready сразу в ответе на POST — без опроса", async () => { + const f = mockFetch((m, url) => m === "POST" ? json(200, st("ready")) : url.pathname.endsWith("/content") ? new Response("x") : json(500, {})); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(clicked).toHaveLength(1); + expect(f.mock.calls.map(([u]) => String(u))).toEqual(["/attachments/31337/fetch", "/attachments/31337/content"]); + }); + + it("размонтирование прекращает опрос", async () => { + const f = mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, st("fetching"))); + const { result, unmount } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + const n = f.mock.calls.length; + unmount(); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(f.mock.calls.length).toBe(n); + }); + + it("смена задачи прекращает опрос", async () => { + const f = mockFetch(() => json(200, st("fetching"))); + const { result, rerender } = renderHook(({ k }) => useAttachmentDownload(k), { wrapper, initialProps: { k: "AURORA-1650" } }); + act(() => result.current.start(att)); + await flush(); + rerender({ k: "AURORA-1651" }); + const n = f.mock.calls.length; + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(f.mock.calls.length).toBe(n); + expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + }); + + it("409 — ошибка с текстом хаба", async () => { + mockFetch(() => json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" })); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" }); + expect(clicked).toHaveLength(0); + }); + + it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => { + let n = 0; + mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching"))); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + }); +}); diff --git a/web/src/issues/attachments/useAttachmentDownload.ts b/web/src/issues/attachments/useAttachmentDownload.ts new file mode 100644 index 0000000..5708551 --- /dev/null +++ b/web/src/issues/attachments/useAttachmentDownload.ts @@ -0,0 +1,85 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "../../api/client.ts"; +import { api } from "../../api/client.ts"; +import { qk } from "../../api/queries.ts"; +import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; + +export const POLL_MS = 2000; +export interface DownloadStatus { busy: boolean; error: string | null } +const IDLE: DownloadStatus = { busy: false, error: null }; + +// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется) +function save(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e); + +// скачивание вложений карточки: одно состояние на вложение, общее для всех её кнопок (раздел, плашки, ссылки в тексте); +// опрос прекращается при размонтировании и смене задачи, команда при этом остаётся в очереди хаба +export function useAttachmentDownload(issueKey: string) { + const qc = useQueryClient(); + const [map, setMap] = useState>({}); + const mapRef = useRef(map); + const gen = useRef(0); + const timers = useRef(new Set>()); + + const put = useCallback((id: string, s: DownloadStatus) => { + mapRef.current = { ...mapRef.current, [id]: s }; + setMap(mapRef.current); + }, []); + + useEffect(() => { + const pending = timers.current; + const counter = gen; + return () => { + counter.current++; + for (const t of pending) clearTimeout(t); + pending.clear(); + mapRef.current = {}; + setMap({}); + }; + }, [issueKey]); + + const start = useCallback((a: IssueAttachment) => { + const id = a.attachment_id; + if (mapRef.current[id]?.busy) return; + const my = gen.current; + const alive = () => gen.current === my; + const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; + + // разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка + const handle = async (st: AttachmentState): Promise => { + if (!alive()) return; + if (st.state === "ready") { + const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`); + if (!alive()) return; + save(blob, a.filename); + put(id, IDLE); + void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба» + } else if (st.state === "fetching") { + const t = setTimeout(() => { + timers.current.delete(t); + if (!alive()) return; + api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e))); + }, POLL_MS); + timers.current.add(t); + } else { + fail(st.error ?? "не удалось скачать вложение"); + } + }; + + put(id, { busy: true, error: null }); + api.post(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" }) + .then(handle).catch((e) => fail(errorText(e))); + }, [issueKey, put, qc]); + + const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); + return { start, status }; +} diff --git a/web/src/issues/wiki/WikiText.tsx b/web/src/issues/wiki/WikiText.tsx index c70f367..35cab0f 100644 --- a/web/src/issues/wiki/WikiText.tsx +++ b/web/src/issues/wiki/WikiText.tsx @@ -1,14 +1,23 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { IconPaperclip } from "../../ui/index.ts"; import { IssueLink } from "../IssueLink.tsx"; -import type { Inline } from "./inline.ts"; +import { TOO_BIG_HINT } from "../attachments/format.ts"; +import type { Inline, WikiAttachment } from "./inline.ts"; import { type Block, type ListBlock, parseWiki } from "./parse.ts"; import "./wiki.css"; -interface Props { text: string; projectKey?: string } +// вид ссылки на вложение в тексте: busy — идёт скачивание; href — вложение больше предела, ссылка ведёт в Jira +export interface AttachmentView { busy: boolean; href?: string } +interface Props { + text: string; projectKey?: string; + attachments?: WikiAttachment[]; // вложения задачи: по ним распознаются `!имя!`, `[^имя]` + onAttachment?: (id: string) => void; + attachmentView?: (id: string) => AttachmentView; +} // вики-разметка Jira только React-элементами, HTML строкой не вставляется (design wiki-render #2, #5); при ошибке — исходник (#6) -export function WikiText({ text, projectKey }: Props) { - return ; +export function WikiText(props: Props) { + return ; } class Fallback extends Component<{ text: string; children: ReactNode }, { failed: boolean }> { @@ -20,54 +29,67 @@ class Fallback extends Component<{ text: string; children: ReactNode }, { failed } } -function Rendered({ text, projectKey }: Props) { - return
    {renderBlocks(parseWiki(text, { projectKey }))}
    ; +// то, что нужно рендеру строки, кроме текста: ссылки на вложения +interface Ctx { onAttachment?: (id: string) => void; attachmentView?: (id: string) => AttachmentView } + +function Rendered({ text, projectKey, attachments, onAttachment, attachmentView }: Props) { + const ctx: Ctx = { onAttachment, attachmentView }; + return
    {renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}
    ; } -function renderBlocks(blocks: Block[]): ReactNode[] { +function renderBlocks(blocks: Block[], ctx: Ctx): ReactNode[] { return blocks.map((b, n) => { switch (b.t) { - case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children)}; } - case "p": return

    {renderInlines(b.children)}

    ; - case "list": return renderList(b, n); + case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children, ctx)}; } + case "p": return

    {renderInlines(b.children, ctx)}

    ; + case "list": return renderList(b, n, ctx); case "table": return ( {b.rows.map((r, i) => ( - {r.map((c, j) => c.header ? : )} + {r.map((c, j) => c.header ? : )} ))}
    {renderInlines(c.children)}{renderInlines(c.children)}
    {renderInlines(c.children, ctx)}{renderInlines(c.children, ctx)}
    ); case "pre": return
    {b.text}
    ; - case "quote": return
    {renderBlocks(b.children)}
    ; + case "quote": return
    {renderBlocks(b.children, ctx)}
    ; case "hr": return
    ; } }); } -function renderList(list: ListBlock, key: number): ReactNode { +function renderList(list: ListBlock, key: number, ctx: Ctx): ReactNode { const L = list.ordered ? "ol" : "ul"; return ( - {list.items.map((it, i) =>
  • {renderInlines(it.children)}{it.lists.map(renderList)}
  • )} + {list.items.map((it, i) =>
  • {renderInlines(it.children, ctx)}{it.lists.map((l, k) => renderList(l, k, ctx))}
  • )}
    ); } -function renderInlines(nodes: Inline[]): ReactNode[] { +function renderInlines(nodes: Inline[], ctx: Ctx): ReactNode[] { return nodes.map((x, n) => { switch (x.t) { case "text": return x.text; case "br": return
    ; - case "strong": return {renderInlines(x.children)}; - case "em": return {renderInlines(x.children)}; - case "del": return {renderInlines(x.children)}; - case "ins": return {renderInlines(x.children)}; - case "sup": return {renderInlines(x.children)}; - case "sub": return {renderInlines(x.children)}; + case "strong": return {renderInlines(x.children, ctx)}; + case "em": return {renderInlines(x.children, ctx)}; + case "del": return {renderInlines(x.children, ctx)}; + case "ins": return {renderInlines(x.children, ctx)}; + case "sup": return {renderInlines(x.children, ctx)}; + case "sub": return {renderInlines(x.children, ctx)}; case "code": return {x.text}; - case "link": return {renderInlines(x.children)}; - case "issue": return {x.children.length ? renderInlines(x.children) : undefined}; + case "link": return {renderInlines(x.children, ctx)}; + case "issue": return {x.children.length ? renderInlines(x.children, ctx) : undefined}; + case "attachment": { + const view = ctx.attachmentView?.(x.id) ?? { busy: false }; + const label = <>{renderInlines(x.children, ctx)}; + return view.href + ? {label} + : ; + } case "mention": return @{x.login}; } }); diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts index ad03b36..53560c5 100644 --- a/web/src/issues/wiki/inline.test.ts +++ b/web/src/issues/wiki/inline.test.ts @@ -74,3 +74,41 @@ describe("parseInline: ключи задач", () => { }); it("без projectKey — текст", () => expect(parseInline("AURORA-1445")).toEqual([t("AURORA-1445")])); }); + +describe("parseInline: ссылки на вложения", () => { + const atts = [ + { id: "31337", filename: "скрин 1.png", created: "2026-10-15T12:00:00.000+0300" }, + { id: "31338", filename: "a.pdf", created: null }, + { id: "10", filename: "дубль.png", created: "2026-10-14T12:00:00.000+0300" }, + { id: "20", filename: "дубль.png", created: "2026-10-20T12:00:00.000+0300" }, + ]; + const A = { attachments: atts }; + const att = (id: string, ...children: Inline[]): Inline => ({ t: "attachment", id, children }); + + it("!имя|параметры! и !имя!", () => { + expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]); + expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]); + }); + it("[^имя] и [текст|^имя]", () => { + expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]); + expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]); + }); + it("имя точно (с регистром); несколько с одним именем — самое новое", () => { + expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]); + expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]); + }); + it("имени нет среди вложений — исходник как есть", () => { + expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); + expect(parseInline("[^нет.pdf] и [x|^нет.pdf]", A)).toEqual([t("[^нет.pdf] и [x|^нет.pdf]")]); + }); + it("внешняя картинка не вложение", () => { + expect(JSON.stringify(parseInline("!http://x/скрин 1.png!", { attachments: [{ id: "1", filename: "http://x/скрин 1.png", created: null }] }))).not.toContain("attachment"); + }); + it("без опции attachments — как раньше", () => { + expect(parseInline("!скрин 1.png!")).toEqual([t("!скрин 1.png!")]); + expect(parseInline("[^a.pdf]")).toEqual([t("[^a.pdf]")]); + }); + it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => { + expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]); + }); +}); diff --git a/web/src/issues/wiki/inline.ts b/web/src/issues/wiki/inline.ts index 94873d7..abfb5a8 100644 --- a/web/src/issues/wiki/inline.ts +++ b/web/src/issues/wiki/inline.ts @@ -1,7 +1,9 @@ // Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. // Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. -export interface WikiOptions { projectKey?: string } +// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое) +export interface WikiAttachment { id: string; filename: string; created: string | null } +export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] } type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub"; @@ -11,6 +13,7 @@ export type Inline = | { t: "code"; text: string } | { t: "link"; href: string; children: Inline[] } | { t: "issue"; key: string; children: Inline[] } + | { t: "attachment"; id: string; children: Inline[] } | { t: "mention"; login: string } | { t: "br" }; @@ -20,6 +23,8 @@ const WORD = /[\p{L}\p{N}]/u; const SPACE = /\s/; const URL = /https?:\/\/[^\s\]|<>"]+/y; const URL_TAIL = /[.,;:!?)]+$/; +const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y; +const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/; // схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет function safeHref(raw: string): string | null { @@ -27,6 +32,18 @@ function safeHref(raw: string): string | null { return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null; } +// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id +function findAttachment(opts: WikiOptions, name: string): string | null { + const same = (opts.attachments ?? []).filter((a) => a.filename === name); + if (!same.length) return null; + const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0]; + return same.reduce((best, a) => { + const [bt, bi] = rank(best); + const [at, ai] = rank(a); + return at > bt || (at === bt && ai > bi) ? a : best; + }).id; +} + function keyPattern(projectKey: string | undefined): RegExp | null { if (!projectKey) return null; const escaped = projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -71,6 +88,13 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false) text(c); i += 1; continue; } + if (c === "!" && !label && opts.attachments?.length) { + IMAGE.lastIndex = i; + const m = IMAGE.exec(line); + const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null; + if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } + } + if (c === "h" && !label && prevOk(i)) { URL.lastIndex = i; const m = URL.exec(line); @@ -126,6 +150,11 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean) const login = body.slice(1).trim(); return login && !SPACE.test(login) ? { t: "mention", login } : null; } + const ref = ATT_LINK.exec(body); + if (ref) { + const id = findAttachment(opts, ref[2]); + return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null; + } const bar = body.indexOf("|"); const label = bar >= 0 ? body.slice(0, bar) : ""; const target = (bar >= 0 ? body.slice(bar + 1) : body).trim(); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 252d6b9..81b232a 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -37,3 +37,8 @@ export const IconList = make(<>); export const IconHistory = make(<>); export const IconSidebar = make(<>); +export const IconPaperclip = make(<>); +export const IconDownload = make(<>); +export const IconFile = make(<>); +export const IconImage = make(<>); +export const IconSheet = make(<>);