Skip to content

Commit 789d42b

Browse files
authored
feat(tools): reference an existing file from a content element (#865)
Closes #834. The seventh purpose-built writer, and the first that creates a `sys_file_reference`. Demo content with images stopped at the upload: nothing could make an uploaded file part of the site, because the `files` group is read-only by design (ADR-047) and the only FAL write was `set_file_alternative_text` on a metadata row that already existed. ## A control caught my own design, and the design lost The tool wrote through the DataHandler **twice**, and I had a measurement to justify it: a probe showed a single-pass write leaving the element's counter one short and the new reference sorting *first*. Both issue comments say so. Then I disabled the second pass and **every test still passed**. The probe had run without `$GLOBALS['LANG']`. In that environment the defect is real; in a complete backend environment — the only one this tool will run in, because `refuseWithoutBackendEnvironment()` checks — a single datamap carrying the parent list and the `NEW_` placeholder produces the right count *and* the right `sorting_foreign`. One pass now, `substNEWwithIDs` only to identify the row for the read-back, and the docblock says what was measured rather than what I remembered. The issue comments are corrected rather than left standing. ## Three refusals the description did not name Each came from the live TCA, not from reasoning about it: | | | |---|---| | **The field must accept the file.** | `image` takes fourteen extensions, `assets` twenty-seven, `media` anything. A `.docx` on `image` is a relation the FormEngine would reject, so it is refused rather than written. | | **`sys_language_uid` must be in the payload.** | Without it a non-admin's write dies inside `checkRecordEditAccess()` on a missing `languageField`. Stated rather than defaulted, which also pins the tool to the default language like the other FAL writer. | | **`title`, `alternative`, `description` are `exclude` fields.** | The DataHandler drops them for a user without the grant — silently, empty `errorLog`. Without checking them the tool reports a caption it did not write. | That last one is also what made the read-back **testable**: remove the grant and the guard fires. ## Discarding is a rollback, not a delete When the read-back fails, removing the reference row is not enough — the element's counter still names it, which is exactly the inconsistent state this tool exists to avoid producing. `discard()` now writes the parent field back to the list that was there before the call. A test caught that too. ## Six controls, each observed | defect injected | result | |---|---| | read-back stops checking the texts | fails | | `discard()` stops restoring the counter | fails | | extension check removed | fails | | ambiguity branch removed | fails | | storage gate removed | fails | | page-permission check removed | fails | Restored tree: 13 tests green. An earlier run of this same harness reported two of these as *undetected*; both were failed `sed` anchors, so the test had run against an unmodified file. The harness now compares the checksum before and after and says "anchor missed" instead of "undetected" — a control that cannot fail is worse than none. ## One measured fact worth recording **No CType that ships with the core offers two of `image` / `assets` / `media`.** `textpic` and `image` show `image`, `textmedia` shows `assets`, nothing shows two. The "several fields → name the one you mean" branch is therefore unreachable with core content types, so the test registers a two-field type of its own rather than leaving the branch an untested claim. There is no `position` argument: the write appends by construction, and an argument nothing reads is worse than none. ## Verification | gate | result | |---|---| | `-s functional -d sqlite` (this class) | 13 tests, 86 assertions, exit 0 | | `-s functional` (tool surfaces) | 32 tests, 193 assertions, exit 0 | | `-s unit` (full) | 7289 tests, 24795 assertions, exit 0 | | `-s phpstan` | No errors | | `-s cgl -n` | SUCCESS | | `-s rector -n` (pinned 8.2, run locally) | Rector is done | | `composer ci:test:changelog` | exit 0 | Every counting surface moves with it: **47 tools → 48**, **six writers → seven**, across `Tools.rst`, `README.md`, seven landing-page data files in both languages, and the four tests that pin those numbers. _Assisted by claude-code:claude-opus-5 — [Session](https://claude.ai/code/session_01MNg1MysJVugv1xo2husknU)_
2 parents 0136719 + f0b47ad commit 789d42b

20 files changed

Lines changed: 1273 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010

11+
- **`attach_file_to_content_element` references an existing file from a content element** — the seventh purpose-built writer and the first that creates a `sys_file_reference`. It appends one existing `sys_file` to a content element's `image`, `assets` or `media` field through the DataHandler as the acting backend user, in the live workspace and the default language. It never uploads, moves or renames anything: the file must already lie in a permitted storage inside the acting user's file mounts, and the element must sit on a page that user may edit — either failure is refused in the same words as an element that does not exist. The file's extension must be one the chosen field accepts, and the field is only inferred when the element's type offers exactly one candidate. Ships disabled, in the `editing` group, and pauses for approval on every call like every other writer (#834).
12+
1113
- **`nrllm:mcp:import` imports an MCP catalogue from the CLI**, so a seeded server gets its tools on deploy instead of waiting for somebody to press **Import catalogue** in the module. Takes a server identifier, or `--all` for every enabled server; prints imported, skipped and orphaned counts per server plus the reason for each skipped tool, and exits non-zero when a server refused. It is the module's button rather than a second path — the same refusals, the same guard rails and the same reconciliation, so a second run against an unchanged catalogue writes nothing. With `--all` a refusing server does not stop the others, and an installation with no enabled server is not a failure (#836).
1214

1315
### Fixed

Classes/Service/Tool/Builtin/AttachFileToContentElementTool.php

Lines changed: 759 additions & 0 deletions
Large diffs are not rendered by default.

Configuration/Icons.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@
9696
'provider' => SvgIconProvider::class,
9797
'source' => 'EXT:nr_llm/Resources/Public/Icons/editor-action-file-alt-text.svg',
9898
],
99+
'nrllm-editor-action-attach-file' => [
100+
'provider' => SvgIconProvider::class,
101+
'source' => 'EXT:nr_llm/Resources/Public/Icons/editor-action-attach-file.svg',
102+
],
99103
'nrllm-editor-action-move-content' => [
100104
'provider' => SvgIconProvider::class,
101105
'source' => 'EXT:nr_llm/Resources/Public/Icons/editor-action-move-content.svg',

Documentation/Administration/Tools.rst

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ reference implementation of the security contract: model-chosen arguments are
4343
validated and scoped, volumes are capped, and secret-bearing output is either
4444
redacted or gated behind a separate ``_raw`` variant. Thirty-eight ship
4545
**enabled**; the three unredacted ``_raw`` variants (``get_env_raw``,
46-
``get_php_info_raw`` and ``list_be_users_raw``) and all six writing tools
46+
``get_php_info_raw`` and ``list_be_users_raw``) and all seven writing tools
4747
(``update_page_metadata``, ``set_file_alternative_text``,
4848
``move_content_element``, ``create_content_element_draft``,
49-
``create_page_draft``, ``create_translation_draft``) ship **disabled** and
49+
``create_page_draft``, ``create_translation_draft``,
50+
``attach_file_to_content_element``) ship **disabled** and
5051
must be enabled deliberately.
5152
Many require admin; the read-only structure, content
5253
and file tools (``get_pagetree``, ``get_tca``, ``get_full_tca``,
@@ -283,10 +284,11 @@ The remaining tools follow the same pattern:
283284
The writing tools
284285
=================
285286

286-
Six tools change anything at all: ``update_page_metadata``,
287+
Seven tools change anything at all: ``update_page_metadata``,
287288
``set_file_alternative_text``, ``move_content_element``,
288-
``create_content_element_draft``, ``create_page_draft`` and
289-
``create_translation_draft``. All six write through the TYPO3 DataHandler, as
289+
``create_content_element_draft``, ``create_page_draft``,
290+
``create_translation_draft`` and ``attach_file_to_content_element``. All seven
291+
write through the TYPO3 DataHandler, as
290292
the acting backend user, in the live workspace only, on exactly **one** record
291293
per call (:ref:`ADR-135 <adr-135>`, :ref:`ADR-146 <adr-146>`,
292294
:ref:`ADR-180 <adr-180>`).
@@ -344,6 +346,37 @@ What holds for all of them:
344346
An empty string is accepted and is the correct value for a decorative
345347
image.
346348

349+
``attach_file_to_content_element``
350+
References an **existing** managed file from one content element, appended
351+
to one of its file fields (``image``, ``assets`` or ``media``). It creates
352+
exactly one ``sys_file_reference``. It never uploads, moves, copies or
353+
renames a file, and never touches ``sys_file_metadata`` — the file has to be
354+
there already.
355+
356+
Authorised on both ends: the acting user needs content-edit rights on the
357+
element's page, and the file has to lie in a permitted storage inside that
358+
user's own file mounts. Either failure is refused in the same words as an
359+
element that does not exist.
360+
361+
Three things worth knowing before enabling it:
362+
363+
- **The field must accept the file.** Each file field declares which
364+
extensions it takes, and they differ — ``image`` accepts fourteen,
365+
``assets`` twenty-seven, ``media`` anything. A ``.docx`` on ``image`` is
366+
refused rather than written, because the FormEngine would reject the
367+
relation anyway.
368+
- **The field is only inferred when it is unambiguous.** When the element's
369+
type offers exactly one of the three, that one is used; when it offers
370+
several, the call must name the one it means. The tool does not pick.
371+
- **Calling twice attaches the file twice.** That is the declared effect
372+
(``NON_IDEMPOTENT_WRITE``), not an accident: two references to one image
373+
are a valid thing to want, so nothing deduplicates behind the caller's
374+
back.
375+
376+
The new reference is always appended last, and the element's own reference
377+
count is re-read afterwards. A write that left the relation inconsistent is
378+
reported as a failure and the reference is removed again.
379+
347380
``move_content_element``
348381
Moves one content element to a page and a column. The element keeps its uid,
349382
its content, its language, its history and its references — only its place

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ The **Admin Tools > LLM** backend module gives you full control:
152152
- **User Budgets** — Cap per-backend-user spending across every preset (requests / tokens / cost × daily / monthly). See the [Administration guide](Documentation/Administration/UserBudgets.rst).
153153
- **Analytics** — Estimated cost and usage trends with breakdowns per provider, model, and service, plus per-user consumption against monthly budgets. See the [Administration guide](Documentation/Administration/Analytics.rst).
154154
- **Skills** — Ingest `SKILL.md` instruction files from GitHub (SHA-pinned, admin-reviewed) and attach them to tasks and configurations. See the [Administration guide](Documentation/Administration/Skills.rst).
155-
- **Tools** — 41 built-in read-only function-calling tools plus six writing tools, in 9 toggleable groups — `content` (search, page content, record history: "who changed this?"), `structure` (TCA/FlexForm schema, URL resolution, TCA validation), `configuration` (TypoScript/TSconfig, syntax check, site config), `code` (last exception, source read/search), `files` (FAL browse/search/references), `system` (URL probe, diagnostics), `accounts` (BE users/groups), `rag` (cited site-content evidence from the installed search index — EXT:solr, ke_search, indexed_search or a database fallback) and `editing` (`update_page_metadata`: sets descriptive fields on one page through the DataHandler; `set_file_alternative_text`: sets the alternative text of one managed file; `move_content_element`: moves one element to another page or column; `create_content_element_draft`, `create_page_draft` and `create_translation_draft`: create a HIDDEN element, page or translation a human must unhide — all six ship disabled, and every call pauses for a human approval). See the [Administration guide](Documentation/Administration/Tools.rst).
155+
- **Tools** — 41 built-in read-only function-calling tools plus seven writing tools, in 9 toggleable groups — `content` (search, page content, record history: "who changed this?"), `structure` (TCA/FlexForm schema, URL resolution, TCA validation), `configuration` (TypoScript/TSconfig, syntax check, site config), `code` (last exception, source read/search), `files` (FAL browse/search/references), `system` (URL probe, diagnostics), `accounts` (BE users/groups), `rag` (cited site-content evidence from the installed search index — EXT:solr, ke_search, indexed_search or a database fallback) and `editing` (`update_page_metadata`: sets descriptive fields on one page through the DataHandler; `set_file_alternative_text`: sets the alternative text of one managed file; `move_content_element`: moves one element to another page or column; `create_content_element_draft`, `create_page_draft` and `create_translation_draft`: create a HIDDEN element, page or translation a human must unhide — all six ship disabled, and every call pauses for a human approval). See the [Administration guide](Documentation/Administration/Tools.rst).
156156

157157
![LLM Usage Analytics dashboard — KPI tiles, a cost and request trend chart, breakdowns by provider, model, and service, and a per-user table with monthly budget bars](Documentation/Images/backend-analytics.png)
158158

Resources/Private/Language/de.locallang.xlf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,6 +1796,14 @@
17961796
<source>Sets the alternative text of one file that already carries a metadata record.</source>
17971797
<target>Setzt den Alternativtext einer Datei, die bereits einen Metadatensatz hat.</target>
17981798
</trans-unit>
1799+
<trans-unit id="editorAction.attach_file_to_content_element.label">
1800+
<source>Attach a file</source>
1801+
<target>Datei anhängen</target>
1802+
</trans-unit>
1803+
<trans-unit id="editorAction.attach_file_to_content_element.description">
1804+
<source>References an existing file from this content element, appended to one of its file fields.</source>
1805+
<target>Verweist aus diesem Inhaltselement auf eine vorhandene Datei und hängt sie an eines seiner Dateifelder an.</target>
1806+
</trans-unit>
17991807
<trans-unit id="editorAction.move_content_element.label">
18001808
<source>Move content element</source>
18011809
<target>Inhaltselement verschieben</target>

Resources/Private/Language/locallang.xlf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,12 @@
13561356
<trans-unit id="editorAction.set_file_alternative_text.description">
13571357
<source>Sets the alternative text of one file that already carries a metadata record.</source>
13581358
</trans-unit>
1359+
<trans-unit id="editorAction.attach_file_to_content_element.label">
1360+
<source>Attach a file</source>
1361+
</trans-unit>
1362+
<trans-unit id="editorAction.attach_file_to_content_element.description">
1363+
<source>References an existing file from this content element, appended to one of its file fields.</source>
1364+
</trans-unit>
13591365
<trans-unit id="editorAction.move_content_element.label">
13601366
<source>Move content element</source>
13611367
</trans-unit>
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)