From 62057606c00eb6e29964025503861a196df3c3ad Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 15:14:40 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E:=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F,=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80,=20413=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=D1=85,=20=D0=B8?= =?UTF-8?q?=D0=BC=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 I-2 M-1 M-2 M-3 M-5 M-6 M-7 T3.4 T4.3 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- src/jira_hub/api.py | 10 +++++-- src/jira_hub/attachments.py | 14 +++++++--- src/jira_hub/mcp_server.py | 11 +++++++- src/jira_hub/mirror.py | 4 +-- tests/test_api.py | 20 ++++++++++++++ tests/test_attachments.py | 13 ++++++++++ tests/test_mcp.py | 26 ++++++++++++++++++- tests/test_mirror.py | 3 ++- tests/test_queue.py | 19 ++++++++++++++ web/src/issues/attachments/format.test.ts | 4 +++ web/src/issues/attachments/format.ts | 7 +++-- .../useAttachmentDownload.test.tsx | 2 ++ .../attachments/useAttachmentDownload.ts | 6 ++++- web/src/issues/wiki/inline.test.ts | 5 ++++ 16 files changed, 132 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 65ff8dd..559f0bc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,7 +48,7 @@ - Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. - Создание проекта: `POST /projects` (веб или `project_create`) → `projects.create` и пакет «Новый проект <метка>» (`refresh` по общей JQL, `batch.project = NULL`) в одной транзакции → `claim` (хаб дописывает в `refresh` доски проектов, `boards`) → агент определяет тип доски → отчёт `refresh` пишет `board_type` → `ensure_sprint` проекта получает `board_id`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. -- Скачивание вложения: `queue.request_fetch` (одна транзакция: состояние `attachments.state` + пакет `fetch_attachment {key, attachment_id}`) → `claim` (без копии базы) → агент → `POST /agent/files` → результат `{file, size}` → `report_result` проверяет файл в `files/` и пишет `attachment_file`; нет файла или чужой размер — команда `failed`. +- Скачивание вложения: вход — `POST /attachments/{id}/fetch` (из веб-приложения и из MCP `attachment`); `queue.request_fetch` (одна транзакция: состояние `attachments.state` + пакет `fetch_attachment {key, attachment_id}`) → `claim` (без копии базы) → агент → `POST /agent/files` → результат `{file, size}` → `report_result` проверяет файл в `files/` и пишет `attachment_file`; нет файла или чужой размер — команда `failed`. Выход — `GET /attachments/{id}/content`: в вебе Blob и сохранение браузером, в MCP копия `downloads//<имя>`. - Журнал изменений: выдача `refresh` (`queue.deliver_params` дописывает `history` — `since` и/или стартовую `jql`) → агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона diff --git a/CLAUDE.md b/CLAUDE.md index 43a6223..126af69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/`, `files/` и `downloads/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 25b364d..3e5644f 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -344,6 +344,8 @@ def h_batches_post(hub, conn, m, q, body): try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir, project=body.get("project")) + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -421,8 +423,10 @@ def h_attachment_fetch(hub, conn, m, q, body): def h_attachment_content(hub, conn, m, q, body): """Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987).""" att = _attachment(hub, conn, m) - row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() - data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" and row else None + # state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state) + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \ + if att["state"] == "ready" else None + data = files.read(hub.files_dir, row["file"]) if row else None if data is None: raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание") files.touch(hub.files_dir, row["file"]) @@ -443,6 +447,8 @@ def h_command_retry(hub, conn, m, q, body): project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) diff --git a/src/jira_hub/attachments.py b/src/jira_hub/attachments.py index bfddd85..a9cd322 100644 --- a/src/jira_hub/attachments.py +++ b/src/jira_hub/attachments.py @@ -5,7 +5,9 @@ import re from . import files -_IMAGE = re.compile(r"!([^!|\n]+)(?:\|[^!\n]*)?!") +# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки; +# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя +_IMAGE = re.compile(r"(?=!(?!\s)([^!|\n]+)(?:\|[^!\n]*)?!)") _LINK = re.compile(r"\[(?:[^\]|\n]*\|)?\^([^\]\n]+)\]") @@ -14,9 +16,13 @@ def fmt_size(size: int | None) -> str: return "размер неизвестен" if size < 1024: return f"{size} Б" - if size < 1024 ** 2: - return f"{size / 1024:.1f} КБ".replace(".", ",") - return f"{size / 1024 ** 2:.1f} МБ".replace(".", ",") + # десятые — в целых, округление half up (как в вебе, fmtSize); единица — по округлённому значению: + # 1048575 Б — «1,0 МБ», а не «1024,0 КБ» + kb = (size * 10 + 512) // 1024 + if kb < 10240: + return f"{kb // 10},{kb % 10} КБ" + mb = (size * 10 + 524288) // 1048576 + return f"{mb // 10},{mb % 10} МБ" def ref_names(text: str | None) -> list[str]: diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 92c3bed..de4b6aa 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -158,8 +158,16 @@ def _files_line(c: dict, atts: list) -> str: def safe_name(filename: str, attachment_id: str) -> str: - """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; пусто, «.», «..» → attachment-.""" + """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-.""" name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "") + if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем + stem, dot, ext = name.rpartition(".") + if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя + stem, ext = name, "" + else: + ext = "." + ext + stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore") + name = stem + ext return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}" @@ -345,6 +353,7 @@ def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: d.chmod(0o700) target = folder / name if target.is_file() and target.stat().st_size == len(data): + os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет return target fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-") try: diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index c1631da..2ebcafe 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -40,8 +40,8 @@ def check_attachments(issues) -> None: where = f"{key}: attachments[{n}]" if not isinstance(a, dict): raise ValueError(f"{where} — объект вложения") - if not isinstance(a.get("id"), str) or not a["id"]: - raise ValueError(f"{where}.id — непустая строка") + if not isinstance(a.get("id"), str) or not a["id"].isdigit(): + raise ValueError(f"{where}.id — строка из цифр") if not isinstance(a.get("filename"), str) or not a["filename"]: raise ValueError(f"{where}.filename — непустая строка") size = a.get("size") diff --git a/tests/test_api.py b/tests/test_api.py index 59c234a..66e6497 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1153,6 +1153,26 @@ def test_attachment_fetch_too_large_413(hub): assert hub["client"].get("/batches")["batches"] == [] +def test_batches_post_fetch_too_large_413(hub): + """M-2: POST /batches с fetch_attachment > 10 МБ — 413 too_large, не 422.""" + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "31337"}}]}) + assert e.value.status == 413 and e.value.error == "too_large" + assert hub["client"].get("/batches")["batches"] == [] + + +def test_command_retry_fetch_too_large_413(hub): + """M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large.""" + _seed_attachment(hub) + st = hub["client"].post("/attachments/31337/fetch", {}) + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post(f"/commands/{st['command_id']}/retry", {"author": "claude"}) + assert e.value.status == 413 and e.value.error == "too_large" + + def test_attachment_fetch_agent_too_old_409(hub): _seed_attachment(hub) hub["agent"].post("/agent/claim", {"agent_version": 13}) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index bcd2bd8..e1be1ed 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -10,6 +10,10 @@ def test_fmt_size(): assert attachments.fmt_size(240000) == "234,4 КБ" assert attachments.fmt_size(25_000_000) == "23,8 МБ" assert attachments.fmt_size(None) == "размер неизвестен" + # M-1: десятые считаются в целых, округление half up; единица — по округлённому значению + assert attachments.fmt_size(1280) == "1,3 КБ" + assert attachments.fmt_size(1048575) == "1,0 МБ" + assert attachments.fmt_size(1310720) == "1,3 МБ" def test_ref_names(): @@ -20,6 +24,15 @@ def test_ref_names(): assert attachments.ref_names(None) == [] +def test_ref_names_lone_exclamation(): + """I-1: одиночный «!» перед настоящей ссылкой не съедает её открывающий «!».""" + assert "скрин.png" in attachments.ref_names("Внимание! см. !скрин.png!") + names = attachments.ref_names("Важно! [^b.xlsx] и !скрин.png!") + assert names.index("b.xlsx") < names.index("скрин.png") + assert attachments.ref_names("{code}!a.png!{code} !b.png!")[-1] == "b.png" + assert attachments.ref_names("!http://x/y.png! !c.png!")[-1] == "c.png" + + def test_resolve_newest(): items = [{"attachment_id": "10", "filename": "скрин.png", "created_at": "2026-10-14T10:00:00+00:00"}, {"attachment_id": "20", "filename": "скрин.png", "created_at": "2026-10-20T10:00:00+00:00"}] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 0c605b8..60fcb33 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -4,6 +4,7 @@ import json import os import re import threading +from pathlib import Path from types import SimpleNamespace import pytest @@ -728,6 +729,27 @@ def test_comment_attachments(): assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) +def test_safe_name_limits_255_bytes(): + """M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется.""" + for stem in ("я" * 200, "ы" * 127 + "a"): + name = mcp_server.safe_name(stem + ".xlsx", "7") + assert len(name.encode()) <= 255 and name.endswith(".xlsx") + name.encode("utf-8") + assert name.startswith("я") or name.startswith("ы") + assert len(mcp_server.safe_name("ф" * 300, "7").encode()) <= 255 + assert mcp_server.safe_name("короткое.png", "7") == "короткое.png" + + +def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): + name = "я" * 200 + ".png" + att = _att("31337", name, 3, state="ready") + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc") + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + path = Path(out.splitlines()[0]) + assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255 + + def test_safe_name(): assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" assert mcp_server.safe_name(" ", "7") == "attachment-7" @@ -812,5 +834,7 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch): path.parent.mkdir(parents=True) path.write_bytes(b"OLD" * 3) os.utime(path, (1_000_000, 1_000_000)) + os.utime(path.parent, (1_000_000, 1_000_000)) asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) - assert path.read_bytes() == b"OLD" * 3 and path.stat().st_mtime == 1_000_000 + assert path.read_bytes() == b"OLD" * 3 + assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 5074efe..fa74ae0 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -363,7 +363,8 @@ def test_attachment_moves_to_other_key(conn): @pytest.mark.parametrize("bad, field", [ ("x", "attachments"), (["x"], "attachments[0]"), - ([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"), + ([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"), + ([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"), ([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"), ([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"), ([{"id": "5", "filename": "a", "size": "1"}], "attachments[0].size"), diff --git a/tests/test_queue.py b/tests/test_queue.py index 906e6f4..2d663dd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1070,6 +1070,25 @@ def test_fetch_checks(fconn): assert queue.create_batch(fconn, "x", "claude", [fetch("31339")])["count"] == 1 # size NULL — ставится +def test_fetch_size_limit_boundary(fconn): + """T3.4: ровно MAX_SIZE принимается, MAX_SIZE+1 — TooLarge.""" + mirror.apply_state(fconn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "41", "filename": "eq.bin", "size": files.MAX_SIZE, "created": "2026-10-20T10:00:00.000+0300"}, + {"id": "42", "filename": "over.bin", "size": files.MAX_SIZE + 1, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + assert queue.request_fetch(fconn, None, "41", "claude")["state"] == "fetching" + with pytest.raises(queue.TooLarge): + queue.request_fetch(fconn, None, "42", "claude") + + +def test_fetch_report_done_non_dict_result_fails(fconn, tmp_path): + """T3.4: done с result не-словарём — failed, вложение не отмечено скачанным.""" + queue.create_batch(fconn, "x", "claude", [fetch()]) + c = queue.claim(fconn) + r = queue.report_result(fconn, c["id"], "done", result="ok", files_dir=tmp_path) + assert r["status"] == "failed" + assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + + def test_fetch_no_backup(fconn): queue.create_batch(fconn, "x", "claude", [fetch()]) calls = [] diff --git a/web/src/issues/attachments/format.test.ts b/web/src/issues/attachments/format.test.ts index d9e322a..a930153 100644 --- a/web/src/issues/attachments/format.test.ts +++ b/web/src/issues/attachments/format.test.ts @@ -10,6 +10,10 @@ describe("fmtSize", () => { expect(fmtSize(240000)).toBe("234,4 КБ"); expect(fmtSize(1024 ** 2)).toBe("1,0 МБ"); expect(fmtSize(25 * 1024 ** 2)).toBe("25,0 МБ"); + expect(fmtSize(1280)).toBe("1,3 КБ"); + expect(fmtSize(1048575)).toBe("1,0 МБ"); + expect(fmtSize(1310720)).toBe("1,3 МБ"); + expect(fmtSize(25_000_000)).toBe("23,8 МБ"); expect(fmtSize(null)).toBe("размер неизвестен"); }); }); diff --git a/web/src/issues/attachments/format.ts b/web/src/issues/attachments/format.ts index dc608e5..be71ef5 100644 --- a/web/src/issues/attachments/format.ts +++ b/web/src/issues/attachments/format.ts @@ -8,8 +8,11 @@ export const TOO_BIG_HINT = "больше 10 МБ — только в Jira"; export function fmtSize(size: number | null): string { if (size === null) return "размер неизвестен"; if (size < 1024) return `${size} Б`; - if (size < 1024 ** 2) return `${(size / 1024).toFixed(1).replace(".", ",")} КБ`; - return `${(size / 1024 ** 2).toFixed(1).replace(".", ",")} МБ`; + // десятые — в целых, округление half up (как в хабе); единица — по округлённому значению: 1048575 → «1,0 МБ» + const kb = Math.floor((size * 10 + 512) / 1024); + if (kb < 10240) return `${Math.floor(kb / 10)},${kb % 10} КБ`; + const mb = Math.floor((size * 10 + 524288) / 1048576); + return `${Math.floor(mb / 10)},${mb % 10} МБ`; } export const isTooBig = (size: number | null): boolean => size !== null && size > MAX_SIZE; diff --git a/web/src/issues/attachments/useAttachmentDownload.test.tsx b/web/src/issues/attachments/useAttachmentDownload.test.tsx index 99fa7db..68e9ee0 100644 --- a/web/src/issues/attachments/useAttachmentDownload.test.tsx +++ b/web/src/issues/attachments/useAttachmentDownload.test.tsx @@ -45,6 +45,8 @@ describe("useAttachmentDownload", () => { await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(polls).toBe(2); expect(clicked).toEqual([{ download: "скрин 1.png", href: "blob:fake" }]); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); expect(result.current.status("31337")).toEqual({ busy: false, error: null }); await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); diff --git a/web/src/issues/attachments/useAttachmentDownload.ts b/web/src/issues/attachments/useAttachmentDownload.ts index 5708551..0831d63 100644 --- a/web/src/issues/attachments/useAttachmentDownload.ts +++ b/web/src/issues/attachments/useAttachmentDownload.ts @@ -15,8 +15,12 @@ function save(blob: Blob, filename: string) { const a = document.createElement("a"); a.href = url; a.download = filename; + document.body.appendChild(a); // Firefox не кликает по ссылке вне документа a.click(); - URL.revokeObjectURL(url); + a.remove(); + // ссылку отзываем не сразу: браузер может начать чтение Blob уже после click(); таймер не в `timers` — + // размонтирование карточки не должно оставлять URL неотозванным + setTimeout(() => URL.revokeObjectURL(url), 60_000); } const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e); diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts index 53560c5..1073755 100644 --- a/web/src/issues/wiki/inline.test.ts +++ b/web/src/issues/wiki/inline.test.ts @@ -101,6 +101,11 @@ describe("parseInline: ссылки на вложения", () => { expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); expect(parseInline("[^нет.pdf] и [x|^нет.pdf]", A)).toEqual([t("[^нет.pdf] и [x|^нет.pdf]")]); }); + it("одиночный «!» перед ссылкой не мешает ей (I-1)", () => { + expect(parseInline("Внимание! см. !a.pdf!", A)).toContainEqual(att("31338", t("a.pdf"))); + const r = parseInline("Важно! [^a.pdf] и !a.pdf!", A); + expect(r.filter((n) => n.t === "attachment")).toEqual([att("31338", t("a.pdf")), att("31338", t("a.pdf"))]); + }); it("внешняя картинка не вложение", () => { expect(JSON.stringify(parseInline("!http://x/скрин 1.png!", { attachments: [{ id: "1", filename: "http://x/скрин 1.png", created: null }] }))).not.toContain("attachment"); });