Вики: скачанные картинки показываются в тексте страницы (#135)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:47:35 +04:00
parent 829d4cbb4b
commit 952398bd9a
6 changed files with 159 additions and 18 deletions

View File

@ -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<Components>(() => ({
...HEADINGS,
a({ href, children }: ComponentProps<"a">) {
if (!href) return <>{children}</>;
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
if (/^attachment:/i.test(href)) return <AttachmentLink id={decode(href.slice(11))} dl={dl} failed={failed}>{children}</AttachmentLink>;
if (/^attachment:/i.test(href)) {
const id = decode(href.slice(11));
return <AttachmentLink id={id} att={byId.get(id)} dl={dl} failed={failed}>{children}</AttachmentLink>;
}
if (/^https?:/i.test(href)) return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
return <a href={href}>{children}</a>;
},
// картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст
img: ({ alt }: ComponentProps<"img">) => <span className="wiki-img">[изображение{alt ? `: ${alt}` : ""}]</span>,
table: ({ children }: ComponentProps<"table">) => <div className="wiki-table"><table>{children}</table></div>,
}), [hrefOf, dl, failed]);
}), [hrefOf, dl, failed, byId]);
if (page.isPending) return <p className="wiki-hint">Загрузка страницы…</p>;
if (page.isError) return <Notice tone="error">{page.error.message}</Notice>;
@ -66,15 +70,19 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str
}
// ссылка attachment:<id>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика
function AttachmentLink({ id, dl, failed, children }: {
id: string; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; children?: ReactNode;
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика.
// Картинка (att.image, #135): нажатие скачивает и показывает её в тексте; полученная — картинкой, под ней ссылка сохранения
function AttachmentLink({ id, att, dl, failed, children }: {
id: string; att?: WikiAttachment; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; 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 (
<>
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id)}>
{src && <span className="wiki-att-img"><img src={src} alt={att?.name ?? ""} /></span>}
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id, mode)}>
<IconPaperclip size={16} />{children}{busy && <span className="wiki-att__busy"> Загружается…</span>}
</button>
{error && <span className="wiki-att__error" role="alert"> {error}</span>}

View File

@ -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");

View File

@ -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[];
}

View File

@ -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<Response>((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);
});
});

View File

@ -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<string, string> = { 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<Record<string, DownloadStatus>>({});
const [images, setImages] = useState<Record<string, string>>({});
const mapRef = useRef(map);
const imagesRef = useRef(images);
const requested = useRef(new Set<string>());
const gen = useRef(0);
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
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<WikiAttachmentState>(`${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]);
}

View File

@ -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)}