diff --git a/src/jira_hub/modules/wiki/convert.py b/src/jira_hub/modules/wiki/convert.py new file mode 100644 index 0000000..ec67a59 --- /dev/null +++ b/src/jira_hub/modules/wiki/convert.py @@ -0,0 +1,64 @@ +"""Перевод HTML страницы Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10). + +Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь +находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает. +""" +import re + +from markdownify import MarkdownConverter + +_STATIC = ("static/", "/static/") +_ATTACH_ID = re.compile(r"attachment:@(\d+)") + + +class _Converter(MarkdownConverter): + def __init__(self, attachments, links, **options): + super().__init__(**options) + self._by_ref = {a["ref"]: a for a in attachments} + self._links = {l["ref"]: l["code"] for l in links} + self.files = [] # [{url, name}] без повторов по url, в порядке первого появления + self._index = {} # url -> индекс в files + + def _attachment(self, entry): + url = entry["url"] + if url in self._index: + return "" # миниатюра и файл одного вложения: вторая ссылка удаляется + self._index[url] = len(self.files) + self.files.append({"url": url, "name": entry["name"]}) + return "[📎 %s](attachment:@%d)" % (entry["name"], self._index[url]) + + def convert_img(self, el, text, parent_tags): + src = el.get("src") or "" + if src.startswith(_STATIC): + return "" + parent = el.parent + if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref: + return "" # картинка внутри ссылки на вложение: ссылку выводит + entry = self._by_ref.get(src) + if entry is not None: + return self._attachment(entry) + return super().convert_img(el, text, parent_tags) + + def convert_a(self, el, text, parent_tags): + href = el.get("href") or "" + if href.startswith(_STATIC): + return "" + entry = self._by_ref.get(href) + if entry is not None: + return self._attachment(entry) + code = self._links.get(href) + if code is not None: + return "[%s](page:%s)" % (text.strip() or code, code) + return super().convert_a(el, text, parent_tags) + + +def to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]: + """HTML страницы → (Markdown, вложения страницы [{url, name}]).""" + conv = _Converter(attachments, links, heading_style="ATX", autolinks=False) + md = conv.convert(html).strip() + "\n" + return md, conv.files + + +def fill_ids(markdown: str, ids: list[int]) -> str: + """`attachment:@` → `attachment:`: строки вложений после записи вложений страницы.""" + return _ATTACH_ID.sub(lambda m: "attachment:%d" % ids[int(m.group(1))], markdown) diff --git a/tests/fixtures/wiki/page_file.html b/tests/fixtures/wiki/page_file.html new file mode 100644 index 0000000..87cfc43 --- /dev/null +++ b/tests/fixtures/wiki/page_file.html @@ -0,0 +1,7 @@ + + + +

Модель

+

model.xlsx 12.03.2026

+ + diff --git a/tests/fixtures/wiki/page_image.html b/tests/fixtures/wiki/page_image.html new file mode 100644 index 0000000..7306f4a --- /dev/null +++ b/tests/fixtures/wiki/page_image.html @@ -0,0 +1,8 @@ + + + +

Схема данных

+

Общая схема модуля:

+

+ + diff --git a/tests/fixtures/wiki/page_links.html b/tests/fixtures/wiki/page_links.html new file mode 100644 index 0000000..66ceb98 --- /dev/null +++ b/tests/fixtures/wiki/page_links.html @@ -0,0 +1,10 @@ + + + +

Требования

+

См. Модель данных и внешний сайт.

+
ПолеТип
idint
+ +

Текст в span

+ + diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py new file mode 100644 index 0000000..8b61415 --- /dev/null +++ b/tests/test_wiki_convert.py @@ -0,0 +1,87 @@ +"""Перевод HTML Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10).""" +import re +from pathlib import Path + +from jira_hub.modules.wiki import convert + +FIX = Path(__file__).parent / "fixtures" / "wiki" +FILE_URL = "https://eva.example.test/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1" +IMG_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/image2026-7-27_14-20-6.png?version=1" +THUMB_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx.meta/thumbnail.jpg?version=1" +FILE_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1" + + +def _html(name): + return (FIX / name).read_text(encoding="utf-8") + + +def test_image_becomes_attachment(): + md, atts = convert.to_markdown( + _html("page_image.html"), + [{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}], + [], + ) + assert "[📎 image2026-7-27_14-20-6.png](attachment:@0)" in md.splitlines() + assert atts == [{"url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}] + + +def test_thumbnail_and_file_one_link(): + md, atts = convert.to_markdown( + _html("page_file.html"), + [ + {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, + {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}, + ], + [], + ) + assert [a["name"] for a in atts] == ["model.xlsx"] and len(atts) == 1 + assert md.count("](attachment:@0)") == 1 + assert "[📎 model.xlsx](attachment:@0)" in md + assert "attach-file" not in md + + +def test_page_link(): + md, _ = convert.to_markdown( + _html("page_links.html"), + [], + [{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}], + ) + assert "[Модель данных](page:DOC-019834)" in md + + +def test_external_link_kept(): + md, _ = convert.to_markdown(_html("page_links.html"), [], []) + assert "(https://example.org)" in md + + +def test_no_raw_html(): + for name in ("page_image.html", "page_file.html", "page_links.html"): + md, _ = convert.to_markdown( + _html(name), + [{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image.png"}, + {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, + {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}], + [{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}], + ) + assert re.search(r"<[a-zA-Z/]", md) is None, name + + +def test_table_kept(): + md, _ = convert.to_markdown(_html("page_links.html"), [], []) + table_lines = [line for line in md.splitlines() if "|" in line] + assert table_lines and any("---" in line for line in table_lines) + + +def test_headings_atx(): + md, _ = convert.to_markdown(_html("page_links.html"), [], []) + assert "# Требования" in md.splitlines() + + +def test_static_links_removed(): + html = '

Текст и ссылка

' + md, atts = convert.to_markdown(html, [], []) + assert "static" not in md and atts == [] + + +def test_fill_ids(): + assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"