Merge pull request 'Пакет D модуля wiki: перевод HTML, вложения, синхронизация, MCP, картинки в тексте (#135)' (#136) from feat/135-wiki-package-d into main
This commit is contained in:
commit
a6b04a392a
387
docs/superpowers/plans/2026-10-27-wiki-package-d.md
Normal file
387
docs/superpowers/plans/2026-10-27-wiki-package-d.md
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
# Пакет 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. Ссылка на страницу, внутри которой и картинка-вложение, и текст (`<a href="project/Document/DOC-1"><img src=…>Схема</a>`) — вложение перед ссылкой, текст ссылки «Схема». Тест в 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'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [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'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [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'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [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'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [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'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [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'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
|
||||||
|
assert atts == [{"url": IMG_URL, "name": "first"}]
|
||||||
|
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [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('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
|
||||||
|
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('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
|
||||||
|
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('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [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('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
|
||||||
|
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` (`<a href=FILE_REF>` и `<img src=IMG_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` — `<span className="wiki-att-img"><img src alt={name} /></span>` и кнопка `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`) видна в тексте своей страницы.
|
||||||
2
openspec/changes/wiki-package-d/.openspec.yaml
Normal file
2
openspec/changes/wiki-package-d/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-27
|
||||||
138
openspec/changes/wiki-package-d/design.md
Normal file
138
openspec/changes/wiki-package-d/design.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Пакет D сверки follow-ups 25.10.2026 (sdlc-hub#135). Маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 сверены с кодом
|
||||||
|
27.10.2026 и воспроизведены на `convert.to_markdown`:
|
||||||
|
|
||||||
|
| Маркер | Вход | Сейчас |
|
||||||
|
|---|---|---|
|
||||||
|
| T6.1 | `<a href="…/f.xlsx"><span><img src="…/f.xlsx.meta/thumbnail.jpg"></span></a> текст` | `текст` — ссылки на вложение нет, строка вложения есть |
|
||||||
|
| T6.2 | `<a href="project/Document/DOC-1"><img src="…/x.png"></a>` | `[\[📎 x.png](attachment:@0)](page:DOC-1)` — ссылка в ссылке |
|
||||||
|
| T6.3 | два `attachments` с одним `ref` | побеждает последний (`dict` по `ref`) |
|
||||||
|
| T6.4 | `<a href="http://ex.com/a b(c).html">док</a>` | `[док](http://ex.com/a b(c).html)` — не ссылка |
|
||||||
|
| T6.5 | `<a href="project/Document/DOC-1">путь C:\</a>` | `[путь C:\](page:DOC-1)` — `\]` экранирует скобку |
|
||||||
|
|
||||||
|
T8.2: `request_fetch` отвечает `ready` по полю `state`, а `GET …/content` при пропавшем файле — 409 «запросите
|
||||||
|
скачивание»: тупик. T8.3: `POST /wiki/sync` проверяет `active_sync`, а `POST /batches` и `POST /commands/{id}/retry`
|
||||||
|
проходят только `commands.check_batch`, где этой проверки нет. T9.3: `mcp.py` склеивает шапку `page`, строки `tree`,
|
||||||
|
`search` и кандидатов из значений Eva без нормализации переводов строк.
|
||||||
|
|
||||||
|
Владелец 27.10.2026 согласился с предложенным поведением по всем восьми маркерам и добавил картинки в тексте страницы
|
||||||
|
«Вики»: показ только после нажатия, уже скачанные — сразу.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **T6.1 — предок, а не родитель.** `convert_img` ищет ближайшего предка `<a>` (`el.find_parent("a")`), а не только
|
||||||
|
`el.parent`. Если `href` этого `<a>` — вложение и картинка — то же вложение (совпадает `url` записей) или вообще не
|
||||||
|
вложение, картинка не выводится: ссылку выведет `<a>`. Картинка — другое вложение (миниатюра файла Y внутри ссылки
|
||||||
|
на файл X) выводит свою ссылку, а `convert_a` для вложения X сохраняет маркеры вложений из `text` перед своей
|
||||||
|
ссылкой (то же правило, что в решении 2): Y не теряется.
|
||||||
|
2. **T6.2 — вложение перед ссылкой на страницу.** В `convert_a` для ссылки на документ (`code` найден): если в `text`
|
||||||
|
(уже сконвертированное содержимое) есть маркеры вложений (`_MARK`), они выносятся перед ссылкой: результат —
|
||||||
|
`<ссылки вложений> [<остаток текста или код>](page:<код>)`. Остаток — `text` без фрагментов `[📎 …<маркер>`;
|
||||||
|
пустой после `strip()` — код. Через `_label` как сейчас. Внешняя ссылка (`super().convert_a`) с картинкой-вложением
|
||||||
|
внутри — то же правило: вложение перед ссылкой, текст ссылки — остаток, пустой — адрес. Ссылка на вложение внутри
|
||||||
|
ссылки на вложение уже покрыта решением 1.
|
||||||
|
3. **T6.3 — первая запись.** `_by_ref` и `_links` собираются с `setdefault`: первая запись по `ref` побеждает (порядок
|
||||||
|
агента — порядок документа).
|
||||||
|
4. **T6.4 — кодирование адреса.** Для внешних ссылок и картинок (ветка `super().convert_a`/`convert_img`) адрес
|
||||||
|
проходит `_href(url)`: пробел → `%20`, `(` → `%28`, `)` → `%29`; прочие символы не трогаются (уже закодированное не
|
||||||
|
кодируется повторно). Ссылки `page:`/`attachment:` не затрагиваются — в них таких символов нет.
|
||||||
|
5. **T6.5 — хвостовая черта.** `_label` после текущих замен удваивает нечётную серию `\` в конце строки (одна `\`
|
||||||
|
в конце → `\\`). То же в тексте внешней ссылки, если markdownify не экранировал её сам; проверять тестом обе ветки.
|
||||||
|
6. **T8.2 — проверка файла в `request_fetch`.** `request_fetch(conn, attachment_id, author, files_dir)`: при `state =
|
||||||
|
ready` и `files.size(files_dir, row["file"]) is None` вложение идёт по ветке «поставить скачивание» (state
|
||||||
|
`fetching`, новый пакет). Маршрут `h_attachment_fetch` передаёт `hub.files_dir`; MCP `attachment` ходит через этот
|
||||||
|
же маршрут и поведение получает без правок. `GET /wiki/pages/{code}` и `GET /wiki/attachments/{id}` по-прежнему
|
||||||
|
отдают `state` из базы (проверка файла на каждом чтении — лишние обращения к диску).
|
||||||
|
7. **T8.3 — проверка в `check_batch`.** Для каждой `wiki_sync` в пакете: если у проекта есть команда `wiki_sync` в
|
||||||
|
`queued`/`claimed` с тем же `params.root`, — `CommandError` «у проекта <метка> уже идёт синхронизация корня <root>
|
||||||
|
(пакет #N)» → 422 `bad_batch`. Тот же `root`, а не любой: `projects.set_root` ставит синхронизацию нового корня,
|
||||||
|
пока команда старого может быть `claimed` (её отчёт будет отброшен по «корень сменился»). `POST /wiki/sync`
|
||||||
|
по-прежнему проверяет `active_sync` раньше и отвечает 409; фоновая работа — тоже раньше (`tick.py`).
|
||||||
|
Возврат корня (A → B → A, пока `wiki_sync {root: A}` ещё `claimed`): `projects.on_updated` отменяет только
|
||||||
|
`queued`, и новая синхронизация A упёрлась бы в проверку — PATCH получил бы 422. Поэтому `commands.enqueue_sync`
|
||||||
|
перед постановкой ищет активную `wiki_sync` проекта с тем же `root` и, если она есть, возвращает её пакет без
|
||||||
|
нового: её отчёт не будет отброшен (корень снова совпадает). Ответ — в форме `create_batch`: `{id,
|
||||||
|
estimate_seconds, count}` из строки пакета и числа его команд; для вызывающих (`on_created`, `on_updated` → ответ
|
||||||
|
PATCH, `POST /wiki/sync`, `tick`) это «пакет синхронизации проекта», новый он или идущий — не различается. Проверка
|
||||||
|
в `check_batch` остаётся для `POST /batches` и повтора команды; `enqueue_sync` с поиском и `check_batch`
|
||||||
|
реализуются одной задачей, иначе `enqueue_sync` между шагами упирается в новую проверку.
|
||||||
|
8. **T9.3 — одна функция строки.** В `modules/wiki/mcp.py` — `_line(v)`: `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*",
|
||||||
|
" ", …)`. Применяется ко всем значениям из Eva внутри строк ответа: `tree` (название), `page` (название, путь, имена
|
||||||
|
вложений, состояние), кандидаты `_candidates` (название и сам `title` запроса), `search` (название, путь, фрагмент),
|
||||||
|
`attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка синхронизации). Тело страницы — как
|
||||||
|
есть.
|
||||||
|
9. **Признак картинки — от хаба по адресу.** Имя вложения для этого не годится: проверка живого AWPCA 27.10.2026
|
||||||
|
показала имена без расширения («Схема расчёта суммы элиминации по тройке» — файл `vgo-matching-1.png`; 14 вложений
|
||||||
|
с именем `attachment`) — eva-agent берёт `alt`/`title`/текст ссылки раньше имени файла. Хаб добавляет в
|
||||||
|
`attachments` ответа `GET /wiki/pages/{code}` поле `image`: последний сегмент пути `url` (без запроса,
|
||||||
|
`unquote`) оканчивается на `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра. Функция — в
|
||||||
|
`modules/wiki/attachments.py`, схема базы не меняется. SVG исключён: файл из Eva недоверенный, а пользы в вики от
|
||||||
|
него мало.
|
||||||
|
10. **Картинки — устройство во фронте.**
|
||||||
|
- `useWikiDownload.start(id, mode)` получает режим: `"save"` (как сейчас) или `"show"`. В режиме `show` по `ready`
|
||||||
|
содержимое (`api.blob`) не сохраняется, а передаётся в хранилище картинок страницы.
|
||||||
|
- Хранилище картинок страницы (новый хук рядом, например `useWikiImages(project, code, attachments)`): `id →
|
||||||
|
objectURL`. При загрузке страницы для каждого вложения-картинки в `ready` один раз запрашивает `…/content` и
|
||||||
|
кладёт objectURL; ошибка — молча, остаётся ссылка. При смене страницы/проекта и размонтировании — `revokeObjectURL`
|
||||||
|
всех адресов и отмена поздних ответов (поколение, как в `useWikiDownload`).
|
||||||
|
- Blob заворачивается с типом по расширению (`image/png` и т.д.): хаб отдаёт содержимое как
|
||||||
|
`application/octet-stream`.
|
||||||
|
- Один хук на страницу: хранилище картинок живёт внутри `useWikiDownload` (или в хуке, который его оборачивает),
|
||||||
|
режим `show` кладёт objectURL туда же. Ссылка на объект, от которого зависит `components` в `PageView`, остаётся
|
||||||
|
стабильной (как сейчас — см. комментарий в `download.ts`): карту картинок `AttachmentLink` читает через
|
||||||
|
`status(id)`-подобный геттер, меняющийся вместе с картой, как сейчас `status`, а не через пересоздание
|
||||||
|
`components`. Перемонтирование вложений при появлении картинки не должно сбрасывать `busy`/`error` других.
|
||||||
|
- Источник картинки — либо автозапрос `content` при загрузке страницы для `ready`-картинок, либо результат
|
||||||
|
`start(id, "show")`. Автозапрос — один раз на вложение за жизнь страницы; его неудача (409, сеть) оставляет
|
||||||
|
ссылку в режиме `show` без текста ошибки. Нажатие на неё — `start(id, "show")`: после T8.2 хаб при пропавшем файле
|
||||||
|
скачивает заново, по `ready` картинка кладётся в хранилище из ответа `start`. Вложение в `failed` — та же ссылка
|
||||||
|
с текстом ошибки, нажатие — повтор `start(id, "show")`.
|
||||||
|
- `AttachmentLink`: картинка с objectURL — `<span className="wiki-att-img"><img src alt={name}/></span>` и под ней
|
||||||
|
прежняя кнопка-ссылка в режиме `save`; картинка без objectURL — кнопка в режиме `show`; не картинка — как сейчас.
|
||||||
|
- CSS: `.wiki-att-img img { max-width: 100%; height: auto; display: block }`, токены AURA.
|
||||||
|
- Правило «внешние картинки не загружаются» (`img` в `components`) не меняется: картинки — только вложения хаба.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Картинки в памяти.** objectURL держат blob до смены страницы; предел файла — 10 МБ, на странице AWPCA — единицы
|
||||||
|
картинок. Приемлемо.
|
||||||
|
- **Состояние вложения после скачивания.** Ответ `GET /wiki/pages/{code}` не перечитывается после скачивания:
|
||||||
|
картинка показывается из хранилища страницы, при повторном открытии — по `ready` из нового ответа.
|
||||||
|
- **Корень сменился во время повтора.** Повтор команды старого корня при идущей синхронизации нового проходит (корни
|
||||||
|
разные); его `done` будет отброшен по «корень сменился». Лишний запрос в Eva, данных не портит.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
- 2026-10-27 — T6.2: вложение выводится перед ссылкой на страницу, пустой текст ссылки — код страницы. Обоснование:
|
||||||
|
Markdown не допускает ссылку в ссылке; порядок совпадает с порядком в HTML. Цена ошибки — вид одной строки текста.
|
||||||
|
- 2026-10-27 — T6.3: при повторе `ref` побеждает первая запись. Обоснование: порядок агента — порядок документа,
|
||||||
|
первое появление определяет имя. Цена ошибки — другое имя вложения в редком случае.
|
||||||
|
- 2026-10-27 — T6.4: кодируются только пробел и круглые скобки. Обоснование: только они ломают разбор
|
||||||
|
`[текст](адрес)`; прочее браузер разбирает сам. Цена ошибки — редкая внешняя ссылка не кликается.
|
||||||
|
- 2026-10-27 — T8.2: проверка файла — только в `request_fetch`, не при чтении состояния. Обоснование: тупик был в
|
||||||
|
повторном запросе, не в показе. Цена ошибки — до нажатия вложение выглядит скачанным.
|
||||||
|
- 2026-10-27 — T8.3: отказ только при активной синхронизации того же корня. Обоснование: смена корня ставит
|
||||||
|
синхронизацию нового корня при выданной старой (спека «Синхронизация вики»). Цена ошибки — один лишний запрос
|
||||||
|
дерева в Eva.
|
||||||
|
- 2026-10-27 — T9.3: все значения Eva в строках ответа MCP — через одну функцию `_line`, тело страницы как есть.
|
||||||
|
Цена ошибки — длинное значение в одну строку.
|
||||||
|
- 2026-10-27 — картинки: признак `image` считает хаб по имени файла в адресе Eva (не по имени вложения), SVG не
|
||||||
|
показывается; под картинкой остаётся ссылка сохранения файла. Обоснование: на живом AWPCA имена картинок бывают
|
||||||
|
без расширения; SVG — недоверенный активный формат. Цена ошибки — картинка с необычным расширением остаётся ссылкой.
|
||||||
|
- 2026-10-27 — T8.3: при возврате корня `enqueue_sync` отдаёт идущий пакет того же корня в форме `create_batch`, не
|
||||||
|
различая «новый/идущий». Цена ошибки — ответ PATCH называет уже идущий пакет.
|
||||||
|
- 2026-10-27 — T6.1: картинка-другое-вложение внутри ссылки на вложение выводится своей ссылкой перед ссылкой
|
||||||
|
внешнего вложения. Цена ошибки — порядок двух ссылок в строке.
|
||||||
|
- 2026-10-27 — R-1: в шапку плана добавлена строка «Ветка» — хук require-plan ищет имя ветки в плане. Цена ошибки —
|
||||||
|
нет (на поведение не влияет).
|
||||||
|
- 2026-10-27 — R-2: в MCP `sync` с ожиданием ошибка команды тоже выводится одной строкой (`_line`) — это тот же текст
|
||||||
|
Eva, что «ошибка синхронизации» решения 8. Цена ошибки — нет.
|
||||||
|
- 2026-10-27 — R-3: хранилище картинок живёт внутри `useWikiDownload` (третий параметр `attachments`, геттер `image`);
|
||||||
|
`components` в `PageView` пересоздаётся при смене карты, как и при смене `status`. Решение 10 допускало оба
|
||||||
|
прочтения; выбрано прежнее поведение `status`: состояние `busy`/`error` живёт в хуке и перемонтированием не
|
||||||
|
сбрасывается (тест). Цена ошибки — перемонтирование ссылок и картинок страницы при смене состояния другого вложения
|
||||||
|
(возможное мигание, потеря фокуса; финальное ревью M-3, deferred).
|
||||||
|
- 2026-10-27 — R-4: картинка выводится над кнопкой-ссылкой сохранения — по сценарию «Скачать картинку». Цена ошибки — нет.
|
||||||
|
- 2026-10-27 — R-5: у внешней ссылки края текста сохраняются, адрес вместо пустого текста — только при вынесенном
|
||||||
|
вложении (финальное ревью I-1, M-1). Цена ошибки — нет: прежнее поведение для ссылок без вложений.
|
||||||
47
openspec/changes/wiki-package-d/proposal.md
Normal file
47
openspec/changes/wiki-package-d/proposal.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Пакет D сверки follow-ups 25.10.2026 (sdlc-hub#135, маркеры из #94). Сверка с кодом 27.10.2026 подтвердила дефекты модуля
|
||||||
|
`wiki`. Самый заметный — T6.1: если в HTML Eva между ссылкой на файл и его миниатюрой стоит обёртка (`<a><span><img>`),
|
||||||
|
ссылка на вложение пропадает из текста страницы целиком, хотя вложение в базе есть. Остальные дефекты: сломанный
|
||||||
|
Markdown на нестандартных ссылках, вложение «скачано», которое нельзя ни открыть, ни скачать заново, вторая живая
|
||||||
|
синхронизация через повтор команды и подделка строк ответа MCP переводами строк из данных Eva.
|
||||||
|
|
||||||
|
Владелец 27.10.2026 добавил в пакет показ скачанных картинок в тексте страницы «Вики»: сейчас картинку можно только
|
||||||
|
сохранить файлом.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Перевод HTML** (T6.1–T6.5): миниатюра внутри ссылки на вложение распознаётся на любой глубине вложенности. Вложение
|
||||||
|
внутри ссылки на страницу выводится отдельной ссылкой рядом со ссылкой на страницу, без вложенных ссылок. При
|
||||||
|
повторе `ref` в списке агента побеждает первая запись. Пробелы и круглые скобки во внешних адресах кодируются.
|
||||||
|
Хвостовая `\` в тексте ссылки на страницу экранируется.
|
||||||
|
- **Вложение без файла** (T8.2): `POST /wiki/attachments/{id}/fetch` для вложения в `ready`, чей файл пропал из
|
||||||
|
`files/`, ставит скачивание заново, а не отвечает `ready`.
|
||||||
|
- **Одна синхронизация на корень** (T8.3): пакет с `wiki_sync` (в том числе повтор команды) отклоняется, если у проекта
|
||||||
|
уже идёт `wiki_sync` с тем же корнем.
|
||||||
|
- **MCP вики** (T9.3): переводы строк в названии, пути, имени вложения и фрагменте поиска заменяются пробелом.
|
||||||
|
- **Картинки в тексте** (решение владельца): на странице «Вики» вложение-картинка после скачивания показывается в
|
||||||
|
тексте на месте ссылки; уже скачанные картинки показываются при открытии страницы. Без клика хаб ничего не скачивает.
|
||||||
|
Картинку определяет хаб по имени файла в адресе Eva: поле `image` у вложений в `GET /wiki/pages/{code}`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Остальные маркеры #94 (M-7, T7.x, T8.4–T8.9, T9.1–T9.7 кроме T9.3 и др.) — остаются в #94.
|
||||||
|
- eva-agent#4 Н-1 (битый `agent.json`) — отдельный bounded PR в eva-agent.
|
||||||
|
- Автоматическое скачивание картинок при открытии страницы — владелец выбрал «только после нажатия».
|
||||||
|
- Картинки в Markdown-копии на диске и в MCP: копия и ответы MCP не меняются, картинки — только в веб-приложении.
|
||||||
|
- Показ SVG в тексте.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `wiki` — «Синхронизация вики», «Markdown-копия вики», «Вложения вики по запросу», «Маршруты вики».
|
||||||
|
- `mcp` — «MCP вики».
|
||||||
|
- `web-app` — «Вики проекта».
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Хаб: `modules/wiki/convert.py`, `attachments.py`, `api.py`, `commands.py`, `mcp.py`; тесты `tests/test_wiki_*`.
|
||||||
|
- Веб: `web/src/modules/wiki/` (`PageView.tsx`, `download.ts`, `wiki.css`) и тесты рядом.
|
||||||
|
- Схема базы и контракт с агентом `wiki` не меняются; в ответе `GET /wiki/pages/{code}` у вложений новое поле `image`. Выкатка: `make web` + перезапуск хаба.
|
||||||
48
openspec/changes/wiki-package-d/specs/mcp/spec.md
Normal file
48
openspec/changes/wiki-package-d/specs/mcp/spec.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: MCP вики
|
||||||
|
`sdlc-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `sdlc-wiki`,
|
||||||
|
ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: sdlc-hub serve».
|
||||||
|
Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты:
|
||||||
|
|
||||||
|
| Инструмент | Параметры | Ответ |
|
||||||
|
|---|---|---|
|
||||||
|
| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен |
|
||||||
|
| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `sdlc-wiki` |
|
||||||
|
| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации |
|
||||||
|
| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер |
|
||||||
|
| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=<N>», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами |
|
||||||
|
| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент |
|
||||||
|
| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-<id>/<имя>`; иначе состояние и подсказка повторить |
|
||||||
|
| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог |
|
||||||
|
| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) |
|
||||||
|
|
||||||
|
Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня
|
||||||
|
(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без
|
||||||
|
синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira`
|
||||||
|
(модуль jira) SHALL остаться прежним.
|
||||||
|
|
||||||
|
Значения из Eva, которые инструменты выводят внутри строки ответа (названия страниц и корня, путь, имя вложения,
|
||||||
|
фрагмент поиска, кандидаты `page` по названию), SHALL выводиться одной строкой: переводы строк (`\r`, `\n`) и пробелы
|
||||||
|
вокруг них заменяются одним пробелом. Тело страницы (Markdown после шапки `page`) выводится как есть.
|
||||||
|
|
||||||
|
#### Scenario: Поиск из сессии awp-ca
|
||||||
|
- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}`
|
||||||
|
- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…`
|
||||||
|
|
||||||
|
#### Scenario: Длинная страница
|
||||||
|
- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов
|
||||||
|
- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки
|
||||||
|
продолжения
|
||||||
|
|
||||||
|
#### Scenario: Корня нет
|
||||||
|
- **WHEN** сессия `sfm_kedr` без корня, вызван `tree`
|
||||||
|
- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет
|
||||||
|
|
||||||
|
#### Scenario: Сервер jira не изменился
|
||||||
|
- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA`
|
||||||
|
- **THEN** имя сервера `sdlc-jira`, список инструментов и описания совпадают с эталоном до change
|
||||||
|
|
||||||
|
#### Scenario: Перевод строки в названии страницы
|
||||||
|
- **WHEN** у DOC-3 название «Статусы\nпуть: поддельный», вызван `page {code: DOC-3}`
|
||||||
|
- **THEN** первая строка ответа — «DOC-3 — Статусы путь: поддельный», строка «путь:» в шапке одна — настоящая
|
||||||
57
openspec/changes/wiki-package-d/specs/web-app/spec.md
Normal file
57
openspec/changes/wiki-package-d/specs/web-app/spec.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Вики проекта
|
||||||
|
Приложение SHALL иметь страницу «Вики» (`/app/wiki?project=<метка>`, раздел группы «Вики» боковой панели; текущий
|
||||||
|
проект — как у остальных страниц, «Проект в приложении»). Шапка страницы: заголовок «Вики», подзаголовок «<название
|
||||||
|
корня> · <N> страниц · обновлено ДД.ММ ЧЧ:ММ» и действие «Обновить вики» (вторичная кнопка; требование «Состояние и
|
||||||
|
обновление модулей»). Страница SHALL показывать:
|
||||||
|
- дерево страниц проекта (`GET /wiki/tree`) слева, свёрнутое до первого уровня; узел раскрывается по клику; узел
|
||||||
|
выбранной страницы и его предки раскрыты;
|
||||||
|
- выбранную страницу справа (`GET /wiki/pages/{code}`): заголовок, дата синхронизации, ссылка «открыть в Eva»,
|
||||||
|
Markdown с таблицами, без сырого HTML; без `page` в адресе — корневая страница; ссылки `page:<код>` открывают страницу
|
||||||
|
вики (код в адресе `&page=<код>`), ссылки `attachment:<id>` скачивают вложение (запрос, опрос состояния, сохранение
|
||||||
|
файла браузером), состояние `failed` — текст ошибки рядом со ссылкой;
|
||||||
|
- вложение-картинку (`image = true` в `attachments` ответа страницы) — в тексте
|
||||||
|
страницы на месте ссылки: вложение в `ready` при открытии страницы показывается картинкой сразу (содержимое `GET
|
||||||
|
/wiki/attachments/{id}/content`, без нового скачивания агентом); нажатие на ссылку нескачанной картинки ставит
|
||||||
|
скачивание (запрос, опрос состояния) и по готовности показывает картинку вместо сохранения файла. Под картинкой
|
||||||
|
остаётся ссылка «📎 <имя>», сохраняющая файл. Картинка не шире колонки страницы. Содержимое не получено (409
|
||||||
|
`not_ready`, ошибка сети) — вместо картинки прежняя ссылка, нажатие на неё ставит скачивание и по готовности
|
||||||
|
показывает картинку; ошибка скачивания — текст рядом со ссылкой. Без нажатия приложение не ставит скачивание;
|
||||||
|
- на экранах уже 640 px выбранная страница — над деревом;
|
||||||
|
- поиск над деревом (`GET /wiki/search`): список результатов с фрагментами вместо дерева, пока строка поиска не пуста;
|
||||||
|
после очистки поиска раскрытие дерева прежнее;
|
||||||
|
- проекта без корня — форму «Корень вики (DOC-… или ссылка)» с кнопкой «Привязать» (`PATCH /projects/{label}`); 422 —
|
||||||
|
текст ошибки у поля;
|
||||||
|
- корень есть, синхронизаций нет — «вики ещё не синхронизирована» и действие «Обновить вики»;
|
||||||
|
- последняя синхронизация с ошибкой — плашку с текстом ошибки над страницей.
|
||||||
|
|
||||||
|
#### Scenario: Открыть страницу по ссылке
|
||||||
|
- **WHEN** на странице DOC-3 нажата ссылка `page:DOC-2`
|
||||||
|
- **THEN** адрес `/app/wiki?project=AWPCA&page=DOC-2`, справа DOC-2, в дереве DOC-2 выделен
|
||||||
|
|
||||||
|
#### Scenario: Проект без корня
|
||||||
|
- **WHEN** открыта `/app/wiki?project=sfm_kedr`, корня нет; введено `DOC-001234`, нажато «Привязать»
|
||||||
|
- **THEN** `PATCH /projects/sfm_kedr {wiki_root: "DOC-001234"}`; строка «Вики» блока состояния показывает ход пакета
|
||||||
|
синхронизации
|
||||||
|
|
||||||
|
#### Scenario: Поиск
|
||||||
|
- **WHEN** в строке поиска «согласов»
|
||||||
|
- **THEN** вместо дерева — результаты `GET /wiki/search?project=AWPCA&q=согласов`; очистка строки возвращает дерево
|
||||||
|
|
||||||
|
#### Scenario: Без выбранной страницы
|
||||||
|
- **WHEN** открыта `/app/wiki?project=AWPCA` без `page`
|
||||||
|
- **THEN** справа корневая страница проекта, в дереве она выделена
|
||||||
|
|
||||||
|
#### Scenario: Скачать картинку
|
||||||
|
- **WHEN** на странице DOC-3 вложение 7 `scheme.png` (`image = true`) в `none`; нажата ссылка «📎 scheme.png»; агент скачал файл
|
||||||
|
- **THEN** `POST /wiki/attachments/7/fetch`, опрос до `ready`, `GET /wiki/attachments/7/content`; на месте ссылки —
|
||||||
|
картинка, под ней ссылка «📎 scheme.png»; файл браузером не сохраняется
|
||||||
|
|
||||||
|
#### Scenario: Скачанная картинка видна сразу
|
||||||
|
- **WHEN** вложение 7 `scheme.png` в `ready`, открыта страница DOC-3
|
||||||
|
- **THEN** картинка показана без нажатия; `POST /wiki/attachments/7/fetch` не вызывался
|
||||||
|
|
||||||
|
#### Scenario: Не картинка
|
||||||
|
- **WHEN** вложение 8 `model.xlsx` (`image = false`) в `ready`, нажата ссылка «📎 model.xlsx»
|
||||||
|
- **THEN** файл сохраняется браузером, картинки нет
|
||||||
183
openspec/changes/wiki-package-d/specs/wiki/spec.md
Normal file
183
openspec/changes/wiki-package-d/specs/wiki/spec.md
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Синхронизация вики
|
||||||
|
Команда `wiki_sync {label, root}` SHALL быть командой чтения модуля `wiki` (без копии базы) в пакете с `project =
|
||||||
|
label`. Её SHALL ставить: `POST /wiki/sync {project}`, задание или смена корня, фоновая работа хаба — по каждому
|
||||||
|
проекту с корнем, если с последней успешной или неудачной синхронизации прошло больше `wiki_auto_sync_hours`
|
||||||
|
(настройка модуля, по умолчанию 24; `0` — выключено). Пока у проекта есть пакет `wiki_sync` не в терминальном статусе,
|
||||||
|
новый не ставится: `POST /wiki/sync` отвечает 409 `sync_in_progress` с `batch_id` текущего. Любая другая постановка
|
||||||
|
пакета с `wiki_sync` (`POST /batches`, повтор команды `POST /commands/{id}/retry`) SHALL отклоняться с 422, если у
|
||||||
|
проекта уже есть команда `wiki_sync` с тем же `root` в `queued` или `claimed`; текст ошибки называет пакет идущей
|
||||||
|
синхронизации. Команда с другим `root` не мешает: смена корня ставит синхронизацию нового корня, пока старая ещё
|
||||||
|
выдана агенту. Смена или сброс корня SHALL отменять невыданные (`queued`) пакеты `wiki_sync` проекта и при новом корне ставить новый; отчёт `done` по
|
||||||
|
команде, чей `root` не равен текущему корню проекта, SHALL отбрасываться без изменения копии и данных
|
||||||
|
(`result.discarded = "корень сменился"`).
|
||||||
|
|
||||||
|
Агент `wiki` SHALL прислать все страницы поддерева корня (включая корень) пачками `POST /agent/wiki/pages {command_id,
|
||||||
|
pages}` — каждая страница `{code, eva_id, title, parent_code, orderno, url, html, attachments: [{ref, url, name}],
|
||||||
|
links: [{ref, code}]}`, где `attachments` — ссылки и картинки на файлы Eva, `links` — ссылки на документы Eva, `ref` —
|
||||||
|
значение атрибута `src`/`href` ровно как в `html`, `url` — абсолютный адрес, `name` — `alt`/`title`, текст ссылки или
|
||||||
|
имя файла; в пачке не больше 50 страниц, — и отчитаться `done` с `result = {root_title, codes, eva_calls}`, где
|
||||||
|
`codes` — коды всех страниц дерева в порядке обхода. Хаб SHALL при приёме пачки переводить HTML в Markdown вне
|
||||||
|
транзакции записи и складывать результат во временную стадию команды, не трогая копию. Пачка по
|
||||||
|
команде не в `claimed` или не `wiki_sync` — 409; неверная форма страницы — 422 `bad_pages` с кодом страницы и полем.
|
||||||
|
|
||||||
|
По отчёту `done` хаб SHALL одной операцией заменить копию проекта (см. «Markdown-копия вики»), страницы, индекс поиска и
|
||||||
|
вложения и записать `synced_at`, `pages_count`, `root_title`, очистив `sync_error`. Код из `codes` без присланной
|
||||||
|
страницы — команда `failed` «нет страницы <код>». Отчёт `failed` или перевод в `failed` по пределу выдач SHALL записать
|
||||||
|
`sync_error`; копия, страницы и вложения остаются прежними. Повторная выдача команды SHALL очищать её стадию. Стадия
|
||||||
|
очищается при любом терминальном статусе команды.
|
||||||
|
|
||||||
|
#### Scenario: Первая синхронизация
|
||||||
|
- **WHEN** у `AWPCA` корень DOC-1 с детьми DOC-2, DOC-3; агент прислал три страницы одной пачкой и `done {root_title:
|
||||||
|
"АРМ КА", codes: [DOC-1, DOC-2, DOC-3]}`
|
||||||
|
- **THEN** в `wiki/AWPCA/` три `index.md`; `GET /projects` — `wiki_pages = 3`, `wiki_root_title = АРМ КА`,
|
||||||
|
`wiki_synced_at` заполнен
|
||||||
|
|
||||||
|
#### Scenario: Неудачная синхронизация не портит копию
|
||||||
|
- **WHEN** копия `AWPCA` из 3 страниц, следующая синхронизация завершилась `failed` «Eva: HTTP 503»
|
||||||
|
- **THEN** копия и поиск прежние; `wiki_sync_error = "Eva: HTTP 503"`
|
||||||
|
|
||||||
|
#### Scenario: Потерянная пачка
|
||||||
|
- **WHEN** отчёт `done` с `codes: [DOC-1, DOC-2]`, пачки пришли только для DOC-1
|
||||||
|
- **THEN** команда `failed` «нет страницы DOC-2», копия прежняя
|
||||||
|
|
||||||
|
#### Scenario: Страница удалена в Eva
|
||||||
|
- **WHEN** в прошлой копии были DOC-1, DOC-2, DOC-3, новая синхронизация вернула DOC-1, DOC-3
|
||||||
|
- **THEN** каталога DOC-2 нет, поиск его не находит, его вложения удалены
|
||||||
|
|
||||||
|
#### Scenario: Корень исправлен, пока агент не установлен
|
||||||
|
- **WHEN** у `AWPCA` корень DOC-12 и пакет `wiki_sync` в `queued`; `PATCH /projects/AWPCA {wiki_root: "DOC-21"}`
|
||||||
|
- **THEN** прежний пакет `cancelled`, поставлен `wiki_sync {root: DOC-21}`
|
||||||
|
|
||||||
|
#### Scenario: Поздний отчёт по старому корню
|
||||||
|
- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту, корень сменён на DOC-21, агент отчитался `done`
|
||||||
|
- **THEN** копия и страницы `AWPCA` не изменились, команда `done` с `result.discarded`
|
||||||
|
|
||||||
|
#### Scenario: Повторный запрос синхронизации
|
||||||
|
- **WHEN** пакет `wiki_sync` проекта `AWPCA` выполняется, вызван `POST /wiki/sync {project: AWPCA}`
|
||||||
|
- **THEN** 409 `sync_in_progress` с `batch_id` текущего пакета, нового пакета нет
|
||||||
|
|
||||||
|
#### Scenario: Повтор упавшей синхронизации во время идущей
|
||||||
|
- **WHEN** команда `wiki_sync {label: AWPCA, root: DOC-1}` 40 в `failed`, фоновая работа поставила пакет 41 с
|
||||||
|
`wiki_sync {root: DOC-1}` (`queued`); `POST /commands/40/retry`
|
||||||
|
- **THEN** 422, в тексте — пакет 41; нового пакета нет
|
||||||
|
|
||||||
|
#### Scenario: Смена корня при выданной синхронизации
|
||||||
|
- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту (`claimed`); `PATCH /projects/AWPCA {wiki_root: "DOC-21"}`
|
||||||
|
- **THEN** 200, поставлен `wiki_sync {root: DOC-21}`
|
||||||
|
|
||||||
|
### Requirement: Markdown-копия вики
|
||||||
|
Копия проекта SHALL лежать в `<SDLC_HUB_HOME>/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог
|
||||||
|
`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня
|
||||||
|
номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей —
|
||||||
|
суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# <title>` и текст. В
|
||||||
|
корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом
|
||||||
|
(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте.
|
||||||
|
|
||||||
|
HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva
|
||||||
|
с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а
|
||||||
|
ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на
|
||||||
|
`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный
|
||||||
|
`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает.
|
||||||
|
|
||||||
|
Перевод SHALL давать корректный Markdown на любом HTML Eva:
|
||||||
|
- картинка внутри ссылки на то же вложение — на любой глубине вложенности (`<a><span><img></span></a>`) — даёт одну
|
||||||
|
ссылку на вложение;
|
||||||
|
- вложение внутри ссылки на документ Eva выводится отдельной ссылкой на вложение перед ссылкой на страницу; текст
|
||||||
|
ссылки на страницу — её текст без вложения, пустой — код страницы; ссылка внутри ссылки не появляется;
|
||||||
|
- при нескольких записях `attachments` или `links` с одним `ref` действует первая;
|
||||||
|
- пробел и круглые скобки в адресе внешней ссылки или картинки кодируются (`%20`, `%28`, `%29`);
|
||||||
|
- текст ссылки не может экранировать её закрывающую скобку: хвостовая `\` экранируется.
|
||||||
|
|
||||||
|
#### Scenario: Раскладка
|
||||||
|
- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2)
|
||||||
|
- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке
|
||||||
|
DOC-1, DOC-3, DOC-2
|
||||||
|
|
||||||
|
#### Scenario: Картинка на странице
|
||||||
|
- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">`
|
||||||
|
- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан
|
||||||
|
|
||||||
|
#### Scenario: Прикреплённый файл с миниатюрой
|
||||||
|
- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и
|
||||||
|
ссылка на `…/<uuid>/model.xlsx`
|
||||||
|
- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет
|
||||||
|
|
||||||
|
#### Scenario: Миниатюра в обёртке
|
||||||
|
- **WHEN** `<a href="…/model.xlsx"><span><img src="…/model.xlsx.meta/thumbnail.jpg"></span></a> текст`
|
||||||
|
- **THEN** в тексте `[📎 model.xlsx](attachment:<id>) текст`
|
||||||
|
|
||||||
|
#### Scenario: Ссылка на другую страницу
|
||||||
|
- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>`
|
||||||
|
- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)`
|
||||||
|
|
||||||
|
#### Scenario: Картинка-ссылка на другую страницу
|
||||||
|
- **WHEN** `<a href="project/Document/DOC-1"><img src="…/scheme.png"></a>`, `scheme.png` — вложение
|
||||||
|
- **THEN** в тексте `[📎 scheme.png](attachment:<id>) [DOC-1](page:DOC-1)`
|
||||||
|
|
||||||
|
#### Scenario: Пробелы и скобки во внешнем адресе
|
||||||
|
- **WHEN** `<a href="http://ex.com/a b(c).html">док</a>`
|
||||||
|
- **THEN** в тексте `[док](http://ex.com/a%20b%28c%29.html)`
|
||||||
|
|
||||||
|
#### Scenario: Обратная косая черта в конце текста
|
||||||
|
- **WHEN** `<a href="project/Document/DOC-1">путь C:\</a>`
|
||||||
|
- **THEN** ссылка на `page:DOC-1` с текстом «путь C:\» остаётся ссылкой при разборе Markdown
|
||||||
|
|
||||||
|
### Requirement: Вложения вики по запросу
|
||||||
|
`POST /wiki/attachments/{id}/fetch` SHALL ставить пакет из одной команды чтения `wiki_fetch {attachment_id, url}` и
|
||||||
|
переводить вложение в `fetching`; для `fetching` — ответ текущим состоянием без нового пакета, для `ready` — сразу
|
||||||
|
`ready`. Вложение в `ready`, чьего файла нет в `files/`, SHALL считаться нескачанным: запрос ставит скачивание заново.
|
||||||
|
Агент `wiki` SHALL скачать файл с токеном Eva, отказаться от файла больше предела хранилища ядра (10 МБ) с
|
||||||
|
ошибкой «файл больше 10 МБ — откройте в Eva», загрузить файл `POST /agent/files` и отчитаться `done {file, size}`. Хаб
|
||||||
|
SHALL проверить, что файл есть в `files/` и размер совпадает, и перевести вложение в `ready`; иначе команда и вложение
|
||||||
|
`failed`. Адрес не на хосте Eva из конфига агента агент SHALL отвергать без запроса (`failed` «чужой хост»).
|
||||||
|
Файлы вложений в `ready` SHALL считаться ссылками для уборки `files/`. `GET /wiki/attachments/{id}` отдаёт
|
||||||
|
`{id, name, page_code, url, state, size, error}`, `GET /wiki/attachments/{id}/content` — содержимое (`ready`) или 409
|
||||||
|
`not_ready`.
|
||||||
|
|
||||||
|
#### Scenario: Скачивание картинки
|
||||||
|
- **WHEN** вложение 7 в `none`, `POST /wiki/attachments/7/fetch`, агент прислал файл 300 КБ
|
||||||
|
- **THEN** вложение 7 `ready`, `size = 307200`, `GET /wiki/attachments/7/content` — файл
|
||||||
|
|
||||||
|
#### Scenario: Слишком большой файл
|
||||||
|
- **WHEN** агент отчитался `failed` «файл больше 10 МБ — откройте в Eva»
|
||||||
|
- **THEN** вложение `failed` с этим текстом; `GET …/content` — 409 `not_ready`
|
||||||
|
|
||||||
|
#### Scenario: Вложение переживает синхронизацию
|
||||||
|
- **WHEN** вложение 7 `ready`, синхронизация вернула ту же страницу с той же ссылкой
|
||||||
|
- **THEN** у вложения тот же `id` 7 и `state = ready`, файл в `files/` не убран
|
||||||
|
|
||||||
|
#### Scenario: Файл вложения пропал
|
||||||
|
- **WHEN** вложение 7 `ready`, его файла нет в `files/`; `POST /wiki/attachments/7/fetch`
|
||||||
|
- **THEN** 202, вложение 7 `fetching`, поставлен пакет `wiki_fetch {attachment_id: 7}`
|
||||||
|
|
||||||
|
### Requirement: Маршруты вики
|
||||||
|
Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409
|
||||||
|
`no_root`):
|
||||||
|
- `GET /wiki/status?project=` — `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`;
|
||||||
|
- `GET /wiki/tree?project=` — `{"pages": [{code, title, depth, parent_code, size}]}` в порядке обхода;
|
||||||
|
- `GET /wiki/pages/{code}?project=` — `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state,
|
||||||
|
size, error, image}]}`; `image` — `true`, если имя файла в адресе вложения (последний сегмент пути без запроса,
|
||||||
|
раскодированный) оканчивается на `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp` без учёта регистра, иначе `false`
|
||||||
|
(имя вложения из Eva для этого не годится: у картинок это часто `alt` без расширения); страницы нет в копии — 404;
|
||||||
|
- `GET /wiki/search?project=&q=&limit=` (`limit` 1–50, по умолчанию 10) — `{"results": [{code, title, path, snippet}]}` по
|
||||||
|
убыванию релевантности; слова запроса ищутся по префиксу, пустой запрос — 422;
|
||||||
|
- `POST /wiki/sync {project, author?}` → 201 `{batch_id}` (409 — см. «Синхронизация вики»);
|
||||||
|
- маршруты вложений — см. «Вложения вики по запросу».
|
||||||
|
|
||||||
|
Под токеном агента `wiki` — `POST /agent/wiki/pages` (см. «Синхронизация вики»).
|
||||||
|
|
||||||
|
#### Scenario: Поиск
|
||||||
|
- **WHEN** в копии `AWPCA` страница DOC-3 «Статусы» содержит «статус «На согласовании» ставит аналитик»;
|
||||||
|
`GET /wiki/search?project=AWPCA&q=согласов`
|
||||||
|
- **THEN** первым — DOC-3 с фрагментом, содержащим «На согласовании»
|
||||||
|
|
||||||
|
#### Scenario: Проект без корня
|
||||||
|
- **WHEN** у `sfm_kedr` нет корня, `GET /wiki/tree?project=sfm_kedr`
|
||||||
|
- **THEN** 409 `no_root`
|
||||||
|
|
||||||
|
#### Scenario: Признак картинки
|
||||||
|
- **WHEN** у вложения 9 страницы DOC-3 имя «Схема расчёта суммы элиминации по тройке», адрес
|
||||||
|
`…/vgo-matching-1.png?version=1`; у вложения 8 имя `attachment`, адрес `…/model.xlsx`
|
||||||
|
- **THEN** в `GET /wiki/pages/DOC-3?project=AWPCA` у 9 `image = true`, у 8 `image = false`
|
||||||
@ -73,8 +73,9 @@ def h_page(hub, conn, m, q, body):
|
|||||||
end = text.find("\n---\n", 4)
|
end = text.find("\n---\n", 4)
|
||||||
text = text[end + 5:].lstrip("\n") if end != -1 else text
|
text = text[end + 5:].lstrip("\n") if end != -1 else text
|
||||||
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"],
|
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"],
|
||||||
"error": a["error"]} for a in conn.execute(
|
"error": a["error"], "image": attachments.is_image(a["url"])} for a in conn.execute(
|
||||||
"SELECT id, name, state, size, error FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
|
"SELECT id, name, state, size, error, url FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id",
|
||||||
|
(label, code))]
|
||||||
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
|
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
|
||||||
"markdown": text, "attachments": atts}
|
"markdown": text, "attachments": atts}
|
||||||
|
|
||||||
@ -122,7 +123,7 @@ def h_attachment_fetch(hub, conn, m, q, body):
|
|||||||
if not isinstance(author, str):
|
if not isinstance(author, str):
|
||||||
raise ApiError(422, "bad_request", "author — строка")
|
raise ApiError(422, "bad_request", "author — строка")
|
||||||
try:
|
try:
|
||||||
info = attachments.request_fetch(conn, int_group(m), author)
|
info = attachments.request_fetch(conn, int_group(m), author, hub.files_dir)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ApiError(404, "not_found", f"вложение {m.group(1)}") from None
|
raise ApiError(404, "not_found", f"вложение {m.group(1)}") from None
|
||||||
queued = info.pop("queued")
|
queued = info.pop("queued")
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
|
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
|
||||||
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
|
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, unquote, urlsplit
|
||||||
|
|
||||||
from ...core import db, files, queue
|
from ...core import db, files, queue
|
||||||
|
|
||||||
FIELDS = "id, label, page_code, url, name, state, file, size, error"
|
FIELDS = "id, label, page_code, url, name, state, file, size, error"
|
||||||
|
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") # SVG — нет: активный формат из недоверенного источника
|
||||||
|
|
||||||
|
|
||||||
|
def is_image(url: str | None) -> bool:
|
||||||
|
"""Картинка ли вложение — по имени файла в адресе Eva: имя вложения (alt/title/текст ссылки) бывает без расширения."""
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
return unquote(urlsplit(url).path.rsplit("/", 1)[-1]).lower().endswith(IMAGE_EXT)
|
||||||
|
|
||||||
|
|
||||||
def _public(row) -> dict:
|
def _public(row) -> dict:
|
||||||
@ -33,15 +41,17 @@ def _active(conn, attachment_id: int) -> bool:
|
|||||||
"AND json_extract(params, '$.attachment_id') = ?", (attachment_id,)).fetchone() is not None
|
"AND json_extract(params, '$.attachment_id') = ?", (attachment_id,)).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
def request_fetch(conn, attachment_id: int, author: str | None) -> dict:
|
def request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict:
|
||||||
"""Поставить скачивание. Одна транзакция: `ready` и `fetching` отвечают как есть; иначе state = fetching и пакет
|
"""Поставить скачивание. Одна транзакция: `ready` и `fetching` отвечают как есть; иначе state = fetching и пакет
|
||||||
wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет.
|
wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет. `ready`, чьего
|
||||||
|
файла нет в files/, — тоже нескачанное (T8.2); files_dir None — файл не сверяется.
|
||||||
Ответ — {id, name, page_code, url, state, size, error, queued}. KeyError — вложения нет."""
|
Ответ — {id, name, page_code, url, state, size, error, queued}. KeyError — вложения нет."""
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()
|
row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise KeyError(attachment_id)
|
raise KeyError(attachment_id)
|
||||||
if row["state"] == "ready" or (row["state"] == "fetching" and _active(conn, attachment_id)):
|
on_disk = files_dir is None or files.size(files_dir, row["file"]) is not None
|
||||||
|
if (row["state"] == "ready" and on_disk) or (row["state"] == "fetching" and _active(conn, attachment_id)):
|
||||||
return dict(_public(row), queued=False)
|
return dict(_public(row), queued=False)
|
||||||
title = f"Вложение вики {file_name(_public(row))}"
|
title = f"Вложение вики {file_name(_public(row))}"
|
||||||
queue.insert_batch(conn, title, author, [{"type": "wiki_fetch", "params": {"attachment_id": row["id"], "url": row["url"]}}],
|
queue.insert_batch(conn, title, author, [{"type": "wiki_fetch", "params": {"attachment_id": row["id"], "url": row["url"]}}],
|
||||||
|
|||||||
@ -47,16 +47,38 @@ def validate(cmds) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _active_same_root(conn, label: str, root: str) -> int | None:
|
||||||
|
"""id самого нового пакета проекта с wiki_sync того же корня в queued/claimed или None."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT batch_id FROM command WHERE type = 'wiki_sync' AND status IN ('queued', 'claimed') "
|
||||||
|
"AND json_extract(params, '$.label') = ? AND json_extract(params, '$.root') = ? ORDER BY batch_id DESC LIMIT 1",
|
||||||
|
(label, root)).fetchone()
|
||||||
|
return row["batch_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def check_batch(conn, cmds, project, files_dir) -> list[dict]:
|
def check_batch(conn, cmds, project, files_dir) -> list[dict]:
|
||||||
"""wiki_sync живёт в пакете своего проекта: project пакета равен label команды."""
|
"""wiki_sync живёт в пакете своего проекта: project пакета равен label команды. Вторая живая синхронизация того же
|
||||||
|
корня не ставится — ни через POST /batches, ни повтором команды (T8.3); другой корень не мешает (смена корня)."""
|
||||||
for i, c in enumerate(cmds, 1):
|
for i, c in enumerate(cmds, 1):
|
||||||
if c["type"] == "wiki_sync" and c["params"]["label"] != project:
|
if c["type"] != "wiki_sync":
|
||||||
|
continue
|
||||||
|
if c["params"]["label"] != project:
|
||||||
raise CommandError(f"команда {i}: проект пакета ({project}) не равен label ({c['params']['label']})")
|
raise CommandError(f"команда {i}: проект пакета ({project}) не равен label ({c['params']['label']})")
|
||||||
|
bid = _active_same_root(conn, project, c["params"]["root"])
|
||||||
|
if bid is not None:
|
||||||
|
raise CommandError(f"команда {i}: у проекта {project} уже идёт синхронизация корня {c['params']['root']} "
|
||||||
|
f"(пакет #{bid})")
|
||||||
return cmds
|
return cmds
|
||||||
|
|
||||||
|
|
||||||
def enqueue_sync(conn, label: str, root: str, author: str) -> dict:
|
def enqueue_sync(conn, label: str, root: str, author: str) -> dict:
|
||||||
"""Пакет «Вики <метка>» из одной команды wiki_sync."""
|
"""Пакет «Вики <метка>» из одной команды wiki_sync. Идёт синхронизация того же корня (возврат корня A → B → A,
|
||||||
|
пока A у агента) — её пакет в той же форме {id, estimate_seconds, count}, нового нет."""
|
||||||
|
bid = _active_same_root(conn, label, root)
|
||||||
|
if bid is not None:
|
||||||
|
row = conn.execute("SELECT b.estimate_seconds, (SELECT COUNT(*) FROM command c WHERE c.batch_id = b.id) AS n "
|
||||||
|
"FROM batch b WHERE b.id = ?", (bid,)).fetchone()
|
||||||
|
return {"id": bid, "estimate_seconds": row["estimate_seconds"], "count": row["n"]}
|
||||||
return queue.create_batch(conn, f"Вики {label}", author, [{"type": "wiki_sync", "params": {"label": label, "root": root}}],
|
return queue.create_batch(conn, f"Вики {label}", author, [{"type": "wiki_sync", "params": {"label": label, "root": root}}],
|
||||||
project=label)
|
project=label)
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ _PRIVATE = re.compile("[]")
|
|||||||
_NAME_EDGE = re.compile(r"(?<!\w)[_*]|[_*](?!\w)") # «_» и «*» на границе слова; внутри слова — как есть
|
_NAME_EDGE = re.compile(r"(?<!\w)[_*]|[_*](?!\w)") # «_» и «*» на границе слова; внутри слова — как есть
|
||||||
_BRACKET = re.compile(r"(?<!\\)([\[\]])")
|
_BRACKET = re.compile(r"(?<!\\)([\[\]])")
|
||||||
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
|
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
|
||||||
|
_TAIL = re.compile(r"\\+$")
|
||||||
_MARK_OPEN, _MARK_CLOSE = "", ""
|
_MARK_OPEN, _MARK_CLOSE = "", ""
|
||||||
_MARK = re.compile("(\\d+)")
|
_MARK = re.compile("(\\d+)")
|
||||||
|
|
||||||
@ -38,18 +39,33 @@ def _name(text: str) -> str:
|
|||||||
return text.replace("<", "<")
|
return text.replace("<", "<")
|
||||||
|
|
||||||
|
|
||||||
|
def _tail(text: str) -> str:
|
||||||
|
"""Хвостовая «\\» экранировала бы закрывающую скобку ссылки: нечётная серия в конце удваивается (T6.5)."""
|
||||||
|
m = _TAIL.search(text)
|
||||||
|
return text + "\\" if m and len(m.group(0)) % 2 else text
|
||||||
|
|
||||||
|
|
||||||
def _label(text: str) -> str:
|
def _label(text: str) -> str:
|
||||||
"""Текст ссылки на страницу: уже экранирован markdownify, здесь только скобки и переводы строк."""
|
"""Текст ссылки на страницу: уже экранирован markdownify, здесь скобки, переводы строк и хвостовая «\\»."""
|
||||||
return _BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text))
|
return _tail(_BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text)))
|
||||||
|
|
||||||
|
|
||||||
|
def _href(url: str) -> str:
|
||||||
|
"""Пробел и круглые скобки ломают разбор `[текст](адрес)`: кодируются; прочее — как есть (T6.4)."""
|
||||||
|
return url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
|
||||||
|
|
||||||
|
|
||||||
class _Converter(MarkdownConverter):
|
class _Converter(MarkdownConverter):
|
||||||
def __init__(self, attachments, links, **options):
|
def __init__(self, attachments, links, **options):
|
||||||
super().__init__(**options)
|
super().__init__(**options)
|
||||||
self._by_ref = {a["ref"]: a for a in attachments}
|
self._by_ref, self._links = {}, {}
|
||||||
self._links = {l["ref"]: l["code"] for l in links}
|
for a in attachments: # при повторе ref действует первая запись (T6.3)
|
||||||
|
self._by_ref.setdefault(a["ref"], a)
|
||||||
|
for l in links:
|
||||||
|
self._links.setdefault(l["ref"], l["code"])
|
||||||
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
|
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
|
||||||
self._index = {} # url -> индекс в files
|
self._index = {} # url -> индекс в files
|
||||||
|
self._frags = [] # выданные ссылки вложений: по ним из текста <a> выносятся вложенные вложения
|
||||||
|
|
||||||
def escape(self, text, parent_tags):
|
def escape(self, text, parent_tags):
|
||||||
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
|
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
|
||||||
@ -61,35 +77,54 @@ class _Converter(MarkdownConverter):
|
|||||||
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
|
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
|
||||||
self._index[url] = len(self.files)
|
self._index[url] = len(self.files)
|
||||||
self.files.append({"url": url, "name": entry["name"]})
|
self.files.append({"url": url, "name": entry["name"]})
|
||||||
return "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
|
frag = "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
|
||||||
|
self._frags.append(frag)
|
||||||
|
return frag
|
||||||
|
|
||||||
|
def _split_marks(self, text: str) -> tuple[str, str]:
|
||||||
|
"""(ссылки вложений из текста подряд, остаток текста): ссылка внутри ссылки в Markdown невозможна (T6.2)."""
|
||||||
|
found = sorted((text.find(f), f) for f in self._frags if f in text)
|
||||||
|
for _, f in found:
|
||||||
|
text = text.replace(f, "", 1)
|
||||||
|
return "".join(f for _, f in found), text
|
||||||
|
|
||||||
def convert_img(self, el, text, parent_tags):
|
def convert_img(self, el, text, parent_tags):
|
||||||
src = el.get("src") or ""
|
src = el.get("src") or ""
|
||||||
if src.startswith(_STATIC):
|
if src.startswith(_STATIC):
|
||||||
return ""
|
return ""
|
||||||
parent = el.parent
|
|
||||||
if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref:
|
|
||||||
return "" # картинка внутри ссылки на вложение: ссылку выводит <a>
|
|
||||||
entry = self._by_ref.get(src)
|
entry = self._by_ref.get(src)
|
||||||
|
anchor = el.find_parent("a") # на любой глубине: <a><span><img></span></a> (T6.1)
|
||||||
|
outer = self._by_ref.get(anchor.get("href") or "") if anchor is not None else None
|
||||||
|
if outer is not None and (entry is None or entry["url"] == outer["url"]):
|
||||||
|
return "" # картинка того же вложения внутри ссылки на него: ссылку выводит <a>
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
return self._attachment(entry)
|
return self._attachment(entry)
|
||||||
if _unsafe(src):
|
if _unsafe(src):
|
||||||
return ""
|
return ""
|
||||||
|
el["src"] = _href(src)
|
||||||
return super().convert_img(el, text, parent_tags)
|
return super().convert_img(el, text, parent_tags)
|
||||||
|
|
||||||
def convert_a(self, el, text, parent_tags):
|
def convert_a(self, el, text, parent_tags):
|
||||||
href = el.get("href") or ""
|
href = el.get("href") or ""
|
||||||
if href.startswith(_STATIC):
|
if href.startswith(_STATIC):
|
||||||
return ""
|
return ""
|
||||||
|
marks, rest = self._split_marks(text)
|
||||||
entry = self._by_ref.get(href)
|
entry = self._by_ref.get(href)
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
return self._attachment(entry)
|
return marks + self._attachment(entry)
|
||||||
|
sep = " " if marks else ""
|
||||||
code = self._links.get(href)
|
code = self._links.get(href)
|
||||||
if code is not None:
|
if code is not None:
|
||||||
return "[%s](page:%s)" % (_label(text.strip()) or _name(code), code)
|
return marks + sep + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)
|
||||||
if _unsafe(href):
|
if _unsafe(href):
|
||||||
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
|
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
|
||||||
return super().convert_a(el, text, parent_tags)
|
el["href"] = _href(href)
|
||||||
|
# края текста возвращаются как были: markdownify сам выносит их за ссылку, иначе слова склеиваются (ревью I-1);
|
||||||
|
# адрес вместо пустого текста — только если из ссылки вынесено вложение (M-1)
|
||||||
|
core = rest.strip()
|
||||||
|
lead, trail = rest[:len(rest) - len(rest.lstrip())], rest[len(rest.rstrip()):]
|
||||||
|
label = lead + _tail(core) + trail if core else (_label(href) if marks else rest)
|
||||||
|
return marks + sep + super().convert_a(el, label, parent_tags)
|
||||||
|
|
||||||
|
|
||||||
def _strip_private(soup) -> None:
|
def _strip_private(soup) -> None:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
|
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
|
||||||
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@ -17,6 +18,12 @@ SYNC_WAIT = 300
|
|||||||
CHUNK = 20000
|
CHUNK = 20000
|
||||||
CANDIDATES = 10
|
CANDIDATES = 10
|
||||||
mcp = FastMCP("sdlc-wiki")
|
mcp = FastMCP("sdlc-wiki")
|
||||||
|
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def _line(v) -> str:
|
||||||
|
"""Значение из Eva внутри строки ответа: переводы строк — пробел, иначе они подделали бы строки шапки (T9.3)."""
|
||||||
|
return _NEWLINES.sub(" ", core.value(v))
|
||||||
|
|
||||||
|
|
||||||
def _project() -> dict:
|
def _project() -> dict:
|
||||||
@ -71,7 +78,7 @@ def tree(code: str = "", depth: int = 2) -> str:
|
|||||||
base = min(p["depth"] for p in pages)
|
base = min(p["depth"] for p in pages)
|
||||||
shown = [p for p in pages if p["depth"] - base <= depth]
|
shown = [p for p in pages if p["depth"] - base <= depth]
|
||||||
base = shown[0]["depth"]
|
base = shown[0]["depth"]
|
||||||
return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {core.value(p.get('title'))} ({core.value(p.get('size'))} симв.)"
|
return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {_line(p.get('title'))} ({core.value(p.get('size'))} симв.)"
|
||||||
for p in shown)
|
for p in shown)
|
||||||
|
|
||||||
|
|
||||||
@ -79,10 +86,10 @@ def _candidates(pages: list, title: str) -> str:
|
|||||||
low = title.casefold()
|
low = title.casefold()
|
||||||
found = [p for p in pages if low in (p.get("title") or "").casefold()]
|
found = [p for p in pages if low in (p.get("title") or "").casefold()]
|
||||||
if not found:
|
if not found:
|
||||||
return f"нет страницы «{title}»; дерево — tree, поиск по тексту — search"
|
return f"нет страницы «{_line(title)}»; дерево — tree, поиск по тексту — search"
|
||||||
lines = [f"{p['code']} {p.get('title')}" for p in found[:CANDIDATES]]
|
lines = [f"{p['code']} {_line(p.get('title'))}" for p in found[:CANDIDATES]]
|
||||||
more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else []
|
more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else []
|
||||||
return "под название «%s» подходит несколько страниц, повторите page с code:\n" % title + "\n".join(lines + more)
|
return "под название «%s» подходит несколько страниц, повторите page с code:\n" % _line(title) + "\n".join(lines + more)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@ -108,9 +115,9 @@ def page(code: str = "", title: str = "", offset: int = 0) -> str:
|
|||||||
d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT)
|
d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT)
|
||||||
text, offset = d.get("markdown") or "", max(int(offset), 0)
|
text, offset = d.get("markdown") or "", max(int(offset), 0)
|
||||||
atts = d.get("attachments") or []
|
atts = d.get("attachments") or []
|
||||||
head = [f"{d['code']} — {core.value(d.get('title'))}", f"путь: {core.value(d.get('path'))}",
|
head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}",
|
||||||
f"Eva: {core.value(d.get('url'))}", f"синхронизировано: {core.value(d.get('synced_at'))}"]
|
f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}"]
|
||||||
head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {a.get('name')} ({core.value(a.get('state'))})" for a in atts]
|
head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts]
|
||||||
if atts else ["вложений нет"])
|
if atts else ["вложений нет"])
|
||||||
if offset >= len(text) and (offset > 0 or not text):
|
if offset >= len(text) and (offset > 0 or not text):
|
||||||
body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста"
|
body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста"
|
||||||
@ -130,7 +137,7 @@ def search(query: str, limit: int = 10) -> str:
|
|||||||
items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"]
|
items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"]
|
||||||
if not items:
|
if not items:
|
||||||
return "ничего не найдено"
|
return "ничего не найдено"
|
||||||
return "\n".join(f"{r['code']} {core.value(r.get('title'))} — {core.value(r.get('path'))}\n {core.value(r.get('snippet'))}"
|
return "\n".join(f"{r['code']} {_line(r.get('title'))} — {_line(r.get('path'))}\n {_line(r.get('snippet'))}"
|
||||||
for r in items)
|
for r in items)
|
||||||
|
|
||||||
|
|
||||||
@ -159,18 +166,18 @@ async def attachment(id: int) -> str:
|
|||||||
if st.get("state") == "fetching":
|
if st.get("state") == "fetching":
|
||||||
return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится"
|
return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится"
|
||||||
if st.get("state") != "ready":
|
if st.get("state") != "ready":
|
||||||
return f"не удалось скачать: {core.value(st.get('error'))}"
|
return f"не удалось скачать: {_line(st.get('error'))}"
|
||||||
data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content")
|
data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content")
|
||||||
path = await asyncio.to_thread(_store, id, st.get("name"), data)
|
path = await asyncio.to_thread(_store, id, st.get("name"), data)
|
||||||
return f"{path}\n{core.value(st.get('name'))} ({len(data)} байт), страница {core.value(st.get('page_code'))}"
|
return f"{path}\n{_line(st.get('name'))} ({len(data)} байт), страница {_line(st.get('page_code'))}"
|
||||||
|
|
||||||
|
|
||||||
def _summary(client) -> str:
|
def _summary(client) -> str:
|
||||||
s = client.get("/wiki/status", project=core.PROJECT)
|
s = client.get("/wiki/status", project=core.PROJECT)
|
||||||
lines = [f"корень: {core.value(s.get('root'))} {core.value(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; "
|
lines = [f"корень: {core.value(s.get('root'))} {_line(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; "
|
||||||
f"последняя синхронизация: {core.value(s.get('synced_at'))}"]
|
f"последняя синхронизация: {core.value(s.get('synced_at'))}"]
|
||||||
if s.get("error"):
|
if s.get("error"):
|
||||||
lines.append(f"ошибка синхронизации: {s['error']}")
|
lines.append(f"ошибка синхронизации: {_line(s['error'])}")
|
||||||
b = s.get("batch")
|
b = s.get("batch")
|
||||||
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет")
|
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@ -203,7 +210,7 @@ async def sync(wait: bool = False) -> str:
|
|||||||
break
|
break
|
||||||
await asyncio.sleep(POLL_SECONDS)
|
await asyncio.sleep(POLL_SECONDS)
|
||||||
lines = [f"пакет #{batch_id}: {b['status']}"]
|
lines = [f"пакет #{batch_id}: {b['status']}"]
|
||||||
lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {c['error']}" if c.get("error") else "") for c in b.get("commands") or []]
|
lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {_line(c['error'])}" if c.get("error") else "") for c in b.get("commands") or []]
|
||||||
if b["status"] not in ("done", "cancelled"):
|
if b["status"] not in ("done", "cancelled"):
|
||||||
return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"])
|
return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"])
|
||||||
return "\n".join(lines + [await asyncio.to_thread(_summary, client)])
|
return "\n".join(lines + [await asyncio.to_thread(_summary, client)])
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import pytest
|
|||||||
|
|
||||||
from sdlc_hub.core import files
|
from sdlc_hub.core import files
|
||||||
from sdlc_hub.core.client import HubError
|
from sdlc_hub.core.client import HubError
|
||||||
from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html
|
from sdlc_hub.modules.wiki import attachments
|
||||||
|
from test_wiki_convert import FILE_REF, FILE_URL, IMG_REF, THUMB_REF, _html
|
||||||
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
|
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
|
||||||
|
|
||||||
ATTS = [{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}]
|
ATTS = [{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}]
|
||||||
@ -62,7 +63,7 @@ def test_status_tree_page(api):
|
|||||||
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
|
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
|
||||||
assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"]
|
assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"]
|
||||||
assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---")
|
assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---")
|
||||||
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None}]
|
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None, "image": False}]
|
||||||
assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404
|
assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404
|
||||||
|
|
||||||
|
|
||||||
@ -237,3 +238,43 @@ def test_sync_project_not_string_422(api, project):
|
|||||||
e = err(api.client.post, "/wiki/sync", {"project": project})
|
e = err(api.client.post, "/wiki/sync", {"project": project})
|
||||||
assert e.status == 422 and e.error == "bad_request" and e.detail == "project — строка"
|
assert e.status == 422 and e.error == "bad_request" and e.detail == "project — строка"
|
||||||
assert api.rows("SELECT id FROM batch") == before # пакета не создано
|
assert api.rows("SELECT id FROM batch") == before # пакета не создано
|
||||||
|
|
||||||
|
|
||||||
|
# пакет D (#135): T8.2 — вложение без файла, признак картинки
|
||||||
|
def _ready(api, aid, data=b"hello"):
|
||||||
|
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
|
||||||
|
f = upload(api, data)
|
||||||
|
api.report(fetch_cmd(api), "done", {"file": f["file"], "size": f["size"]})
|
||||||
|
return f["file"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_ready_file_missing_requeues(api):
|
||||||
|
aid = synced(api)
|
||||||
|
sha = _ready(api, aid)
|
||||||
|
(api.wiki_dir.parent / "files" / sha).unlink()
|
||||||
|
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):
|
||||||
|
aid = synced(api)
|
||||||
|
_ready(api, aid)
|
||||||
|
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):
|
||||||
|
html = f'<p><a href="{FILE_REF}">файл</a> <img src="{IMG_REF}"></p>'
|
||||||
|
synced(api, html, [{"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["name"], a["image"]) for a in atts] == [("attachment", 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), ("https://e/files/a/x.xlsx", False)])
|
||||||
|
def test_is_image(url, expected):
|
||||||
|
assert attachments.is_image(url) is expected
|
||||||
|
|||||||
@ -128,3 +128,32 @@ def test_validate_settings(key, bad, good):
|
|||||||
def test_wiki_agent_gets_own_keys(conn):
|
def test_wiki_agent_gets_own_keys(conn):
|
||||||
out = settings.for_agent(conn, "wiki")
|
out = settings.for_agent(conn, "wiki")
|
||||||
assert out == {"wiki_pacing": 0.2, "settings_version": 1}
|
assert out == {"wiki_pacing": 0.2, "settings_version": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# пакет D (#135): T8.3 — одна синхронизация на корень
|
||||||
|
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):
|
||||||
|
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 == first and _count(conn) == 1
|
||||||
|
|||||||
@ -274,3 +274,76 @@ def test_plain_at_sign_kept():
|
|||||||
def test_code_keeps_literal_text():
|
def test_code_keeps_literal_text():
|
||||||
md, _ = convert.to_markdown("<p><code><script> a@b</code></p>", [], [])
|
md, _ = convert.to_markdown("<p><code><script> a@b</code></p>", [], [])
|
||||||
assert "`<script> a@b`" in md
|
assert "`<script> a@b`" in md
|
||||||
|
|
||||||
|
|
||||||
|
# пакет D (#135): T6.1–T6.5
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_in_wrapper_keeps_link():
|
||||||
|
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [THUMB, FILE], [])
|
||||||
|
assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_attachment_inside_attachment_link():
|
||||||
|
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [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():
|
||||||
|
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [IMG], [LINK])
|
||||||
|
assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_and_text_inside_page_link():
|
||||||
|
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [IMG], [LINK])
|
||||||
|
assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_inside_external_link():
|
||||||
|
md, _ = convert.to_markdown(f'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [IMG], [])
|
||||||
|
assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_ref_first_wins():
|
||||||
|
md, atts = convert.to_markdown(f'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
|
||||||
|
assert atts == [{"url": IMG_URL, "name": "first"}]
|
||||||
|
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK, dict(LINK, code="DOC-9")])
|
||||||
|
assert "(page:DOC-1)" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_href_spaces_and_parens_encoded():
|
||||||
|
md, _ = convert.to_markdown('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
|
||||||
|
assert md == "[док](http://ex.com/a%20b%28c%29.html)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_href_already_encoded_untouched():
|
||||||
|
md, _ = convert.to_markdown('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
|
||||||
|
assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_backslash_in_page_link_text():
|
||||||
|
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [LINK])
|
||||||
|
assert md == "[путь C:\\\\](page:DOC-1)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_backslash_in_external_link_text():
|
||||||
|
md, _ = convert.to_markdown('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
|
||||||
|
assert md == "[путь C:\\\\](http://ex.com/)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_link_keeps_edge_spaces():
|
||||||
|
"""Финальное ревью I-1: пробелы на краях текста внешней ссылки выносятся наружу, слова не склеиваются."""
|
||||||
|
md, _ = convert.to_markdown('<p>слово<a href="http://ex.com/"> док </a>дальше</p>', [], [])
|
||||||
|
assert md == "слово [док](http://ex.com/) дальше\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_external_link_dropped():
|
||||||
|
"""Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом."""
|
||||||
|
md, _ = convert.to_markdown('<p>a<a href="#top"></a>b<a href="http://x"><img src="static/i.png"></a>c</p>', [], [])
|
||||||
|
assert md == "abc\n"
|
||||||
|
|||||||
@ -313,3 +313,36 @@ def test_root_set_quotes_label(hub):
|
|||||||
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
|
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
|
||||||
assert "#5" in wmcp.root_set("DOC-2")
|
assert "#5" in wmcp.root_set("DOC-2")
|
||||||
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"
|
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"
|
||||||
|
|
||||||
|
|
||||||
|
# пакет D (#135): T9.3 — значения из Eva одной строкой
|
||||||
|
def test_page_header_newlines_collapsed(hub):
|
||||||
|
hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\r\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}]}})
|
||||||
|
out = wmcp.page(code="DOC-3")
|
||||||
|
lines = out.splitlines()
|
||||||
|
assert lines[0] == "DOC-3 — Статусы путь: поддельный"
|
||||||
|
assert sum(line.startswith("путь:") for line in lines) == 1 and "путь: Корень/ Статусы" in lines
|
||||||
|
assert " #7 a b.png (none)" in lines and "строка1\nстрока2" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_tree_candidates_newlines_collapsed(hub):
|
||||||
|
hub({"/wiki/search": {"results": [{"code": "DOC-2", "title": "A\nB", "path": "P\nQ", "snippet": "x\n y"}]},
|
||||||
|
"/wiki/tree": {"pages": [{"code": "DOC-1", "title": "Корень", "depth": 0, "parent_code": None, "size": 1},
|
||||||
|
{"code": "DOC-2", "title": "T\nU", "depth": 1, "parent_code": "DOC-1", "size": 200},
|
||||||
|
{"code": "DOC-3", "title": "T\nV", "depth": 1, "parent_code": "DOC-1", "size": 5}]}})
|
||||||
|
assert wmcp.search("q").splitlines() == ["DOC-2 A B — P Q", " x y"]
|
||||||
|
assert " DOC-2 T U (200 симв.)" in wmcp.tree().splitlines()
|
||||||
|
cands = wmcp.page(title="T").splitlines()
|
||||||
|
assert cands[0].startswith("под название «T» подходит") and "DOC-2 T U" in cands and "DOC-3 T V" in cands
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_wait_error_and_root_title_one_line(hub):
|
||||||
|
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", "error": "Eva: 503\nпуть: x"}]}
|
||||||
|
hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": done,
|
||||||
|
"/wiki/status": {"root": "DOC-1", "root_title": "К\nстраниц: 999", "synced_at": "s", "pages": 3,
|
||||||
|
"error": "Eva: 503\nпуть: x", "batch": None}})
|
||||||
|
lines = asyncio.run(wmcp.sync(wait=True)).splitlines()
|
||||||
|
assert " #1 wiki_sync failed: Eva: 503 путь: x" in lines and "ошибка синхронизации: Eva: 503 путь: x" in lines
|
||||||
|
assert not any(line.startswith("путь:") or line.startswith("страниц:") for line in lines)
|
||||||
|
|||||||
@ -190,3 +190,34 @@ def test_create_project_batches(hub_server):
|
|||||||
c.post("/projects", {"label": "AWPCA", "name": "A"})
|
c.post("/projects", {"label": "AWPCA", "name": "A"})
|
||||||
patched = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"})
|
patched = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"})
|
||||||
assert patched["batches"] == [{"module": "wiki", "id": patched["batch_ids"][0]}]
|
assert patched["batches"] == [{"module": "wiki", "id": patched["batch_ids"][0]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_back_while_old_claimed(hub_server):
|
||||||
|
"""A → B → A, пока синхронизация A у агента: PATCH не 422, идущий пакет A остаётся (#135, T8.3)."""
|
||||||
|
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"})
|
||||||
|
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):
|
||||||
|
"""Повтор упавшей wiki_sync при идущей того же корня — 422 с номером идущего пакета (#135, T8.3)."""
|
||||||
|
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)
|
||||||
|
assert len(_syncs(hub_server["hub"])) == 2
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Link } from "react-router";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts";
|
import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts";
|
||||||
import { fmtStamp } from "../../ui/format.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 { useWikiDownload } from "./download.ts";
|
||||||
import { wikiUrlTransform } from "./urlTransform.ts";
|
import { wikiUrlTransform } from "./urlTransform.ts";
|
||||||
|
|
||||||
@ -26,23 +26,27 @@ const decode = (s: string) => { try { return decodeURIComponent(s); } catch { re
|
|||||||
// страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML)
|
// страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML)
|
||||||
export function PageView({ project, code, hrefOf }: { project: string; code: string; hrefOf(code: string): string }) {
|
export function PageView({ project, code, hrefOf }: { project: string; code: string; hrefOf(code: string): string }) {
|
||||||
const page = useWikiPage(project, code);
|
const page = useWikiPage(project, code);
|
||||||
const dl = useWikiDownload(project, code);
|
|
||||||
const atts = page.data?.attachments;
|
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 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>(() => ({
|
const components = useMemo<Components>(() => ({
|
||||||
...HEADINGS,
|
...HEADINGS,
|
||||||
a({ href, children }: ComponentProps<"a">) {
|
a({ href, children }: ComponentProps<"a">) {
|
||||||
if (!href) return <>{children}</>;
|
if (!href) return <>{children}</>;
|
||||||
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
|
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>;
|
if (/^https?:/i.test(href)) return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
|
||||||
return <a href={href}>{children}</a>;
|
return <a href={href}>{children}</a>;
|
||||||
},
|
},
|
||||||
// картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст
|
// картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст
|
||||||
img: ({ alt }: ComponentProps<"img">) => <span className="wiki-img">[изображение{alt ? `: ${alt}` : ""}]</span>,
|
img: ({ alt }: ComponentProps<"img">) => <span className="wiki-img">[изображение{alt ? `: ${alt}` : ""}]</span>,
|
||||||
table: ({ children }: ComponentProps<"table">) => <div className="wiki-table"><table>{children}</table></div>,
|
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.isPending) return <p className="wiki-hint">Загрузка страницы…</p>;
|
||||||
if (page.isError) return <Notice tone="error">{page.error.message}</Notice>;
|
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>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
|
// ссылка attachment:<id>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
|
||||||
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика
|
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика.
|
||||||
function AttachmentLink({ id, dl, failed, children }: {
|
// Картинка (att.image, #135): нажатие скачивает и показывает её в тексте; полученная — картинкой, под ней ссылка сохранения
|
||||||
id: string; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; children?: ReactNode;
|
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 st = dl.status(id);
|
||||||
const busy = st.busy, error = st.error ?? (busy ? null : failed.get(id) ?? null);
|
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 (
|
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>}
|
<IconPaperclip size={16} />{children}{busy && <span className="wiki-att__busy"> Загружается…</span>}
|
||||||
</button>
|
</button>
|
||||||
{error && <span className="wiki-att__error" role="alert"> {error}</span>}
|
{error && <span className="wiki-att__error" role="alert"> {error}</span>}
|
||||||
|
|||||||
@ -162,6 +162,28 @@ describe("страница «Вики»", () => {
|
|||||||
expect(await screen.findByText(/Eva: 404/)).toBeInTheDocument();
|
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 () => {
|
it("ссылка page: открывает страницу", async () => {
|
||||||
hub();
|
hub();
|
||||||
const { router } = open("/wiki?project=AWPCA&page=DOC-3");
|
const { router } = open("/wiki?project=AWPCA&page=DOC-3");
|
||||||
|
|||||||
@ -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;
|
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 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 {
|
export interface WikiPageData {
|
||||||
code: string; title: string | null; path: string; url: string | null; synced_at: string | null; markdown: string; attachments: WikiAttachment[];
|
code: string; title: string | null; path: string; url: string | null; synced_at: string | null; markdown: string; attachments: WikiAttachment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,4 +81,73 @@ describe("useWikiDownload", () => {
|
|||||||
expect(result.current.status("7")).toEqual({ busy: false, error: ATT_TIMEOUT_TEXT });
|
expect(result.current.status("7")).toEqual({ busy: false, error: ATT_TIMEOUT_TEXT });
|
||||||
expect(gets).toBe(ATT_MAX_POLLS);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { api, ApiError } from "../../api/client.ts";
|
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_POLL_MS = 2000;
|
||||||
export const ATT_MAX_POLLS = 60; // 60 × 2 с = 2 минуты: дольше агент, скорее всего, не ответит
|
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);
|
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);
|
const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e);
|
||||||
|
|
||||||
// скачивание вложений страницы вики: запрос (POST …/fetch), опрос состояния, затем содержимое; состояние — на вложение.
|
// скачивание вложений страницы вики: запрос (POST …/fetch), опрос состояния, затем содержимое; состояние — на вложение.
|
||||||
// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба
|
// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба.
|
||||||
export function useWikiDownload(project: string, scope: string) {
|
// Картинки (image у вложения, #135): mode "show" показывает файл в тексте вместо сохранения; картинки в ready страница
|
||||||
|
// получает сама при открытии — только содержимое, без нового скачивания агентом. objectURL живут до смены страницы
|
||||||
|
export function useWikiDownload(project: string, scope: string, attachments?: WikiAttachment[]) {
|
||||||
const [map, setMap] = useState<Record<string, DownloadStatus>>({});
|
const [map, setMap] = useState<Record<string, DownloadStatus>>({});
|
||||||
|
const [images, setImages] = useState<Record<string, string>>({});
|
||||||
const mapRef = useRef(map);
|
const mapRef = useRef(map);
|
||||||
|
const imagesRef = useRef(images);
|
||||||
|
const requested = useRef(new Set<string>());
|
||||||
const gen = useRef(0);
|
const gen = useRef(0);
|
||||||
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
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 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(() => {
|
useEffect(() => {
|
||||||
const pending = timers.current, counter = gen;
|
const pending = timers.current, counter = gen, asked = requested.current;
|
||||||
return () => {
|
return () => {
|
||||||
counter.current++;
|
counter.current++;
|
||||||
for (const t of pending) clearTimeout(t);
|
for (const t of pending) clearTimeout(t);
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
asked.clear();
|
||||||
|
for (const url of Object.values(imagesRef.current)) URL.revokeObjectURL(url);
|
||||||
|
imagesRef.current = {};
|
||||||
|
setImages({});
|
||||||
mapRef.current = {};
|
mapRef.current = {};
|
||||||
setMap({});
|
setMap({});
|
||||||
};
|
};
|
||||||
}, [project, scope]);
|
}, [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;
|
if (mapRef.current[id]?.busy) return;
|
||||||
const my = gen.current, alive = () => gen.current === my;
|
const my = gen.current, alive = () => gen.current === my;
|
||||||
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
|
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") {
|
if (st.state === "ready") {
|
||||||
const blob = await api.blob(`${base}/content`);
|
const blob = await api.blob(`${base}/content`);
|
||||||
if (!alive()) return;
|
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);
|
put(id, IDLE);
|
||||||
} else if (st.state === "fetching") {
|
} else if (st.state === "fetching") {
|
||||||
if (++polls > ATT_MAX_POLLS) return fail(ATT_TIMEOUT_TEXT);
|
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 });
|
put(id, { busy: true, error: null });
|
||||||
api.post<WikiAttachmentState>(`${base}/fetch`, { author: "web" }).then(handle).catch((e) => fail(errorText(e)));
|
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]);
|
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
||||||
// объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует вложения
|
const image = useCallback((id: string): string | null => images[id] ?? null, [images]);
|
||||||
return useMemo(() => ({ start, status }), [start, status]);
|
// объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует
|
||||||
|
// вложения (их состояние — здесь, в хуке, и перемонтирование его не сбрасывает)
|
||||||
|
return useMemo(() => ({ start, status, image }), [start, status, image]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,9 @@
|
|||||||
.wiki-att:hover:not(:disabled){text-decoration:underline}
|
.wiki-att:hover:not(:disabled){text-decoration:underline}
|
||||||
.wiki-att:disabled{cursor:default;color:var(--color-text-secondary)}
|
.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: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__busy{color:var(--color-text-secondary)}
|
||||||
.wiki-att__error{color:var(--color-status-error)}
|
.wiki-att__error{color:var(--color-status-error)}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user