From 952398bd9a7176bdb264bad5c634299e2be08011 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:47:35 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B8=D0=BA=D0=B8:=20=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/modules/wiki/PageView.tsx | 24 ++++++--- web/src/modules/wiki/WikiPage.test.tsx | 22 ++++++++ web/src/modules/wiki/api/queries.ts | 2 +- web/src/modules/wiki/download.test.tsx | 69 ++++++++++++++++++++++++++ web/src/modules/wiki/download.ts | 57 +++++++++++++++++---- web/src/modules/wiki/wiki.css | 3 ++ 6 files changed, 159 insertions(+), 18 deletions(-) diff --git a/web/src/modules/wiki/PageView.tsx b/web/src/modules/wiki/PageView.tsx index d8f05c0..902c18f 100644 --- a/web/src/modules/wiki/PageView.tsx +++ b/web/src/modules/wiki/PageView.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router"; import remarkGfm from "remark-gfm"; import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts"; import { fmtStamp } from "../../ui/format.ts"; -import { useWikiPage } from "./api/queries.ts"; +import { useWikiPage, type WikiAttachment } from "./api/queries.ts"; import { useWikiDownload } from "./download.ts"; import { wikiUrlTransform } from "./urlTransform.ts"; @@ -26,23 +26,27 @@ const decode = (s: string) => { try { return decodeURIComponent(s); } catch { re // страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML) export function PageView({ project, code, hrefOf }: { project: string; code: string; hrefOf(code: string): string }) { const page = useWikiPage(project, code); - const dl = useWikiDownload(project, code); const atts = page.data?.attachments; + const dl = useWikiDownload(project, code, atts); const failed = useMemo(() => new Map((atts ?? []).filter((a) => a.state === "failed" && a.error).map((a) => [String(a.id), a.error!])), [atts]); + const byId = useMemo(() => new Map((atts ?? []).map((a) => [String(a.id), a])), [atts]); const components = useMemo(() => ({ ...HEADINGS, a({ href, children }: ComponentProps<"a">) { if (!href) return <>{children}; if (/^page:/i.test(href)) return {children}; - if (/^attachment:/i.test(href)) return {children}; + if (/^attachment:/i.test(href)) { + const id = decode(href.slice(11)); + return {children}; + } if (/^https?:/i.test(href)) return {children}; return {children}; }, // картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст img: ({ alt }: ComponentProps<"img">) => [изображение{alt ? `: ${alt}` : ""}], table: ({ children }: ComponentProps<"table">) =>
{children}
, - }), [hrefOf, dl, failed]); + }), [hrefOf, dl, failed, byId]); if (page.isPending) return

Загрузка страницы…

; if (page.isError) return {page.error.message}; @@ -66,15 +70,19 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str } // ссылка attachment:: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом -// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика -function AttachmentLink({ id, dl, failed, children }: { - id: string; dl: ReturnType; failed: Map; children?: ReactNode; +// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика. +// Картинка (att.image, #135): нажатие скачивает и показывает её в тексте; полученная — картинкой, под ней ссылка сохранения +function AttachmentLink({ id, att, dl, failed, children }: { + id: string; att?: WikiAttachment; dl: ReturnType; failed: Map; children?: ReactNode; }) { const st = dl.status(id); const busy = st.busy, error = st.error ?? (busy ? null : failed.get(id) ?? null); + const src = att?.image ? dl.image(id) : null; + const mode = att?.image && !src ? "show" : "save"; return ( <> - {error && {error}} diff --git a/web/src/modules/wiki/WikiPage.test.tsx b/web/src/modules/wiki/WikiPage.test.tsx index 960592a..b189e5c 100644 --- a/web/src/modules/wiki/WikiPage.test.tsx +++ b/web/src/modules/wiki/WikiPage.test.tsx @@ -162,6 +162,28 @@ describe("страница «Вики»", () => { expect(await screen.findByText(/Eva: 404/)).toBeInTheDocument(); }); + it("#135: скачанная картинка видна в тексте, под ней ссылка сохранения", async () => { + Object.assign(URL, { createObjectURL: () => "blob:img", revokeObjectURL: () => {} }); + const calls = hub((_m, url) => url.pathname === "/wiki/pages/DOC-3" + ? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "Схема", state: "ready", size: 3, error: null, image: true }] }) + : url.pathname === "/wiki/attachments/7/content" ? new Response("png") : undefined); + open("/wiki?project=AWPCA&page=DOC-3"); + const pic = await screen.findByRole("img", { name: "Схема" }); + expect(pic).toHaveAttribute("src", "blob:img"); + expect(within(screen.getByRole("article")).getByRole("button", { name: /скачать/ })).toBeInTheDocument(); + expect(calls.some((c) => c.method === "POST")).toBe(false); + Reflect.deleteProperty(URL, "createObjectURL"); Reflect.deleteProperty(URL, "revokeObjectURL"); + }); + + it("#135: не картинка в ready — ссылка без картинки", async () => { + hub((_m, url) => url.pathname === "/wiki/pages/DOC-3" + ? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "model.xlsx", state: "ready", size: 3, error: null, image: false }] }) + : undefined); + open("/wiki?project=AWPCA&page=DOC-3"); + expect(await within(await screen.findByRole("article")).findByRole("button", { name: /скачать/ })).toBeInTheDocument(); + expect(screen.queryByRole("img")).toBeNull(); + }); + it("ссылка page: открывает страницу", async () => { hub(); const { router } = open("/wiki?project=AWPCA&page=DOC-3"); diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index 0b02400..fd6cf2c 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -7,7 +7,7 @@ export interface WikiStatus { root: string; root_title: string | null; synced_at: string | null; pages: number; error: string | null; batch: WikiBatch | null; } export interface WikiNode { code: string; title: string | null; depth: number; parent_code: string | null; size: number | null } -export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null } +export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null; image?: boolean } export interface WikiPageData { code: string; title: string | null; path: string; url: string | null; synced_at: string | null; markdown: string; attachments: WikiAttachment[]; } diff --git a/web/src/modules/wiki/download.test.tsx b/web/src/modules/wiki/download.test.tsx index b07fe22..c2ab2c5 100644 --- a/web/src/modules/wiki/download.test.tsx +++ b/web/src/modules/wiki/download.test.tsx @@ -81,4 +81,73 @@ describe("useWikiDownload", () => { expect(result.current.status("7")).toEqual({ busy: false, error: ATT_TIMEOUT_TEXT }); expect(gets).toBe(ATT_MAX_POLLS); }); + + // пакет D (#135): картинки в тексте страницы + const img = (state: string, extra: object = {}) => ({ id: 7, name: "scheme.png", state, size: 3, error: null, image: true, ...extra }); + + it("ready-картинка: content запрошен при открытии, fetch не ставится, image(id) — objectURL", async () => { + const f = mockFetch((_m, url) => (url.pathname.endsWith("/content") ? new Response("png") : json(404, {}))); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")])); + await flush(); + expect(result.current.image("7")).toBe("blob:fake"); + expect(f.mock.calls.filter(([u]) => String(u) === "/wiki/attachments/7/content")).toHaveLength(1); + expect(f.mock.calls.some(([, i]) => (i as RequestInit | undefined)?.method === "POST")).toBe(false); + expect(clicked).toEqual([]); + }); + + it("режим show: fetch → ready → картинка, файл не сохраняется", async () => { + mockFetch((m, url) => { + if (m === "POST") return json(202, st("fetching", { name: "scheme.png" })); + if (url.pathname.endsWith("/content")) return new Response("png"); + return json(200, st("ready", { name: "scheme.png" })); + }); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("none")])); + await flush(); + expect(result.current.image("7")).toBeNull(); + act(() => result.current.start("7", "show")); + await tick(ATT_POLL_MS); + expect(result.current.image("7")).toBe("blob:fake"); + expect(result.current.status("7")).toEqual({ busy: false, error: null }); + expect(clicked).toEqual([]); + }); + + it("content 409 у ready-картинки — картинки нет, ошибки нет", async () => { + mockFetch((_m, url) => (url.pathname.endsWith("/content") ? json(409, { error: "not_ready" }) : json(404, {}))); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")])); + await flush(); + expect(result.current.image("7")).toBeNull(); + expect(result.current.status("7")).toEqual({ busy: false, error: null }); + }); + + it("смена страницы: поздний content не кладёт картинку, objectURL отозваны", async () => { + let release: (r: Response) => void = () => {}; + mockFetch((_m, url) => { + if (url.pathname === "/wiki/attachments/7/content") return new Response("png"); + if (url.pathname === "/wiki/attachments/9/content") return new Promise((r) => { release = r; }); + return json(404, {}); + }); + const { result, rerender } = renderHook(({ scope, atts }) => useWikiDownload("AWPCA", scope, atts), + { initialProps: { scope: "DOC-1", atts: [img("ready"), img("ready", { id: 9 })] } }); + await flush(); + expect(result.current.image("7")).toBe("blob:fake"); + rerender({ scope: "DOC-2", atts: [] }); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); + release(new Response("late")); + await flush(); + expect(result.current.image("9")).toBeNull(); + expect(result.current.image("7")).toBeNull(); + }); + + it("появление картинки не сбрасывает busy другого вложения", async () => { + mockFetch((m, url) => { + if (url.pathname === "/wiki/attachments/7/content") return new Response("png"); + if (m === "POST") return json(202, st("fetching", { id: 8 })); + return json(200, st("fetching", { id: 8 })); + }); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")])); + act(() => result.current.start("8")); + await flush(); + expect(result.current.image("7")).toBe("blob:fake"); + expect(result.current.status("8").busy).toBe(true); + }); }); diff --git a/web/src/modules/wiki/download.ts b/web/src/modules/wiki/download.ts index 5229217..9a7887a 100644 --- a/web/src/modules/wiki/download.ts +++ b/web/src/modules/wiki/download.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, ApiError } from "../../api/client.ts"; -import type { WikiAttachmentState } from "./api/queries.ts"; +import type { WikiAttachment, WikiAttachmentState } from "./api/queries.ts"; export const ATT_POLL_MS = 2000; export const ATT_MAX_POLLS = 60; // 60 × 2 с = 2 минуты: дольше агент, скорее всего, не ответит @@ -20,29 +20,65 @@ function save(blob: Blob, filename: string) { setTimeout(() => URL.revokeObjectURL(url), 60_000); } +// хаб отдаёт содержимое как application/octet-stream: картинке — тип по расширению имени, без расширения браузер определит сам +const MIME_BY_EXT: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" }; +function asImage(blob: Blob, name: string | null | undefined): Blob { + const type = MIME_BY_EXT[(/\.([a-z0-9]+)$/i.exec(name ?? "")?.[1] ?? "").toLowerCase()]; + return type ? new Blob([blob], { type }) : blob; +} + const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e); // скачивание вложений страницы вики: запрос (POST …/fetch), опрос состояния, затем содержимое; состояние — на вложение. -// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба -export function useWikiDownload(project: string, scope: string) { +// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба. +// Картинки (image у вложения, #135): mode "show" показывает файл в тексте вместо сохранения; картинки в ready страница +// получает сама при открытии — только содержимое, без нового скачивания агентом. objectURL живут до смены страницы +export function useWikiDownload(project: string, scope: string, attachments?: WikiAttachment[]) { const [map, setMap] = useState>({}); + const [images, setImages] = useState>({}); const mapRef = useRef(map); + const imagesRef = useRef(images); + const requested = useRef(new Set()); 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); }, []); + const putImage = useCallback((id: string, blob: Blob, name: string | null | undefined) => { + const url = URL.createObjectURL(asImage(blob, name)); + const old = imagesRef.current[id]; + if (old) URL.revokeObjectURL(old); + imagesRef.current = { ...imagesRef.current, [id]: url }; + setImages(imagesRef.current); + }, []); useEffect(() => { - const pending = timers.current, counter = gen; + const pending = timers.current, counter = gen, asked = requested.current; return () => { counter.current++; for (const t of pending) clearTimeout(t); pending.clear(); + asked.clear(); + for (const url of Object.values(imagesRef.current)) URL.revokeObjectURL(url); + imagesRef.current = {}; + setImages({}); mapRef.current = {}; setMap({}); }; }, [project, scope]); - const start = useCallback((id: string) => { + // картинки в ready — один запрос содержимого на вложение за жизнь страницы; неудача (409, сеть) оставляет ссылку + useEffect(() => { + const my = gen.current; + for (const a of attachments ?? []) { + const id = String(a.id); + if (!a.image || a.state !== "ready" || requested.current.has(id)) continue; + requested.current.add(id); + api.blob(`/wiki/attachments/${encodeURIComponent(id)}/content`) + .then((blob) => { if (gen.current === my) putImage(id, blob, a.name); }) + .catch(() => {}); + } + }, [attachments, putImage]); + + const start = useCallback((id: string, mode: "save" | "show" = "save") => { if (mapRef.current[id]?.busy) return; const my = gen.current, alive = () => gen.current === my; const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; @@ -53,7 +89,8 @@ export function useWikiDownload(project: string, scope: string) { if (st.state === "ready") { const blob = await api.blob(`${base}/content`); if (!alive()) return; - save(blob, (st.name ?? "").trim() || `attachment-${id}`); + if (mode === "show") putImage(id, blob, st.name); + else save(blob, (st.name ?? "").trim() || `attachment-${id}`); put(id, IDLE); } else if (st.state === "fetching") { if (++polls > ATT_MAX_POLLS) return fail(ATT_TIMEOUT_TEXT); @@ -69,9 +106,11 @@ export function useWikiDownload(project: string, scope: string) { }; put(id, { busy: true, error: null }); api.post(`${base}/fetch`, { author: "web" }).then(handle).catch((e) => fail(errorText(e))); - }, [put]); + }, [put, putImage]); const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); - // объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует вложения - return useMemo(() => ({ start, status }), [start, status]); + const image = useCallback((id: string): string | null => images[id] ?? null, [images]); + // объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует + // вложения (их состояние — здесь, в хуке, и перемонтирование его не сбрасывает) + return useMemo(() => ({ start, status, image }), [start, status, image]); } diff --git a/web/src/modules/wiki/wiki.css b/web/src/modules/wiki/wiki.css index 25ed012..e80ab72 100644 --- a/web/src/modules/wiki/wiki.css +++ b/web/src/modules/wiki/wiki.css @@ -56,6 +56,9 @@ .wiki-att:hover:not(:disabled){text-decoration:underline} .wiki-att:disabled{cursor:default;color:var(--color-text-secondary)} .wiki-att:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px} +/* картинка-вложение в тексте страницы (#135): не шире колонки, под ней ссылка сохранения */ +.wiki-att-img{display:block;margin-block:var(--space-2) var(--space-1)} +.wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)} .wiki-att__busy{color:var(--color-text-secondary)} .wiki-att__error{color:var(--color-status-error)}