diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d23ac..5e354f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.6.5 — 2026-10-14 + +A patch release that lets a caller find the Document behind an image GLPI left +as a raw `data:` URI. GLPI may keep a large pasted image in the stored body as +`` instead of rewriting it into +a document link, while still creating its Document, whose tag equals the +image's `id`. Existing methods are unchanged. + +### Added + +- **`Link.image_id`**, a new last field of the `Link` the `rewrite_link` + callback receives: for an ``, the value of its `id` attribute (trimmed; + `None` when absent or blank); always `None` for a link. +- **`GlpiContentConverter.data_image_ids(html)`** returns, in document order + and without duplicates, the `id` of every `` whose `src` is a `data:` + URI, so a caller can tell whether it needs to read any Document at all. It + does not decode the base64, and leaves out an image inside `
`, ``,
+  `` or ``, which the reader never offers to `rewrite_link`.
+- **`GetDocument.tag`**, a read-only property: the Document's tag from
+  `extra_payload`, or `None` when absent or not a non-empty string. GLPI sets a
+  tag on the Documents it creates from an upload or an editor paste, images or
+  not.
+
 ## 0.6.4 — 2026-10-12
 
 A patch release that reads the documents GLPI links to a followup. GLPI's
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index f27c045..be755ca 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -2007,6 +2007,25 @@ other type raises ``TypeError``. It is not called for a value passed through
 as Markdown (``plain_text_is_markdown=True``), nor for a body read as its text.
 ``easyvista-python-client`` 0.4.3 carries the same hook.
 
+GLPI does not always rewrite a pasted image into a document link. A large one
+can stay in the stored body as ````
+while GLPI still creates its Document, whose tag is the image's ``id``. For
+such an image ``Link.image_id`` is that ``id`` and ``href`` is the data URI;
+``GlpiContentConverter.data_image_ids(html)`` lists those ids without
+converting, so a caller reads Documents only when a body needs it. A
+:class:`~glpi_python_client.models.GetDocument` exposes its tag as ``tag``:
+
+.. code-block:: python
+
+    ids = GlpiContentConverter.data_image_ids(body)
+    if ids:
+        by_tag = {d.tag: d for d in documents if d.tag}
+        matched = [by_tag[i] for i in ids if i in by_tag]
+
+The ``id`` is body text, and anyone who can write the body can forge it. Match
+it only against Documents the caller already trusts, such as the same
+ticket's own, never against every Document the instance holds.
+
 It is not a sanitiser
 ^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py
index 7b8a525..386ed73 100644
--- a/glpi_python_client/__init__.py
+++ b/glpi_python_client/__init__.py
@@ -133,7 +133,7 @@
     date_window,
 )
 
-__version__ = "0.6.4"
+__version__ = "0.6.5"
 
 __all__ = [
     "AsyncGlpiClient",
diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py
index 0a7f826..b45e9bd 100644
--- a/glpi_python_client/content/conversion.py
+++ b/glpi_python_client/content/conversion.py
@@ -179,6 +179,30 @@ def _plain_text_html(text: str) -> str:
     return "

" + "
".join(lines) + "

" +#: What a browser strips from the front of a URL, and drops from inside one. +_URL_LEADING = "".join(chr(c) for c in range(0x21)) +_URL_INNER = str.maketrans("", "", "\t\r\n") + +#: The elements inside which the reader writes no image, so offers none. +_UNFORMATTED = ("pre", "code", "kbd", "samp") + + +def _is_data_uri(src: str) -> bool: + """Whether a browser reads ``src`` as a ``data:`` URI. + + The scheme is case-insensitive, leading C0 controls and spaces are + stripped, and tabs and line breaks are dropped wherever they are. + """ + + return src.lstrip(_URL_LEADING).translate(_URL_INNER).casefold().startswith("data:") + + +def _image_id(el: Tag) -> str | None: + """The trimmed ``id`` of an element, or ``None`` when it has none.""" + + return str(el.get("id") or "").strip() or None + + def _soup(html: str) -> BeautifulSoup: """Parse ``html`` and drop what a browser does not display.""" @@ -402,6 +426,12 @@ class Link: enclosing_href : str or None For an ```` inside a link, that link's ``href``; ``None`` otherwise. The image is read before the link around it. + image_id : str or None + For an ````, the value of its ``id`` attribute, trimmed, or + ``None`` when it has none or the element is a link. GLPI sets it to + the tag of the Document it created for an image it has not rewritten + into a document link, and that Document carries the same value as its + tag, so a caller can find it. """ href: str @@ -410,6 +440,7 @@ class Link: image: bool = False document_id: int | None = None enclosing_href: str | None = None + image_id: str | None = None #: A ``rewrite_link`` callback: called with each link and image a read meets, @@ -664,6 +695,7 @@ def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str: enclosing_href=( None if around is None else str(around.get("href") or "") ), + image_id=_image_id(el), ) ) if isinstance(answer, str): @@ -1021,6 +1053,33 @@ def document_id_of(url: str) -> int | None: document_id = int(values[0]) return document_id if document_id > 0 else None + @staticmethod + def data_image_ids(html: str) -> tuple[str, ...]: + """Return the ``id`` of every image a body keeps as a ``data:`` URI. + + Each ```` whose ``src`` a browser reads as a ``data:`` URI and + that has a non-empty ``id`` contributes it, in document order and + without repeats. The base64 is not decoded. An image inside ``
``,
+        ````, ```` or ```` is left out: the reader shows it
+        as no image, so it never offers it to ``rewrite_link`` either.
+
+        GLPI sets a tag on the Documents it creates from an upload or an
+        editor paste, images or not. For an image it has not rewritten into a
+        document link, that tag is the ``id`` of the ````
+        (:attr:`Link.image_id`, :attr:`GetDocument.tag`). A caller calls this
+        first, to decide whether it needs to read any Document at all.
+        """
+
+        if "<" not in html:
+            return ()
+        ids = (
+            _image_id(img)
+            for img in _soup(html).find_all("img")
+            if _is_data_uri(str(img.get("src") or ""))
+            and img.find_parent(_UNFORMATTED) is None
+        )
+        return tuple(dict.fromkeys(i for i in ids if i is not None))
+
     @staticmethod
     def document_image(
         document_id: int,
diff --git a/glpi_python_client/content/tests/test_cost.py b/glpi_python_client/content/tests/test_cost.py
index 6c0841c..7f93f20 100644
--- a/glpi_python_client/content/tests/test_cost.py
+++ b/glpi_python_client/content/tests/test_cost.py
@@ -242,3 +242,20 @@ def test_a_body_too_deep_to_convert_keeps_its_text() -> None:
 
     assert "init" in markdown
     assert "au fond" in render(markdown)
+
+
+def test_a_large_data_uri_is_read_within_the_cost_bound() -> None:
+    """A pasted image GLPI keeps as a raw data URI runs to about a megabyte."""
+
+    html = (
+        '

' + ) + + started = time.perf_counter() + seen: list[object] = [] + GlpiContentConverter.from_transport(html, rewrite_link=seen.append) + + assert len(seen) == 1 + assert time.perf_counter() - started < 10 diff --git a/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py index 7750ea1..ca18a96 100644 --- a/glpi_python_client/content/tests/test_rewrite_link.py +++ b/glpi_python_client/content/tests/test_rewrite_link.py @@ -16,6 +16,7 @@ from glpi_python_client import GlpiValidationError from glpi_python_client.content import GlpiContentConverter, Link, RewriteLink +from glpi_python_client.content.conversion import _is_data_uri from glpi_python_client.content.tests.test_round_trip import ( E_MAIL_SHAPES, EARLIER_REGRESSIONS, @@ -26,6 +27,7 @@ render = GlpiContentConverter.to_transport document_image = GlpiContentConverter.document_image document_id_of = GlpiContentConverter.document_id_of +data_image_ids = GlpiContentConverter.data_image_ids #: What every rendered link carries: GLPI's editor writes it on a link it makes. NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"' @@ -39,6 +41,11 @@ ) +#: A pasted image GLPI left as a raw data URI (synthetic tag and bytes). +DATA_URI = "data:image/png;base64,iVBORw0KGgo=" +DATA_IMAGE = f'

' + + def offered(html: str) -> list[Link]: """Read ``html`` with a callback that keeps everything, and return what it saw.""" @@ -60,12 +67,67 @@ def keep(link: Link) -> None: "html", REALISTIC + E_MAIL_SHAPES - + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies], + + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies] + + [DATA_IMAGE], ) def test_a_callback_answering_none_changes_nothing(html: str) -> None: assert read(html, rewrite_link=lambda link: None) == read(html) +def test_a_pasted_data_image_is_offered_with_its_id() -> None: + assert offered(DATA_IMAGE) == [ + Link(href=DATA_URI, image=True, image_id="tag-1", document_id=None) + ] + + +def test_an_image_without_an_id_has_no_image_id() -> None: + (link,) = offered(f'

') + + assert link.image_id is None + + +@pytest.mark.parametrize("attribute", ['id=""', 'id=" "', "id"]) +def test_an_empty_or_blank_id_reads_as_none(attribute: str) -> None: + (link,) = offered(f'

') + + assert link.image_id is None + + +def test_an_image_inside_a_link_keeps_its_own_id_and_the_link_has_none() -> None: + html = ( + '

' + f'

' + ) + + image, anchor = offered(html) + + assert image.image_id == "tag-2" + assert anchor.image_id is None + + +def test_an_id_is_trimmed() -> None: + (link,) = offered(f'

') + + assert link.image_id == "tag-3" + + +def test_link_positional_construction_still_works() -> None: + link = Link("h", "t", "ti", True, 5, "https://x.example") + + assert (link.href, link.text, link.title, link.image) == ("h", "t", "ti", True) + assert (link.document_id, link.enclosing_href) == (5, "https://x.example") + assert link.image_id is None + + +def test_answering_with_a_link_rewrites_a_data_image() -> None: + markdown = read( + DATA_IMAGE, + rewrite_link=lambda link: Link("https://files.example/9", "alt", image=True), + ) + + assert markdown == "![alt](https://files.example/9)" + + def test_each_link_and_image_is_offered_once_an_image_before_its_link() -> None: html = ( '

' @@ -402,3 +464,122 @@ def rewrite(link: Link) -> None: def test_rewrite_link_is_a_public_type() -> None: callback: RewriteLink = lambda link: None # noqa: E731 assert read("

x

", rewrite_link=callback) == "x" + + +# --------------------------------------------------------------------------- +# Which images are raw data, and what they are called +# --------------------------------------------------------------------------- + + +def test_data_image_ids_lists_ids_in_document_order() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == ("b", "a", "c") + + +def test_data_image_ids_drops_duplicates() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == ("a", "b") + + +def test_data_image_ids_ignores_images_without_an_id() -> None: + html = ( + f'

' + f'

' + ) + + assert data_image_ids(html) == () + + +def test_data_image_ids_ignores_a_non_data_src() -> None: + html = ( + '

' + f"{PASTED.replace('

', '').replace('

', '')}" + '

' + ) + + assert data_image_ids(html) == () + + +def test_data_image_ids_sees_a_scheme_in_mixed_case_spaced_and_split() -> None: + html = '

' + + assert data_image_ids(html) == ("t",) + + +@pytest.mark.parametrize("lead", ["\x01", "\x0b", "\x1f", " \x0b\x01 "]) +def test_data_image_ids_strips_leading_control_characters(lead: str) -> None: + html = f'

' + + assert data_image_ids(html) == ("c",) + + +def test_data_image_ids_does_not_strip_a_leading_no_break_space() -> None: + html = '

' + + assert data_image_ids(html) == () + + +@pytest.mark.parametrize("wrapper", ["pre", "code", "kbd", "samp"]) +def test_data_image_ids_skips_an_image_the_reader_never_offers(wrapper: str) -> None: + html = f'<{wrapper}>' + + assert data_image_ids(html) == () + + +def test_data_image_ids_skips_an_image_nested_inside_code() -> None: + html = ( + f'

x' + f'

' + ) + + assert data_image_ids(html) == ("b",) + + +@pytest.mark.parametrize("html", ["", "plain text", "

no image

"]) +def test_data_image_ids_of_an_empty_body_is_empty(html: str) -> None: + assert data_image_ids(html) == () + + +@pytest.mark.parametrize( + "html", + [ + DATA_IMAGE, + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '

', + '
', + '

x

', + '

' + '

', + ], +) +def test_data_image_ids_agrees_with_the_ids_the_reader_offers(html: str) -> None: + from_the_reader = tuple( + dict.fromkeys( + link.image_id + for link in offered(html) + if link.image and link.image_id and _is_data_uri(link.href) + ) + ) + + assert data_image_ids(html) == from_the_reader diff --git a/glpi_python_client/models/api_schema/management/_document.py b/glpi_python_client/models/api_schema/management/_document.py index 83e090a..b176f94 100644 --- a/glpi_python_client/models/api_schema/management/_document.py +++ b/glpi_python_client/models/api_schema/management/_document.py @@ -61,6 +61,20 @@ class GetDocument(GlpiModel): mime: str | None = None sha1sum: str | None = None + @property + def tag(self) -> str | None: + """The tag GLPI gave the Document, or ``None``. + + GLPI sets a tag on the Documents it creates from an upload or an + editor paste, images or not. For an image it has not rewritten into + a document link, it is the ``id`` of the ```` left in the body + (see ``GlpiContentConverter.data_image_ids``). Read from + ``extra_payload``; ``None`` when absent, empty or not a string. + """ + + value = self.extra_payload.get("tag") + return value if isinstance(value, str) and value else None + class PostDocument(GlpiModel): """Request body for ``POST /Management/Document``. diff --git a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py index 1ef125d..dab11ad 100644 --- a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py +++ b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from glpi_python_client.models.api_schema.management import ( DeleteDocument, GetDocument, @@ -48,3 +50,18 @@ def test_delete_document_default() -> None: """``DeleteDocument`` exposes ``force`` as optional.""" assert DeleteDocument().force is None + + +def test_get_document_tag_reads_the_extra_payload() -> None: + doc = GetDocument.model_validate({"id": 1, "tag": "tag-1"}) + + assert doc.tag == "tag-1" + + +def test_get_document_tag_is_none_when_absent() -> None: + assert GetDocument.model_validate({"id": 1}).tag is None + + +@pytest.mark.parametrize("value", [None, "", 7, ["tag-1"]]) +def test_get_document_tag_is_none_when_not_a_non_empty_string(value: object) -> None: + assert GetDocument.model_validate({"id": 1, "tag": value}).tag is None diff --git a/pyproject.toml b/pyproject.toml index 802d8a2..a6fd02c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.4" +version = "0.6.5" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.11" diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md index 883a92d..188d137 100644 --- a/skills/glpi-asset-workflow/SKILL.md +++ b/skills/glpi-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 1de40c9..2d0ee23 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index 46ae603..704189b 100644 --- a/skills/glpi-contract-workflow/SKILL.md +++ b/skills/glpi-contract-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index d73761b..1ab3445 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 2c78d7e..3505808 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 56a1532..5fb9156 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 1eb2fe7..54719b0 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 9fb9513..3d0fdb7 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index f0aa959..0893a29 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 219bd52..ee7e0d0 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 0e7d941..7763019 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.6.4" + version: "0.6.5" --- # GLPI User, Location, And Entity Provisioning