# Пакет D модуля wiki — план реализации > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** закрыть маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 и показать скачанные картинки в тексте страницы «Вики» (sdlc-hub#135). **Architecture:** точечные правки модуля `wiki`: перевод HTML (`convert.py`), постановка `wiki_sync` (`commands.py`), вложения (`attachments.py`, `api.py`), MCP (`mcp.py`); во фронте — режим «показать» у скачивания вложения и хранилище картинок страницы (`download.ts`, `PageView.tsx`). Схема базы и контракт с агентом не меняются. **Tech Stack:** Python 3.13 (uv), pytest, BeautifulSoup + markdownify; React 19 + TypeScript, Vitest, Testing Library. **Ветка:** `feat/135-wiki-package-d` (worktree `.wt/wiki-d`). **Spec:** `openspec/changes/wiki-package-d/` (proposal.md, design.md — решения 1–10, specs/wiki, specs/mcp, specs/web-app). ## Global Constraints - Только хаб `sdlc-hub`; eva-agent и jira-agent не меняются, `SCHEMA_VERSION` не меняется. - Граница ядра: в `src/sdlc_hub/core/` правок нет (проверяют `tests/test_registry.py`). - Тексты ошибок и комментарии — по-русски, в стиле соседнего кода. - Расширения картинок: `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра; SVG — не картинка. - Пробел → `%20`, `(` → `%28`, `)` → `%29` — только во внешних адресах ссылок и картинок. - Зелёным считается только весь набор: в конце `make check` (pytest + lint и тесты фронта). - Соединения SQLite в тестах — через фикстуры `open_db`/`conn`, иначе явный `close()`. ## Review Focus 1. Ссылка на страницу, внутри которой и картинка-вложение, и текст (`Схема`) — вложение перед ссылкой, текст ссылки «Схема». Тест в Task 1. 2. Внешняя ссылка с уже закодированным адресом (`%20` в `href`) не кодируется повторно (`%2520`). Тест в Task 1. 3. Повтор упавшей синхронизации, когда идущих синхронизаций нет, проходит как раньше. Тест в Task 2. 4. Вложение `ready` с файлом на месте — `fetch` отвечает 200 `ready` без нового пакета (T8.2 не ломает обычный путь). Тест в Task 3. 5. Смена страницы, пока картинка грузится: поздний ответ `content` не кладёт картинку на новую страницу, objectURL старой отозваны. Тест в Task 5. --- ### Task 1: Перевод HTML — T6.1–T6.5 **Files:** - Modify: `src/sdlc_hub/modules/wiki/convert.py` (`_Converter.__init__`, `convert_img`, `convert_a`, `_label`, новые `_href`, `_split_marks`) - Test: `tests/test_wiki_convert.py` **Interfaces:** - Produces: `convert.to_markdown(html, attachments, links) -> (str, list[dict])` — сигнатура прежняя. - [ ] **Step 1: Написать падающие тесты** (константы `FILE_REF`, `THUMB_REF`, `IMG_REF`, `FILE_URL` уже в файле; `IMG_URL = "https://eva.example.test" + IMG_REF`, `FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}`, `THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}`, `IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}`, `LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"}`) ```python def test_thumbnail_in_wrapper_keeps_link(): # T6.1 md, atts = convert.to_markdown(f'

текст

', [THUMB, FILE], []) assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1 def test_other_attachment_inside_attachment_link(): # T6.1, design 1 md, atts = convert.to_markdown(f'

', [FILE, IMG], []) assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n" assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"] def test_attachment_inside_page_link(): # T6.2 md, _ = convert.to_markdown(f'

', [IMG], [LINK]) assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n" def test_attachment_and_text_inside_page_link(): # Review Focus 1 md, _ = convert.to_markdown(f'

Схема

', [IMG], [LINK]) assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n" def test_attachment_inside_external_link(): # design 2 md, _ = convert.to_markdown(f'

', [IMG], []) assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n" def test_duplicate_ref_first_wins(): # T6.3 md, atts = convert.to_markdown(f'

', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], []) assert atts == [{"url": IMG_URL, "name": "first"}] md, _ = convert.to_markdown('

т

', [], [LINK, dict(LINK, code="DOC-9")]) assert "(page:DOC-1)" in md def test_external_href_spaces_and_parens_encoded(): # T6.4 md, _ = convert.to_markdown('

док

', [], []) assert md == "[док](http://ex.com/a%20b%28c%29.html)\n" def test_external_href_already_encoded_untouched(): # Review Focus 2 md, _ = convert.to_markdown('

док

', [], []) assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md def test_trailing_backslash_in_page_link_text(): # T6.5 md, _ = convert.to_markdown('

путь C:\\

', [], [LINK]) assert md == "[путь C:\\\\](page:DOC-1)\n" # в Markdown: [путь C:\\](page:DOC-1) def test_trailing_backslash_in_external_link_text(): # T6.5 md, _ = convert.to_markdown('

путь C:\\

', [], []) assert md == "[путь C:\\\\](http://ex.com/)\n" ``` Если markdownify уже экранирует `\` в тексте, удвоение в `_label` не нужно — решают тесты. - [ ] **Step 2: Запустить — тесты падают** Run: `uv run pytest tests/test_wiki_convert.py -q` Expected: новые тесты FAIL, прежние PASS. - [ ] **Step 3: Реализовать в `convert.py`** - `_by_ref`, `_links` — первая запись по `ref` (`setdefault` в цикле). - `convert_img`: предок — `el.find_parent("a")`; если его `href` в `_by_ref` и (`src` не в `_by_ref` или `url` записи `src` == `url` записи `href`) — `""`. - `_split_marks(text: str) -> tuple[str, str]` — (все фрагменты `[📎 …<маркер>` подряд, остаток текста без них); фрагмент вложения — `"[📎 " + имя + _MARK_OPEN + n + _MARK_CLOSE`, искать регуляркой до `_MARK_CLOSE`. - `convert_a`: для вложения — `marks + self._attachment(entry)` (маркеры из `text` сохраняются перед своей ссылкой); для ссылки на страницу — `marks + (" " if marks else "") + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)`; для внешней — то же разбиение, ссылка через `super().convert_a` с `el["href"] = _href(href)` и текстом-остатком (пустой — адрес). - `convert_img` для внешней картинки — `el["src"] = _href(src)` перед `super().convert_img`. - `_href(url: str) -> str`: `url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")`. - `_label`: нечётная серия `\` в конце строки удваивается до чётной. - [ ] **Step 4: Запустить — всё зелёное** Run: `uv run pytest tests/test_wiki_convert.py tests/test_wiki_sync.py -q` Expected: PASS. - [ ] **Step 5: Коммит** ```bash git add src/sdlc_hub/modules/wiki/convert.py tests/test_wiki_convert.py git commit -m "Вики: перевод HTML — миниатюра в обёртке, вложение в ссылке, кодирование адресов (#135, T6.1–T6.5)" ``` ### Task 2: Одна синхронизация на корень — T8.3 **Files:** - Modify: `src/sdlc_hub/modules/wiki/commands.py` (`check_batch`, `enqueue_sync`, новая `_active_same_root`) - Test: `tests/test_wiki_commands.py`, `tests/test_wiki_projects.py` **Interfaces:** - Produces: `commands._active_same_root(conn, label: str, root: str) -> int | None` — id пакета с `wiki_sync` проекта в `queued`/`claimed` и `params.root == root` (по `json_extract(c.params, '$.root')`), самый новый. - Produces: `commands.enqueue_sync(conn, label, root, author) -> dict` — `{id, estimate_seconds, count}`; при идущей синхронизации того же корня — её пакет (`estimate_seconds` из `batch`, `count` — число команд пакета). - [ ] **Step 1: Написать падающие тесты** ```python # tests/test_wiki_commands.py SYNC = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}] def test_check_batch_refuses_second_sync_same_root(conn): b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") with pytest.raises(CommandError, match=f"#{b['id']}"): queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") def test_check_batch_other_root_allowed(conn): queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") other = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-2"}}] assert queue.create_batch(conn, "t", "u", other, project="AWPCA")["count"] == 1 def test_retry_failed_sync_without_active_passes(conn): # Review Focus 3 b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") conn.execute("UPDATE command SET status = 'failed' WHERE batch_id = ?", (b["id"],)) assert queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")["count"] == 1 def test_enqueue_sync_returns_active_same_root(conn): _root(conn) first = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") again = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") assert again["id"] == first["id"] and again["count"] == 1 and _count(conn) == 1 ``` ```python # tests/test_wiki_projects.py def test_root_back_while_old_claimed(hub_server): # A → B → A c = hub_server["client"] c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) conn = hub_server["hub"].conn() try: conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'") finally: conn.close() c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) c.patch("/projects/AWPCA", {"wiki_root": "DOC-12"}) # не 422 assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("cancelled", "DOC-21")] def test_retry_while_sync_running_refused(hub_server): # сценарий spec «Повтор упавшей синхронизации…» c = hub_server["client"] c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"}) conn = hub_server["hub"].conn() try: failed = conn.execute("SELECT id FROM command WHERE type = 'wiki_sync'").fetchone()["id"] conn.execute("UPDATE command SET status = 'failed' WHERE id = ?", (failed,)) finally: conn.close() running = c.post("/wiki/sync", {"project": "AWPCA"})["batch_id"] with pytest.raises(HubError) as e: c.post(f"/commands/{failed}/retry", {}) assert e.value.status == 422 and f"#{running}" in str(e.value) ``` Статус отменённой команды в `_syncs` — каким его пишет `queue.cancel_batch` (исполнитель сверяет по `test_patch_root_cancels_queued` и подставляет фактический). Атрибуты `HubError` (`status`, текст) — как в соседних тестах файла. - [ ] **Step 2: Запустить — тесты падают** Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py -q` Expected: новые FAIL. - [ ] **Step 3: Реализовать `_active_same_root`, проверку в `check_batch` и поиск в `enqueue_sync`** Текст ошибки `check_batch`: `f"команда {i}: у проекта {project} уже идёт синхронизация корня {root} (пакет #{bid})"`. `enqueue_sync` и `check_batch` меняются в одном коммите (design 7). - [ ] **Step 4: Запустить wiki-тесты** Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py tests/test_wiki_api.py tests/test_wiki_sync.py -q` Expected: PASS. - [ ] **Step 5: Коммит** ```bash git add src/sdlc_hub/modules/wiki/commands.py tests/test_wiki_commands.py tests/test_wiki_projects.py git commit -m "Вики: одна синхронизация на корень — повтор не ставит вторую (#135, T8.3)" ``` ### Task 3: Вложение без файла и признак картинки — T8.2, `image` **Files:** - Modify: `src/sdlc_hub/modules/wiki/attachments.py` (`request_fetch`, новая `is_image`), `src/sdlc_hub/modules/wiki/api.py:77-79` (страница), `:125` (fetch) - Test: `tests/test_wiki_api.py` **Interfaces:** - Produces: `attachments.request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict` — новый обязательный параметр `files_dir` (путь или `None`; `None` — проверка файла пропускается). - Produces: `attachments.is_image(url: str | None) -> bool` — последний сегмент `urlsplit(url).path`, `unquote`, `.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"))`. - Produces: `GET /wiki/pages/{code}` → `attachments: [{id, name, state, size, error, image}]` (Task 5 читает `image`). - [ ] **Step 1: Написать падающие тесты** ```python def test_fetch_ready_file_missing_requeues(api): # T8.2 aid = synced(api) api.client.post(f"/wiki/attachments/{aid}/fetch", {}) sha = upload(api, b"hello")["sha256"] api.report(fetch_cmd(api), "done", {"file": sha, "size": 5}) (api.files_dir / sha[:2] / sha).unlink() # путь файла — как в core.files; сверить r = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) assert r["state"] == "fetching" assert fetch_cmd(api)["params"]["attachment_id"] == aid def test_fetch_ready_file_present_no_batch(api): # Review Focus 4 aid = synced(api) api.client.post(f"/wiki/attachments/{aid}/fetch", {}) sha = upload(api, b"hello")["sha256"] api.report(fetch_cmd(api), "done", {"file": sha, "size": 5}) n = len(api.rows("SELECT id FROM batch")) assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "ready" assert len(api.rows("SELECT id FROM batch")) == n def test_page_attachment_image_flag(api): synced(api, attachments=[{"ref": FILE_REF, "url": FILE_URL, "name": "attachment"}, {"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "Схема без расширения"}]) atts = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"] assert [a["image"] for a in atts] == [False, True] @pytest.mark.parametrize("url,expected", [("https://e/files/a/x.PNG?version=1", True), ("https://e/files/a/x.svg", False), ("https://e/files/a/%D1%81.jpeg", True), (None, False), ("https://e/files/a/x.png.meta/", False)]) def test_is_image(url, expected): assert attachments.is_image(url) is expected ``` Ключ ответа `POST /agent/files`, путь файла в `files/` и доступ к `files_dir` в фикстуре `api` — по `core/files.py` и `tests/test_wiki_sync.py`; исполнитель подставляет фактические. HTML страницы в `test_page_attachment_image_flag` должен содержать оба `ref` (`` и ``) — передать `name_html`. - [ ] **Step 2: Запустить — тесты падают** Run: `uv run pytest tests/test_wiki_api.py -q` Expected: новые FAIL. - [ ] **Step 3: Реализовать** - `request_fetch`: ветка «ответить как есть» для `ready` — только если `files_dir is None or files.size(files_dir, row["file"]) is not None`. - `h_attachment_fetch` передаёт `hub.files_dir`; прочие вызовы `request_fetch` (grep) — тоже. - Страница: в выборке вложений добавить `url`, в ответ — `image: attachments.is_image(url)`, `url` в ответ страницы не добавлять. - [ ] **Step 4: Запустить wiki-тесты** Run: `uv run pytest tests/test_wiki_api.py tests/test_wiki_mcp.py -q` Expected: PASS (прежний `test_status_tree_page` обновить: в ожидаемом словаре вложения — `"image": False`). - [ ] **Step 5: Коммит** ```bash git add src/sdlc_hub/modules/wiki/attachments.py src/sdlc_hub/modules/wiki/api.py tests/test_wiki_api.py git commit -m "Вики: вложение без файла скачивается заново, признак картинки у вложений страницы (#135, T8.2)" ``` ### Task 4: MCP — значения из Eva одной строкой (T9.3) **Files:** - Modify: `src/sdlc_hub/modules/wiki/mcp.py` (новая `_line`, места вывода из design 8) - Test: `tests/test_wiki_mcp.py` **Interfaces:** - Produces: `mcp._line(v) -> str` — `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*", " ", …)`. - [ ] **Step 1: Написать падающие тесты** (маршруты `Fake` — как в соседних тестах `test_page_chunks`, `test_search`, `test_tree_depth_and_subtree`) ```python def test_page_header_newlines_collapsed(hub): hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\nСтатусы", "url": "https://eva/x", "synced_at": "2026-10-24T10:00:00+00:00", "markdown": "строка1\nстрока2", "attachments": [{"id": 7, "name": "a\nb.png", "state": "none", "size": None, "error": None, "image": True}]}}) out = asyncio.run(...) # вызов page как в test_page_chunks lines = out.splitlines() assert lines[0] == "DOC-3 — Статусы путь: поддельный" assert sum(l.startswith("путь:") for l in lines) == 1 assert " #7 a b.png (none)" in lines and "строка1\nстрока2" in out def test_search_and_tree_newlines_collapsed(hub): # search: title "A\nB", path "P\nQ", snippet "x\ny" → одна строка «… A B — P Q» и строка « x y» # tree: title "T\nU" → «DOC-2 T U (200 симв.)» ``` Исполнитель дописывает второй тест конкретными `Fake`-маршрутами и утверждениями из комментария — значения заданы. - [ ] **Step 2: Запустить — тесты падают** Run: `uv run pytest tests/test_wiki_mcp.py -q` Expected: новые FAIL. - [ ] **Step 3: Реализовать `_line` и применить** — `tree`, `page` (шапка и вложения), `_candidates` (названия и `title` запроса), `search`, `attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка). Тело страницы не трогать. - [ ] **Step 4: Запустить** Run: `uv run pytest tests/test_wiki_mcp.py -q` Expected: PASS. - [ ] **Step 5: Коммит** ```bash git add src/sdlc_hub/modules/wiki/mcp.py tests/test_wiki_mcp.py git commit -m "MCP вики: значения из Eva одной строкой (#135, T9.3)" ``` ### Task 5: Картинки в тексте страницы «Вики» **Files:** - Modify: `web/src/modules/wiki/api/queries.ts` (`WikiAttachment.image`), `web/src/modules/wiki/download.ts` (режим `show`, хранилище картинок), `web/src/modules/wiki/PageView.tsx` (`AttachmentLink`), `web/src/modules/wiki/wiki.css` - Test: `web/src/modules/wiki/download.test.tsx`, `web/src/modules/wiki/WikiPage.test.tsx` **Interfaces:** - Consumes: `GET /wiki/pages/{code}` → `attachments[].image: boolean` (Task 3). - Produces: `useWikiDownload(project, scope, images?: WikiAttachment[])` → `{ start(id: string, mode?: "save" | "show"): void; status(id): DownloadStatus; image(id): string | null }`; `image` — objectURL картинки или `null`. Объект стабилен при неизменных `start/status/image` (как сейчас). - Тип blob: `new Blob([blob], { type: MIME_BY_EXT[ext] ?? "application/octet-stream" })`, `MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" }`; расширение — из `name`, если его нет — `image/*` не ставится, браузер определит сам (blob без типа). - [ ] **Step 1: Написать падающие тесты** (моки `api` и `URL.createObjectURL`/`revokeObjectURL` — как в `download.test.tsx`) ```tsx it("ready-картинка при открытии страницы показывается без fetch", async () => { // страница DOC-3, attachments: [{id: 7, name: "scheme.png", state: "ready", image: true}] // ожидание: GET /wiki/attachments/7/content вызван, POST …/fetch — нет; в DOM img с src blob:… и alt «scheme.png»; // под картинкой кнопка «scheme.png» (сохранение) }); it("клик по нескачанной картинке: fetch, опрос, картинка вместо сохранения", async () => { // state none → клик → POST /wiki/attachments/7/fetch → ready → GET content; img в DOM; save (a.download) не вызван }); it("не картинка сохраняется как раньше", async () => { // {id: 8, name: "model.xlsx", state: "ready", image: false} → клик → a.download === "model.xlsx", img нет }); it("content 409 у ready-картинки — остаётся ссылка без ошибки, клик ставит скачивание", async () => {}); it("смена страницы: поздний content не показывается, objectURL отозваны", async () => { // Review Focus 5 }); it("появление картинки не сбрасывает busy другого вложения", async () => {}); ``` Исполнитель дописывает тела по образцу соседних тестов `download.test.tsx`/`WikiPage.test.tsx`; ожидания заданы в комментариях. - [ ] **Step 2: Запустить — тесты падают** Run: `cd web && npx vitest run src/modules/wiki` Expected: новые FAIL. - [ ] **Step 3: Реализовать** по design 10: автозапрос `content` один раз на `ready`-картинку за жизнь `scope`; режим `show` кладёт objectURL в ту же карту; сброс карты и `revokeObjectURL` при смене `project/scope` и размонтировании; `AttachmentLink` — `{name}` и кнопка `save` под ней, без картинки — кнопка `show` для `image`, `save` иначе. CSS: `.wiki-att-img{display:block}` и `.wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)}`. - [ ] **Step 4: Запустить фронт целиком** Run: `cd web && npm run lint && npm test` Expected: lint чистый, все тесты PASS. - [ ] **Step 5: Коммит** ```bash git add web/src/modules/wiki git commit -m "Вики: скачанные картинки показываются в тексте страницы (#135)" ``` ### Завершение - [ ] `make check` — весь набор зелёный. - [ ] Живая проверка на тестовом хабе или после выкатки (за владельцем): картинка `vgo-matching-1.png` (AWPCA, в `ready`) видна в тексте своей страницы.