Веб: страница «Вики» (#91)

Модуль wiki: дерево страниц, страница с Markdown (react-markdown, remark-gfm, без сырого HTML),
поиск, форма привязки корня, ход синхронизации в боковой панели, скачивание вложений,
поле «Корень вики» в окне нового проекта. Сид копии вики и e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 03:09:32 +04:00
parent c244b3a8c1
commit 48ca56c07f
21 changed files with 2237 additions and 13 deletions

35
web/e2e/wiki.spec.ts Normal file
View File

@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
// копия вики проекта AWPCA засеяна scripts/seed-hub.py: корень и две страницы (одна с вложением)
test("вики: дерево, страница, поиск", async ({ page }) => {
await login(page, "/app/wiki?project=AWPCA");
const tree = page.getByRole("tree", { name: "Страницы вики" });
await expect(tree.getByRole("treeitem")).toHaveCount(3);
await tree.getByRole("treeitem", { name: "Регламент согласования" }).click();
await expect(page).toHaveURL(/page=DOC-9003/);
const article = page.getByRole("article");
await expect(article.getByRole("heading", { name: "Регламент согласования" })).toBeVisible();
await expect(article.getByRole("button", { name: /схема\.txt/ })).toBeVisible();
// ссылка на другую страницу вики меняет адрес и выделенный узел
await article.getByRole("link", { name: "К модели" }).click();
await expect(page).toHaveURL(/page=DOC-9002/);
await expect(tree.getByRole("treeitem", { name: "Модель данных" })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("article").locator("table")).toBeVisible();
await page.getByRole("searchbox", { name: "Поиск по вики" }).fill("согласов");
await expect(tree).toHaveCount(0);
await expect(page.getByRole("list", { name: "Результаты поиска" }).getByRole("link")).not.toHaveCount(0);
await page.getByRole("list", { name: "Результаты поиска" }).getByRole("link", { name: /Регламент согласования/ }).click();
await expect(page).toHaveURL(/page=DOC-9003/);
});
test("вики: вложение скачивается через запрос к агенту", async ({ page }) => {
await login(page, "/app/wiki?project=AWPCA&page=DOC-9003");
const req = page.waitForRequest((r) => r.method() === "POST" && /\/wiki\/attachments\/\d+\/fetch$/.test(r.url()));
await page.getByRole("article").getByRole("button", { name: /схема\.txt/ }).click();
await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл
await expect(page.getByRole("article").getByText("Загружается…")).toBeVisible();
});

1491
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,9 @@
"@tanstack/react-query": "5.104.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0"
"react-markdown": "10.1.0",
"react-router": "8.4.0",
"remark-gfm": "4.0.1"
},
"devDependencies": {
"@eslint/js": "10.0.1",

View File

@ -7,6 +7,7 @@ from pathlib import Path
from jira_hub.core import db, paths, settings
from jira_hub.modules.jira import mirror
from jira_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
extras_path = Path(__file__).with_name("seed-extras.json")
@ -38,6 +39,29 @@ if extras.get("commands"): # завершённые команды по
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
c.get("error")))
# копия вики проекта AWPCA для e2e (тексты синтетические): корень DOC-9001 и две страницы, у одной вложение.
# Собирается тем же build_copy, что и настоящая синхронизация, — без команд и без агента
WIKI_PAGES = [
{"code": "DOC-9001", "title": "Тестовая вики", "parent_code": None, "orderno": 1, "url": "https://eva.test/DOC-9001",
"markdown": "Корневая страница тестовой вики.\n\n[Модель данных](page:DOC-9002)\n", "attachments": []},
{"code": "DOC-9002", "title": "Модель данных", "parent_code": "DOC-9001", "orderno": 1, "url": "https://eva.test/DOC-9002",
"markdown": "# Таблицы\n\n| Поле | Смысл |\n|---|---|\n| id | ключ |\n\nСогласование схемы проводится раз в квартал.\n",
"attachments": []},
{"code": "DOC-9003", "title": "Регламент согласования", "parent_code": "DOC-9001", "orderno": 2, "url": "https://eva.test/DOC-9003",
"markdown": "Порядок согласования описан в файле.\n\n[📎 схема.txt](attachment:@0)\n\n[К модели](page:DOC-9002)\n",
"attachments": [{"url": "https://eva.test/files/schema.txt", "name": "схема.txt"}]},
]
conn.execute("INSERT OR REPLACE INTO wiki_project(label, root_code, root_title, synced_at, pages_count) VALUES ('AWPCA', 'DOC-9001', 'Тестовая вики', ?, ?)",
(db.now(), len(WIKI_PAGES)))
att_id = conn.execute("INSERT INTO wiki_attachment(label, page_code, url, name) VALUES ('AWPCA', 'DOC-9003', ?, 'схема.txt')",
("https://eva.test/files/schema.txt",)).lastrowid
wiki_rows = wiki_sync.build_copy(WIKI_PAGES, [p["code"] for p in WIKI_PAGES], wiki_paths.project_dir(conn, "AWPCA"),
{("DOC-9003", "https://eva.test/files/schema.txt"): att_id})
for r in wiki_rows:
conn.execute("INSERT INTO wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at) "
"VALUES ('AWPCA', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"], r["size"], r["synced_at"]))
wiki_search.replace(conn, "AWPCA", [(r["code"], r["title"], r["markdown"]) for r in wiki_rows])
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
conn.close()
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")

View File

@ -1,8 +1,9 @@
import { jiraModule } from "./jira/index.tsx";
import { wikiModule } from "./wiki/index.tsx";
import type { HubModule, Overlay } from "./types.ts";
// список модулей — константа: хуки модулей вызываются в постоянном порядке на каждой отрисовке
export const MODULES: readonly HubModule[] = [jiraModule];
export const MODULES: readonly HubModule[] = [jiraModule, wikiModule];
const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []);
const CREATED_HOOKS = MODULES.flatMap((m) => m.useOnProjectCreated ?? []);

View File

@ -3,13 +3,13 @@ import { PAGE_ROUTES } from "../shell/pages.tsx";
import { MODULES } from "./index.ts";
describe("сборка оболочки из модулей", () => {
it("страницы: адреса прежние, NotFound последним", () => {
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/board", "/issues", "/feed", "*"]);
it("страницы: адреса прежние плюс вики, NotFound последним", () => {
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/board", "/issues", "/feed", "/wiki", "*"]);
});
it("пункты панели: подписи и адреса прежние", () => {
it("пункты панели: подписи и адреса прежние плюс вики", () => {
expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([
["Доска связей", "/board"], ["Задачи", "/issues"], ["Лента", "/feed"],
["Доска связей", "/board"], ["Задачи", "/issues"], ["Лента", "/feed"], ["Вики", "/wiki"],
]);
});

View File

@ -0,0 +1,70 @@
import { useMemo, type ComponentProps, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts";
import { fmtStamp } from "../jira/board/model/util.ts";
import { useWikiPage } from "./api/queries.ts";
import { useWikiDownload } from "./download.ts";
import { wikiUrlTransform } from "./urlTransform.ts";
const PLUGINS = [remarkGfm];
const safeUrl = (u: string | null) => (u && /^https?:\/\//i.test(u.trim()) ? u : null);
// копия начинается заголовком «# название» — он повторяет заголовок страницы и в тексте не нужен
function withoutTitle(md: string, title: string | null): string {
const m = /^# ([^\n]*)\n+/.exec(md);
return m && title !== null && m[1].trim() === title.split(/\s+/).filter(Boolean).join(" ") ? md.slice(m[0].length) : md;
}
const decode = (s: string) => { try { return decodeURIComponent(s); } catch { return s; } };
// страница вики: заголовок, дата синхронизации, «открыть в 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 components = useMemo<Components>(() => ({
a({ href, children }: ComponentProps<"a">) {
if (!href) return <>{children}</>;
if (href.startsWith("page:")) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
if (href.startsWith("attachment:")) return <AttachmentLink id={decode(href.slice(11))} dl={dl}>{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]);
if (page.isPending) return <p className="wiki-hint">Загрузка страницы…</p>;
if (page.isError) return <Notice tone="error">{page.error.message}</Notice>;
const d = page.data;
const eva = safeUrl(d.url);
return (
<article className="wiki-page" aria-labelledby="wiki-page-title">
<header className="wiki-page__head">
<h2 id="wiki-page-title" className="wiki-page__title">{d.title || d.code}</h2>
<p className="wiki-page__meta">
<span className="wiki-page__code">{d.code}</span>
{d.synced_at && <span>синхронизировано {fmtStamp(d.synced_at)}</span>}
{eva && <a href={eva} target="_blank" rel="noopener noreferrer" className="wiki-page__eva"><IconExternal size={16} />открыть в Eva</a>}
</p>
</header>
<div className="wiki-md">
<Markdown remarkPlugins={PLUGINS} urlTransform={wikiUrlTransform} components={components}>{withoutTitle(d.markdown, d.title)}</Markdown>
</div>
</article>
);
}
// ссылка attachment:<id>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
function AttachmentLink({ id, dl, children }: { id: string; dl: ReturnType<typeof useWikiDownload>; children?: ReactNode }) {
const { busy, error } = dl.status(id);
return (
<>
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id)}>
<IconPaperclip size={16} />{children}{busy && <span className="wiki-att__busy"> Загружается…</span>}
</button>
{error && <span className="wiki-att__error" role="alert"> {error}</span>}
</>
);
}

View File

@ -0,0 +1,36 @@
import { useState, type FormEvent } from "react";
import { ApiError } from "../../api/client.ts";
import { useUpdateProject } from "../../api/queries.ts";
import { useQueryClient } from "@tanstack/react-query";
import { Button, Field, Panel, TextInput } from "../../ui/index.ts";
import { wikiQk } from "./api/queries.ts";
// проект без корня вики: ввод кода или ссылки, «Привязать» — PATCH /projects/{метка}; 422 — текст у поля
export function RootForm({ project }: { project: string }) {
const [value, setValue] = useState("");
const qc = useQueryClient();
const update = useUpdateProject();
const err = update.error;
const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null;
const submit = (e: FormEvent) => {
e.preventDefault();
if (!value.trim()) return;
update.mutate({ label: project, fields: { wiki_root: value.trim() } }, {
onSuccess: () => void qc.invalidateQueries({ queryKey: wikiQk.project(project) }),
});
};
return (
<Panel padding="lg" className="wiki-root">
<form className="ui-form" onSubmit={submit}>
<h2 className="wiki-root__title">У проекта нет вики</h2>
<p className="wiki-root__text">Укажите корневую страницу вики проекта в Eva: хаб скопирует её дерево.</p>
<Field label="Корень вики (DOC-… или ссылка)" hint="например DOC-001234" error={text}>
<TextInput value={value} onChange={(e) => setValue(e.target.value)} autoComplete="off" />
</Field>
<div className="ui-actions">
<Button variant="primary" type="submit" loading={update.isPending} disabled={!value.trim()}>Привязать</Button>
</div>
</form>
</Panel>
);
}

View File

@ -0,0 +1,30 @@
import { Link } from "react-router";
import { Notice, SearchInput } from "../../ui/index.ts";
import type { WikiHit } from "./api/queries.ts";
export const SEARCH_LABEL = "Поиск по вики";
export function SearchBox({ value, onChange }: { value: string; onChange(v: string): void }) {
return <SearchInput label={SEARCH_LABEL} placeholder="Поиск по вики" value={value} onChange={onChange} />;
}
// результаты поиска вместо дерева: название и фрагмент; пока строка поиска не пуста
export function SearchResults({ hits, pending, error, hrefOf, selected }: {
hits: WikiHit[] | undefined; pending: boolean; error: Error | null; hrefOf(code: string): string; selected: string | null;
}) {
if (error) return <Notice tone="error">{error.message}</Notice>;
if (pending || !hits) return <p className="wiki-hint">Поиск…</p>;
if (!hits.length) return <p className="wiki-hint">Ничего не найдено</p>;
return (
<ul className="wiki-hits" aria-label="Результаты поиска">
{hits.map((h) => (
<li key={h.code}>
<Link to={hrefOf(h.code)} className="wiki-hit" aria-current={h.code === selected ? "page" : undefined}>
<span className="wiki-hit__title">{h.title || h.code}</span>
{h.snippet && <span className="wiki-hit__snippet">{h.snippet}</span>}
</Link>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,28 @@
import { useProjects } from "../../api/queries.ts";
import { fmtStamp } from "../jira/board/model/util.ts";
import { useWikiStatus } from "./api/queries.ts";
// ход синхронизации вики текущего проекта в боковой панели; запрос — только у проекта с корнем (по списку проектов)
export function SidebarStatus({ project, compact }: { project: string | null; compact: boolean }) {
const projects = useProjects();
const hasRoot = !!project && !!projects.data?.find((p) => p.label === project)?.wiki_root;
const status = useWikiStatus(project ?? "", hasRoot);
const s = status.data;
if (!hasRoot || !s) return null;
let text: string, tone: "info" | "error" | "success" | "plain" = "plain";
if (s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled") {
text = `Вики: пакет #${s.batch.id} ${s.batch.status === "queued" ? "в очереди" : "выполняется"}…`; tone = "info";
} else if (s.error) {
text = `Вики: синхронизация не удалась — ${s.error}`; tone = "error";
} else if (s.synced_at) {
text = `Вики на ${fmtStamp(s.synced_at)}`;
} else {
text = "Вики ещё не синхронизирована";
}
return (
<div className={`data-status wiki-status wiki-status--${tone}`}>
<span className={compact ? "visually-hidden" : "wiki-status__text"} role={tone === "plain" ? undefined : "status"} title={text}>{text}</span>
{compact && tone !== "plain" && <span className={`data-status__dot wiki-status__dot wiki-status__dot--${tone}`} aria-hidden="true" title={text} />}
</div>
);
}

View File

@ -0,0 +1,69 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { IconChevronDown } from "../../ui/index.ts";
import type { WikiNode } from "./api/queries.ts";
// дерево страниц (WAI-ARIA tree, плоская раскладка с aria-level): стрелки вверх/вниз — по видимым узлам, вправо — раскрыть
// или войти в узел, влево — свернуть или выйти к родителю, Enter/пробел — открыть страницу. Корень раскрыт, остальные свёрнуты;
// предки открытой страницы раскрываются сами
export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: string | null; onOpen(code: string): void }) {
const byCode = useMemo(() => new Map(nodes.map((n) => [n.code, n])), [nodes]);
const hasKids = useMemo(() => new Set(nodes.flatMap((n) => (n.parent_code ? [n.parent_code] : []))), [nodes]);
const [open, setOpen] = useState<Set<string>>(() => new Set(nodes.filter((n) => n.depth === 0).map((n) => n.code)));
const [focus, setFocus] = useState<string | null>(null);
const refs = useRef(new Map<string, HTMLDivElement>());
// предки выбранной страницы раскрыты
useEffect(() => {
if (!selected) return;
const up: string[] = [];
for (let n = byCode.get(selected)?.parent_code; n && !up.includes(n); n = byCode.get(n)?.parent_code ?? null) up.push(n);
if (up.some((c) => !open.has(c))) setOpen((o) => new Set([...o, ...up]));
}, [selected, byCode, open]);
const visible = useMemo(() => nodes.filter((n) => {
for (let p = n.parent_code, guard = 0; p && guard < nodes.length; p = byCode.get(p)?.parent_code ?? null, guard++) {
if (!open.has(p)) return false;
}
return true;
}), [nodes, byCode, open]);
const tabbable = visible.find((n) => n.code === focus)?.code ?? visible.find((n) => n.code === selected)?.code ?? visible[0]?.code;
const toggle = (code: string) => setOpen((o) => { const n = new Set(o); if (n.has(code)) n.delete(code); else n.add(code); return n; });
const move = (code: string | undefined) => { if (!code) return; setFocus(code); refs.current.get(code)?.focus(); };
const onKey = (e: KeyboardEvent, n: WikiNode, i: number) => {
const kids = hasKids.has(n.code);
switch (e.key) {
case "ArrowDown": move(visible[i + 1]?.code); break;
case "ArrowUp": move(visible[i - 1]?.code); break;
case "Home": move(visible[0]?.code); break;
case "End": move(visible[visible.length - 1]?.code); break;
case "ArrowRight": if (kids && !open.has(n.code)) toggle(n.code); else if (kids) move(visible[i + 1]?.code); break;
case "ArrowLeft": if (kids && open.has(n.code)) toggle(n.code); else if (n.parent_code) move(n.parent_code); break;
case "Enter": case " ": onOpen(n.code); break;
default: return;
}
e.preventDefault();
};
return (
<div role="tree" aria-label="Страницы вики" className="wiki-tree">
{visible.map((n, i) => {
const kids = hasKids.has(n.code);
const sel = n.code === selected;
return (
<div key={n.code} role="treeitem" aria-level={n.depth + 1} aria-selected={sel} aria-expanded={kids ? open.has(n.code) : undefined}
tabIndex={n.code === tabbable ? 0 : -1} data-code={n.code} className={"wiki-tree__item" + (sel ? " is-selected" : "")}
style={{ paddingInlineStart: `calc(var(--space-2) + ${n.depth} * var(--space-4))` }}
ref={(el) => { if (el) refs.current.set(n.code, el); else refs.current.delete(n.code); }}
onFocus={() => setFocus(n.code)} onKeyDown={(e) => onKey(e, n, i)}
onClick={() => { onOpen(n.code); if (kids && !open.has(n.code)) toggle(n.code); }}>
<span className={"wiki-tree__chevron" + (kids ? (open.has(n.code) ? " is-open" : "") : " is-leaf")} aria-hidden="true"
onClick={kids ? (e) => { e.stopPropagation(); toggle(n.code); } : undefined}>{kids && <IconChevronDown size={16} />}</span>
<span className="wiki-tree__title">{n.title || n.code}</span>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,99 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../../auth/token.ts";
import { json, mockFetch } from "../../test/fetchMock.ts";
import { renderWithProviders } from "../../test/render.tsx";
import { WikiPage } from "./WikiPage.tsx";
const STATUS = { root: "DOC-1", root_title: "Корень", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null, batch: null };
const TREE = { pages: [
{ code: "DOC-1", title: "Корень", depth: 0, parent_code: null, size: 10 },
{ code: "DOC-2", title: "Модель данных", depth: 1, parent_code: "DOC-1", size: 10 },
{ code: "DOC-3", title: "Регламент", depth: 1, parent_code: "DOC-1", size: 10 },
] };
const PAGES: Record<string, string> = {
"DOC-2": "Текст модели данных",
"DOC-3": "Смотри [Модель данных](page:DOC-2) и [скачать](attachment:7)",
};
const page = (code: string, markdown = PAGES[code] ?? "") => ({
code, title: TREE.pages.find((p) => p.code === code)?.title ?? code, path: code, url: `https://eva.test/${code}`,
synced_at: "2026-10-20T10:00:00+00:00", markdown, attachments: [],
});
type Calls = { method: string; path: string; search: string; body: unknown }[];
function hub(over: (method: string, url: URL, body: unknown) => Response | undefined = () => undefined, status: object = STATUS) {
const calls: Calls = [];
mockFetch((method, url, body) => {
calls.push({ method, path: url.pathname, search: url.search, body });
const o = over(method, url, body);
if (o) return o;
if (url.pathname === "/wiki/status") return json(200, status);
if (url.pathname === "/wiki/tree") return json(200, TREE);
const m = /^\/wiki\/pages\/([^/]+)$/.exec(url.pathname);
if (m) return json(200, page(decodeURIComponent(m[1])));
return json(404, { error: "not_found" });
});
return calls;
}
const open = (route: string) => renderWithProviders(<WikiPage project="AWPCA" />, { route, path: "/wiki" });
describe("страница «Вики»", () => {
beforeEach(() => setToken("tok"));
it("ссылка page: открывает страницу", async () => {
hub();
const { router } = open("/wiki?project=AWPCA&page=DOC-3");
const article = await screen.findByRole("article");
await userEvent.click(await within(article).findByRole("link", { name: "Модель данных" }));
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&page=DOC-2"));
expect(await within(screen.getByRole("article")).findByText("Текст модели данных")).toBeInTheDocument();
expect(screen.getByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-selected", "true");
});
it("без корня — форма привязки", async () => {
const calls = hub((method, url) => {
if (url.pathname === "/wiki/status") return json(409, { error: "no_root", detail: "у проекта sfm_kedr нет корня вики" });
if (method === "PATCH") return json(200, { project: { label: "sfm_kedr", name: "x", created_at: "" }, batch_ids: [5] });
});
renderWithProviders(<WikiPage project="sfm_kedr" />, { route: "/wiki?project=sfm_kedr", path: "/wiki" });
await userEvent.type(await screen.findByLabelText("Корень вики (DOC-… или ссылка)"), "DOC-001234");
await userEvent.click(screen.getByRole("button", { name: "Привязать" }));
await waitFor(() => expect(calls.find((c) => c.method === "PATCH")).toBeTruthy());
const patch = calls.find((c) => c.method === "PATCH")!;
expect(patch.path).toBe("/projects/sfm_kedr");
expect(patch.body).toMatchObject({ wiki_root: "DOC-001234" });
});
it("поиск заменяет дерево", async () => {
const calls = hub((_m, url) => url.pathname === "/wiki/search"
? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок согласования" }] }) : undefined);
open("/wiki?project=AWPCA");
expect(await screen.findByRole("tree")).toBeInTheDocument();
await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "согласов");
expect(await screen.findByText("порядок согласования")).toBeInTheDocument();
expect(screen.queryByRole("tree")).toBeNull();
const s = calls.find((c) => c.path === "/wiki/search")!;
expect(new URLSearchParams(s.search).get("q")).toBe("согласов");
expect(new URLSearchParams(s.search).get("project")).toBe("AWPCA");
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
expect(await screen.findByRole("tree")).toBeInTheDocument();
});
it("сырой HTML не исполняется", async () => {
hub((_m, url) => url.pathname === "/wiki/pages/DOC-2"
? json(200, page("DOC-2", "до <script>window.__x = 1</script> после\n\n[плохая](javascript:alert(1))")) : undefined);
const { container } = open("/wiki?project=AWPCA&page=DOC-2");
const article = await screen.findByRole("article");
await within(article).findByText(/до/);
expect(container.querySelector("script")).toBeNull();
expect(article.textContent).toContain("<script>");
expect(container.querySelector('a[href^="javascript"]')).toBeNull();
});
it("ошибка синхронизации видна", async () => {
hub(undefined, { ...STATUS, error: "Eva не ответила: 502" });
open("/wiki?project=AWPCA&page=DOC-2");
expect(await screen.findByText(/Eva не ответила: 502/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { ApiError } from "../../api/client.ts";
import { Button, IconRefresh, Notice, PageHeader, Panel } from "../../ui/index.ts";
import { useWikiSearch, useWikiStatus, useWikiSync, useWikiTree } from "./api/queries.ts";
import { PageView } from "./PageView.tsx";
import { RootForm } from "./RootForm.tsx";
import { SearchBox, SearchResults } from "./Search.tsx";
import { Tree } from "./Tree.tsx";
import "./wiki.css";
const Q_DELAY = 300; // поиск — после паузы ввода
export function WikiPage({ project }: { project: string }) {
const [params] = useSearchParams();
const navigate = useNavigate();
const code = params.get("page");
// адрес страницы вики: проект и страница; поиск и прочие параметры в адрес не входят
const hrefOf = useCallback((c: string) => "?" + new URLSearchParams({ project, page: c }).toString(), [project]);
const status = useWikiStatus(project);
const noRoot = status.error instanceof ApiError && status.error.error === "no_root";
const synced = !!status.data && (status.data.synced_at !== null || status.data.pages > 0);
const tree = useWikiTree(project, synced);
const sync = useWikiSync(project);
const [q, setQ] = useState("");
const [dq, setDq] = useState("");
useEffect(() => {
const t = setTimeout(() => setDq(q.trim()), q.trim() === "" ? 0 : Q_DELAY);
return () => clearTimeout(t);
}, [q]);
const search = useWikiSearch(project, dq);
const searching = q.trim() !== "";
if (status.isPending) return <div className="wiki"><p className="wiki-hint">Загрузка…</p></div>;
if (noRoot) return <div className="wiki"><PageHeader title="Вики" /><RootForm project={project} /></div>;
if (status.isError) return <div className="wiki"><PageHeader title="Вики" /><Notice tone="error" action={<Button onClick={() => void status.refetch()}>Повторить</Button>}>{status.error.message}</Notice></div>;
const s = status.data;
const running = !!s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled";
const syncError = sync.error instanceof ApiError && sync.error.status === 409 ? null : sync.error?.message;
return (
<div className="wiki" data-testid="page-wiki">
<PageHeader title="Вики" subtitle={s.root_title ? `${s.root_title} · страниц: ${s.pages}` : undefined}
actions={<Button variant="primary" icon={<IconRefresh />} loading={sync.isPending || running} onClick={() => sync.mutate()}>Обновить вики</Button>} />
{s.error && <Notice tone="error" title="Последняя синхронизация не удалась">{s.error}</Notice>}
{syncError && <Notice tone="error">{syncError}</Notice>}
{!synced ? (
<Notice tone="info">{running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."}</Notice>
) : (
<div className="wiki__body">
<Panel as="section" padding="md" className="wiki__nav" aria-label="Навигация по вики">
<SearchBox value={q} onChange={setQ} />
{searching
? <SearchResults hits={dq === "" ? undefined : search.data} pending={dq === "" || search.isPending} error={search.error} hrefOf={hrefOf} selected={code} />
: tree.isPending ? <p className="wiki-hint">Загрузка дерева…</p>
: tree.isError ? <Notice tone="error">{tree.error.message}</Notice>
: <Tree nodes={tree.data} selected={code} onOpen={(c) => void navigate(hrefOf(c))} />}
</Panel>
<Panel as="section" padding="lg" className="wiki__content" aria-label="Страница вики">
{code ? <PageView key={code} project={project} code={code} hrefOf={hrefOf} /> : <p className="wiki-hint">Выберите страницу в дереве или найдите её поиском.</p>}
</Panel>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { api } from "../../../api/client.ts";
export interface WikiBatch { id: number; status: string }
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 }
export interface WikiPageData {
code: string; title: string | null; path: string; url: string | null; synced_at: string | null; markdown: string; attachments: WikiAttachment[];
}
export interface WikiHit { code: string; title: string | null; path: string; snippet: string }
export interface WikiAttachmentState { id: number; name: string | null; state: string; error: string | null; size: number | null }
export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён
export const SEARCH_LIMIT = 20;
// ключи начинаются с ["wiki", проект]: одной инвалидацией обновляется всё про проект
export const wikiQk = {
project: (p: string) => ["wiki", p] as const,
status: (p: string) => ["wiki", p, "status"] as const,
tree: (p: string) => ["wiki", p, "tree"] as const,
page: (p: string, code: string) => ["wiki", p, "page", code] as const,
search: (p: string, q: string) => ["wiki", p, "search", q] as const,
};
const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled";
// ход синхронизации; пока пакет идёт — опрос раз в 3 с, по его завершении дерево и страницы перечитываются
export function useWikiStatus(project: string, enabled = true) {
const qc = useQueryClient();
const q = useQuery({
queryKey: wikiQk.status(project), enabled,
queryFn: () => api.get<WikiStatus>("/wiki/status", { project }),
refetchInterval: (query) => (terminal(query.state.data?.batch ?? null) ? false : POLL_MS),
});
const batch = q.data?.batch ?? null;
const wasRunning = useRef(false);
useEffect(() => {
const running = !terminal(batch);
if (wasRunning.current && !running) {
for (const key of ["tree", "page", "search"]) void qc.invalidateQueries({ queryKey: [...wikiQk.project(project), key] });
}
wasRunning.current = running;
}, [batch, project, qc]);
return q;
}
export const useWikiTree = (project: string, enabled: boolean) =>
useQuery({ queryKey: wikiQk.tree(project), enabled, queryFn: () => api.get<{ pages: WikiNode[] }>("/wiki/tree", { project }).then((r) => r.pages) });
export const useWikiPage = (project: string, code: string | null) =>
useQuery({
queryKey: wikiQk.page(project, code ?? ""), enabled: code !== null,
queryFn: () => api.get<WikiPageData>(`/wiki/pages/${encodeURIComponent(code!)}`, { project }),
});
export const useWikiSearch = (project: string, q: string) =>
useQuery({
queryKey: wikiQk.search(project, q), enabled: q !== "",
queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results),
});
// «Обновить вики»: пакет ставит хаб; 409 — синхронизация уже идёт, статус перечитывается и показывает её пакет
export function useWikiSync(project: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<{ batch_id: number }>("/wiki/sync", { project, author: "viewer" }),
onSettled: () => qc.invalidateQueries({ queryKey: wikiQk.status(project) }),
});
}

View File

@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api, ApiError } from "../../api/client.ts";
import type { WikiAttachmentState } from "./api/queries.ts";
export const ATT_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;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
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) {
const [map, setMap] = useState<Record<string, DownloadStatus>>({});
const mapRef = useRef(map);
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); }, []);
useEffect(() => {
const pending = timers.current, counter = gen;
return () => {
counter.current++;
for (const t of pending) clearTimeout(t);
pending.clear();
mapRef.current = {};
setMap({});
};
}, [project, scope]);
const start = useCallback((id: string) => {
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 }); };
const base = `/wiki/attachments/${encodeURIComponent(id)}`;
const handle = async (st: WikiAttachmentState): Promise<void> => {
if (!alive()) return;
if (st.state === "ready") {
const blob = await api.blob(`${base}/content`);
if (!alive()) return;
save(blob, (st.name ?? "").trim() || `attachment-${id}`);
put(id, IDLE);
} else if (st.state === "fetching") {
const t = setTimeout(() => {
timers.current.delete(t);
if (!alive()) return;
api.get<WikiAttachmentState>(base).then(handle).catch((e) => fail(errorText(e)));
}, ATT_POLL_MS);
timers.current.add(t);
} else {
fail(st.error ?? "не удалось скачать вложение");
}
};
put(id, { busy: true, error: null });
api.post<WikiAttachmentState>(`${base}/fetch`, { author: "web" }).then(handle).catch((e) => fail(errorText(e)));
}, [put]);
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
return { start, status };
}

View File

@ -0,0 +1,34 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../../auth/token.ts";
import { NewProjectDialog } from "../../projects/NewProjectDialog.tsx";
import { json, mockFetch } from "../../test/fetchMock.ts";
import { wikiModule } from "./index.tsx";
describe("модуль wiki", () => {
beforeEach(() => setToken("tok"));
it("описывает пункт панели и поле корня", () => {
expect(wikiModule.nav.map((n) => [n.to, n.label])).toEqual([["/wiki", "Вики"]]);
expect(wikiModule.projectFields).toEqual([
{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" },
]);
});
it("поле корня в окне нового проекта", async () => {
const posts: unknown[] = [];
mockFetch((method, _u, body) => {
if (method === "POST") { posts.push(body); return json(201, { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3 }); }
return json(404, {});
});
render(<QueryClientProvider client={new QueryClient()}><NewProjectDialog onClose={() => {}} onCreated={() => {}} /></QueryClientProvider>);
await userEvent.type(screen.getByLabelText("Метка"), "P");
await userEvent.type(screen.getByLabelText("Название"), "Пэ");
await userEvent.type(screen.getByLabelText("Корень вики"), "DOC-001234");
await userEvent.click(screen.getByRole("button", { name: "Создать" }));
await waitFor(() => expect(posts).toHaveLength(1));
expect(posts[0]).toMatchObject({ label: "P", wiki_root: "DOC-001234" });
});
});

View File

@ -0,0 +1,13 @@
import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBook } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
import { SidebarStatus } from "./SyncStatus.tsx";
import { WikiPage } from "./WikiPage.tsx";
export const wikiModule: HubModule = {
name: "wiki",
pages: [{ path: "/wiki", element: <ProjectGate>{(p) => <WikiPage key={p.label} project={p.label} />}</ProjectGate> }],
nav: [{ to: "/wiki", label: "Вики", icon: <IconBook />, padded: true }],
SidebarStatus,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};

View File

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { wikiUrlTransform } from "./urlTransform.ts";
describe("wikiUrlTransform", () => {
it("пропускает page:, attachment:, http(s) и относительные", () => {
for (const u of ["page:DOC-2", "attachment:7", "https://eva.test/x", "http://a/b", "/rel/path", "#h", "a/b?c=1"]) expect(wikiUrlTransform(u)).toBe(u);
});
it("режет javascript:, data:, vbscript:, прочие схемы и обход управляющими символами", () => {
for (const u of ["javascript:alert(1)", "JaVaScRiPt:1", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "java\nscript:alert(1)", " \tjavascript:1"]) expect(wikiUrlTransform(u)).toBe("");
});
});

View File

@ -0,0 +1,12 @@
// адреса в Markdown вики: пропускаются внутренние page:/attachment:, http(s) и относительные; остальные схемы
// (javascript:, data:, vbscript:, file: …) заменяются пустой строкой. Управляющие символы и пробелы в схеме
// браузер игнорирует («java\nscript:»), поэтому схема определяется после их удаления.
const ALLOWED = new Set(["page", "attachment", "http", "https"]);
// eslint-disable-next-line no-control-regex
const IGNORED = /[\u0000- \u007f]/g;
export function wikiUrlTransform(url: string): string {
const m = /^([a-z][a-z0-9+.-]*):/i.exec(url.replace(IGNORED, ""));
if (!m) return url; // схемы нет — относительный адрес
return ALLOWED.has(m[1].toLowerCase()) ? url : "";
}

View File

@ -0,0 +1,69 @@
/* страница «Вики»: цвета, радиусы и отступы — только токены AURA */
.wiki{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
.wiki .ui-page-header{margin-bottom:0}
.wiki__body{display:grid;grid-template-columns:minmax(260px,340px) minmax(0,1fr);gap:var(--space-4);align-items:start}
@container page (max-width:759.98px){.wiki__body{grid-template-columns:minmax(0,1fr)}}
.wiki__nav{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
.wiki__content{min-width:0}
.wiki-hint{margin:0;color:var(--color-text-secondary)}
.wiki-root{max-width:560px}
.wiki-root__title{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700}
.wiki-root__text{margin:0;color:var(--color-text-secondary)}
/* дерево */
.wiki-tree{display:flex;flex-direction:column;gap:var(--space-1);max-height:70vh;overflow:auto}
.wiki-tree__item{display:flex;align-items:center;gap:var(--space-1);min-height:var(--control-h);padding-inline-end:var(--space-2);border-radius:var(--radius-md);
font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-primary);cursor:pointer}
.wiki-tree__item:hover{background:var(--color-bg-hover)}
.wiki-tree__item.is-selected{background:var(--color-bg-selected);color:var(--color-action-primary);font-weight:600}
.wiki-tree__item:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:-2px}
.wiki-tree__chevron{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--color-text-secondary);transform:rotate(-90deg);transition:transform var(--motion)}
.wiki-tree__chevron.is-open{transform:none}
.wiki-tree__title{min-width:0;overflow-wrap:anywhere}
/* результаты поиска */
.wiki-hits{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-1)}
.wiki-hit{display:flex;flex-direction:column;gap:var(--space-1);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);color:var(--color-text-primary);text-decoration:none}
.wiki-hit:hover{background:var(--color-bg-hover)}
.wiki-hit[aria-current="page"]{background:var(--color-bg-selected)}
.wiki-hit:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:-2px}
.wiki-hit__title{font-weight:600}
.wiki-hit__snippet{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary);overflow-wrap:anywhere}
/* страница */
.wiki-page__head{margin-bottom:var(--space-4)}
.wiki-page__title{margin:0;font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;overflow-wrap:anywhere}
.wiki-page__meta{margin:var(--space-1) 0 0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-1) var(--space-3);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.wiki-page__code{font-family:var(--font-mono)}
.wiki-page__eva{display:inline-flex;align-items:center;gap:var(--space-1);color:var(--color-action-primary);text-decoration:none}
.wiki-page__eva:hover{text-decoration:underline}
.wiki-md{font-size:var(--fs-body);line-height:var(--lh-body);overflow-wrap:anywhere}
.wiki-md>:first-child{margin-top:0}
.wiki-md h1,.wiki-md h2,.wiki-md h3,.wiki-md h4{margin:var(--space-5) 0 var(--space-2);font-weight:700}
.wiki-md h1{font-size:var(--fs-section);line-height:var(--lh-section)}
.wiki-md h2,.wiki-md h3,.wiki-md h4{font-size:var(--fs-lg);line-height:var(--lh-lg)}
.wiki-md p,.wiki-md ul,.wiki-md ol,.wiki-md blockquote,.wiki-md pre{margin:0 0 var(--space-3)}
.wiki-md a,.wiki-att{color:var(--color-action-primary)}
.wiki-md blockquote{padding-inline-start:var(--space-3);border-inline-start:3px solid var(--color-border-strong);color:var(--color-text-secondary)}
.wiki-md code{font-family:var(--font-mono);font-size:13px;padding:0 var(--space-1);background:var(--color-bg-subtle);border-radius:var(--radius-xs)}
.wiki-md pre{padding:var(--space-3);background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-md);overflow:auto}
.wiki-md pre code{padding:0;background:none}
.wiki-table{overflow-x:auto;margin:0 0 var(--space-3)}
.wiki-md table{border-collapse:collapse;min-width:100%}
.wiki-md th,.wiki-md td{padding:var(--space-2) var(--space-3);text-align:left;vertical-align:top;border:1px solid var(--color-border-default)}
.wiki-md th{background:var(--color-bg-subtle);font-weight:600}
.wiki-img{color:var(--color-text-secondary)}
.wiki-att{display:inline-flex;align-items:center;gap:var(--space-1);font:inherit;padding:0;background:none;border:0;cursor:pointer;text-align:left;overflow-wrap:anywhere}
.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}
.wiki-att__busy{color:var(--color-text-secondary)}
.wiki-att__error{color:var(--color-status-error)}
/* плашка хода синхронизации в боковой панели */
.wiki-status{flex-direction:row;align-items:center}
.wiki-status--info .wiki-status__text{color:var(--color-text-primary)}
.wiki-status--error .wiki-status__text{color:var(--color-status-error)}
.wiki-status .wiki-status__dot{position:static;box-shadow:none}
.wiki-status__dot--info{background:var(--color-action-primary)}
.wiki-status__dot--error{background:var(--color-status-error)}

View File

@ -42,3 +42,4 @@ export const IconDownload = make(<><path d="M12 4v12"/><path d="M7 11l5 5 5-5"/>
export const IconFile = make(<><path d="M14 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/><path d="M14 3v4h4"/></>);
export const IconImage = make(<><rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M4 17l5-4.5 3.5 3 3-2.5 4.5 4"/></>);
export const IconSheet = make(<><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 10h16M4 15h16M10 4v16"/></>);
export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H19v-3"/><path d="M9 7.5h6M9 11h6"/></>);