From e3e465c30fb7ae1f29c186b7414a15e736ed9061 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 18:24:19 +0200 Subject: [PATCH 1/2] feat(api): let a caller ask which export formats this build can run `GET /api/export-formats` answers with every format the registry knows of, each carrying its name, whether this build can actually run it, and the sink family that carries its bytes. Until now the only way to learn any of this was to configure a destination and read the `supported` list off the 400 -- which the console throws away, because `sanitiseFetchError` keeps nothing of a failed response but its status. `pdf` and `confluence` move from a paragraph in the module docstring into `UNBUILT`, beside `FORMATS`, and `catalogue()` reads both halves out. They were documented as unbuilt in three places that had no way to correct each other; building either would have falsified two of them with nothing failing. Moving a name between the two structures is now the single edit in the single file that `export_formats` always promised it would be. Three fields on the wire and no fourth. `media_type` is read inside this process, a label is a word in a language with no catalogue here, and a `target_pattern` is a Python regular expression -- handing one to a caller to re-compile hands over a dialect rather than a rule. --- src/sturnus/application/export_formats.py | 91 ++++++++++++++++++ src/sturnus/console/routes_exports.py | 74 ++++++++++++++- tests/application/test_export_formats.py | 67 ++++++++++++++ tests/console/test_export_routes.py | 107 ++++++++++++++++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) diff --git a/src/sturnus/application/export_formats.py b/src/sturnus/application/export_formats.py index 6fca77d..8f72724 100644 --- a/src/sturnus/application/export_formats.py +++ b/src/sturnus/application/export_formats.py @@ -38,6 +38,15 @@ the refusal, not accepted and then silently skipped after every meeting. When either is built it belongs behind this registry and needs no other change. + +Absent from `FORMATS`, but **named in `UNBUILT` and reported by +`catalogue`**. "Which formats exist" and "which of them this build can run" +are two questions, and until an interface could ask the second it had to +answer both by hard-coding a list of its own -- which is how the console +came to hold three separate restatements of this paragraph, each of which +building `pdf` would have quietly falsified. Moving a name from `UNBUILT` +into `FORMATS` is now the one edit, in this one file, that the paragraph +above always claimed it would be. """ from __future__ import annotations @@ -314,3 +323,85 @@ def supported_formats() -> tuple[str, ...]: than being left to guess. """ return tuple(FORMATS) + + +# --------------------------------------------------------------------------- +# The catalogue: what exists, and what this build can run +# --------------------------------------------------------------------------- + +#: The formats the specification names (spec §3.4) and this build does not +#: implement. Names rather than entries, because an entry is a renderer +#: paired with a sink and there is neither: writing `pdf` here would be +#: this module claiming a fact about bytes nothing can produce. +#: +#: **Kept beside `FORMATS` rather than in the console, in the API layer, or +#: in a sentence in a locale file**, and that is the whole reason this +#: constant exists. `pdf` and `confluence` were documented as unbuilt in +#: this module's docstring, restated as an absence in the console's own +#: format list, and restated a third time as English prose under the format +#: picker. Three copies of one fact, and building `pdf` would have made +#: liars of the second and third with nothing failing. Moving a name out of +#: here and into `FORMATS` is now the single edit the module docstring +#: always promised it would be, and every reader of `catalogue` follows. +UNBUILT: Final[tuple[str, ...]] = ( + #: A large native dependency in an image that today holds Python and a + #: Whisper model -- a decision about attack surface and image size. + "pdf", + #: A second wiki API, with its own auth, its own document model and its + #: own idea of what a page is. + "confluence", +) + + +@dataclass(frozen=True, slots=True) +class CatalogueEntry: + """One format as somebody outside this process may be told about it. + + Deliberately not `ExportFormat`. That record holds a callable, a media + type and a compiled pattern -- everything the *publisher* needs and + nothing a reader asking "what may I configure?" can use. This is the + other question, and it has three answers in it and no more: + + - `name`, which is what `guild_export_target.format` stores; + - `available`, whether this build can actually run it -- the fact no + caller outside this process has ever been able to obtain, and the + reason `catalogue` exists at all; + - `sink`, the family that carries the bytes, which is what decides + whether a `target` is an address in Outline or a key prefix in an + object store. It is `None` for an unbuilt format, and honestly so: + nothing here has decided what would carry a PDF, and naming a family + for one would be inventing the answer rather than reporting it. + + No media type and no file extension. They are read by the object-store + sink and by the route that serves an artefact back, both of which are + inside this process and both of which read `ExportFormat` directly. + """ + + name: str + available: bool + sink: str | None + + +def catalogue() -> tuple[CatalogueEntry, ...]: + """Every format this deployment knows of, buildable or not. + + `supported_formats` answers "what may be configured"; this answers + "what exists, and which of it works here", which is a strictly larger + question and the one an interface has to render. A console that can + only see the first has to guess at the difference between *not offered* + and *not built*, and the only way to guess is to hard-code a second + list that this module has no way to correct. + + Buildable entries first, in registry order, then the unbuilt ones. + Order is part of the answer: `FORMATS` is ordered so that `outline` -- + what every guild published to before any of the others existed -- comes + first, and a reader offered a list of choices reads the first one as + the ordinary one. + """ + return tuple( + [ + CatalogueEntry(name=entry.name, available=True, sink=entry.sink) + for entry in FORMATS.values() + ] + + [CatalogueEntry(name=name, available=False, sink=None) for name in UNBUILT] + ) diff --git a/src/sturnus/console/routes_exports.py b/src/sturnus/console/routes_exports.py index af917da..ebad6be 100644 --- a/src/sturnus/console/routes_exports.py +++ b/src/sturnus/console/routes_exports.py @@ -1,5 +1,6 @@ -"""Where a guild publishes: five routes, one authorisation rule, and no secret out. +"""Where a guild publishes: six routes, one authorisation rule, and no secret out. +- `GET /api/export-formats` - `GET /api/guilds/{guild_id}/export-targets` - `POST /api/guilds/{guild_id}/export-targets` - `PUT /api/guilds/{guild_id}/export-targets/{target_id}` @@ -36,6 +37,23 @@ in this file -- and one that is specified but not built (`pdf`, `confluence`) is refused where an administrator can read the refusal rather than accepted and silently skipped after every meeting. + +**`GET /api/export-formats` is that same answer said before the refusal +instead of after it.** It is the one route here that is not about a guild, +and it is here rather than in a module of its own because it reads the +registry these five routes enforce: a caller cannot be told what +`_requested_target` will accept by any list except the one it accepts from. +It carries the unbuilt names too, marked unavailable -- see +`export_formats.catalogue`. A reader that was told only the buildable +three would have to invent the difference between *not offered* and *not +built*, and inventing it means hard-coding a list this deployment has no +way to correct. + +**No audit line, and nothing per-caller in the answer.** Every other route +in this file either writes or reads a guild's own configuration; this one +reads a compiled-in constant that is identical for every administrator of +every guild on this deployment, so there is nothing an access record could +establish and no guild id to scope one to. """ from __future__ import annotations @@ -46,7 +64,12 @@ from aiohttp import web -from sturnus.application.export_formats import format_named, supported_formats +from sturnus.application.export_formats import ( + CatalogueEntry, + catalogue, + format_named, + supported_formats, +) from sturnus.console.ports import ExportTargets from sturnus.domain.exports import ExportTarget from sturnus.observability.events import Event, log_event @@ -57,6 +80,10 @@ #: `register`, matching `routes_settings.SETTINGS_STORE`. EXPORT_TARGETS: web.AppKey[ExportTargets] = web.AppKey("export_targets") +#: The one route here that names no guild. Every format on it is the same +#: for every guild on this deployment, which is why it is not under one. +_FORMATS_PATH = "/api/export-formats" + _PATH = "/api/guilds/{guild_id}/export-targets" _TARGET_PATH = "/api/guilds/{guild_id}/export-targets/{target_id}" _SECRET_PATH = "/api/guilds/{guild_id}/export-targets/{target_id}/secret" @@ -94,6 +121,12 @@ def register(app: web.Application) -> None: app.add_routes( [ + # Behind a session like everything else, though it holds no + # secret and names no guild. `routes_setup.register` makes the + # argument for the invite link and it is the same one: public + # is not the same as unauthenticated, and an endpoint of this + # API that answered without a session would be the only one. + web.get(_FORMATS_PATH, require_session(list_formats)), web.get(_PATH, require_session(list_targets)), web.post(_PATH, require_session(create_target)), web.put(_TARGET_PATH, require_session(update_target)), @@ -107,6 +140,25 @@ def register(app: web.Application) -> None: ) +def format_json(entry: CatalogueEntry) -> dict[str, Any]: + """One format, as a caller deciding what to configure may be told it. + + Three fields, and the case for stopping at three is that a fourth + would have to be invented here. `media_type` and `file_extension` are + on `ExportFormat` and are read by the sink that stores the bytes and + by the route that serves them back -- neither of them a caller. A + label is a word in a language this process has no catalogue for. And + `target_pattern` is a Python regular expression: handing one to a + caller to compile is handing over a dialect, not a rule, and the rule + is enforced here whatever the caller believes. + + `sink` is `null` for an unbuilt format rather than absent, so that "no + sink has been decided for this" and "this response forgot a field" are + different things on the wire. + """ + return {"name": entry.name, "available": entry.available, "sink": entry.sink} + + def target_json(target: ExportTarget) -> dict[str, Any]: """One destination, as the console sees it. @@ -139,6 +191,24 @@ def target_json(target: ExportTarget) -> dict[str, Any]: # --------------------------------------------------------------------------- +async def list_formats(_request: web.Request) -> web.Response: + """Every format this deployment knows of, and which of them it can run. + + The unbuilt ones are in the answer, marked `available: false`. Leaving + them out would make this endpoint say exactly what a 400's `supported` + list already said, and the reason that list was not enough is that a + reader who has to distinguish "not offered here" from "not built here" + can only do it by keeping a list of its own -- which is a second + registry, and a second registry is one that goes stale the day `pdf` + is built and nothing fails. + + A list rather than a mapping, because the order is part of the answer: + `outline` is what every guild published to before the others existed, + and JSON object order is a thing implementations are entitled to lose. + """ + return web.json_response({"formats": [format_json(entry) for entry in catalogue()]}) + + async def list_targets(request: web.Request) -> web.Response: """Every destination of this guild, enabled or not. diff --git a/tests/application/test_export_formats.py b/tests/application/test_export_formats.py index 7575230..5f50506 100644 --- a/tests/application/test_export_formats.py +++ b/tests/application/test_export_formats.py @@ -113,6 +113,73 @@ def test_every_entry_names_a_sink_family_and_a_media_type() -> None: assert entry.file_extension +# --------------------------------------------------------------------------- +# The catalogue: what exists, and what this build can run +# --------------------------------------------------------------------------- + + +def test_the_catalogue_names_everything_the_specification_names() -> None: + """Five formats, not three. `supported_formats` answers "what may be + configured"; the catalogue answers "what exists", which is the question + an interface has to render and the larger of the two.""" + assert {entry.name for entry in export_formats.catalogue()} == { + "outline", + "markdown", + "html", + "pdf", + "confluence", + } + + +def test_a_format_this_build_runs_is_available_and_names_its_sink() -> None: + available = {entry.name: entry for entry in export_formats.catalogue() if entry.available} + assert set(available) == set(export_formats.supported_formats()) + for name, entry in available.items(): + assert entry.sink == export_formats.FORMATS[name].sink + + +def test_an_unbuilt_format_is_reported_as_unavailable_with_no_sink() -> None: + """`sink` is `None` rather than a guess. + + A sink family is what decides whether a `target` is an address in + Outline or a key prefix in an object store, and nothing here has + decided what would carry a PDF. Naming one would be this module + inventing an answer and every reader downstream believing it. + """ + unbuilt = {entry.name: entry for entry in export_formats.catalogue() if not entry.available} + assert set(unbuilt) == set(export_formats.UNBUILT) + for entry in unbuilt.values(): + assert entry.sink is None + + +def test_no_name_is_both_buildable_and_unbuilt() -> None: + """The two halves of the catalogue are what a name is moved *between* + when a format is finally built. A name left in both would be reported + twice, and a console would render it as choosable and as refused at + once.""" + assert set(export_formats.UNBUILT).isdisjoint(export_formats.FORMATS) + + +def test_the_catalogue_offers_what_a_guild_already_publishes_to_first() -> None: + """Order is part of the answer: a reader offered a list reads the first + row as the ordinary one, and `outline` is what every guild published to + before any of the others existed.""" + assert [entry.name for entry in export_formats.catalogue()][:3] == list( + export_formats.supported_formats() + ) + + +def test_the_catalogue_carries_no_renderer_and_no_media_type() -> None: + """It is the answer to "what may I configure?", and a caller asking + that can use none of it. `media_type` and the renderer are read inside + this process, from `ExportFormat`, by the sink and by the route that + serves an artefact back.""" + entry = export_formats.catalogue()[0] + assert not hasattr(entry, "render") + assert not hasattr(entry, "media_type") + assert not hasattr(entry, "target_pattern") + + # --------------------------------------------------------------------------- # outline: today's behaviour, unchanged # --------------------------------------------------------------------------- diff --git a/tests/console/test_export_routes.py b/tests/console/test_export_routes.py index 773f8a3..9fcc040 100644 --- a/tests/console/test_export_routes.py +++ b/tests/console/test_export_routes.py @@ -426,6 +426,10 @@ async def test_every_route_refuses_a_request_with_no_session( ) -> None: client = await aiohttp_client(api()) assert (await client.get(f"/api/guilds/{GUILD}/export-targets")).status == 401 + # Including the format catalogue, which holds no secret and names no + # guild. Public is not the same as unauthenticated -- `routes_setup` + # makes the same argument for the invite link. + assert (await client.get("/api/export-formats")).status == 401 # --------------------------------------------------------------------------- @@ -433,6 +437,109 @@ async def test_every_route_refuses_a_request_with_no_session( # --------------------------------------------------------------------------- +async def test_the_format_catalogue_names_every_format_the_specification_names( + aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] +) -> None: + """Five, not the three that may be configured. + + The unbuilt ones are the whole reason this endpoint is not the 400's + `supported` list said earlier: a caller told only the buildable three + cannot tell "this deployment does not offer PDF" from "PDF is not a + thing", and the only way to tell is to keep a list of its own. + """ + client = await aiohttp_client(api()) + response = await client.get("/api/export-formats", cookies=cookies) + assert response.status == 200 + body = await response.json() + assert [entry["name"] for entry in body["formats"]] == [ + "outline", + "markdown", + "html", + "pdf", + "confluence", + ] + + +async def test_a_buildable_format_is_available_and_names_its_sink_family( + aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] +) -> None: + """The sink family is what tells a caller whether `target` is an + address in Outline or a key prefix in an object store -- the one thing + beyond the name that an interface needs in order to ask for the right + field.""" + client = await aiohttp_client(api()) + body = await (await client.get("/api/export-formats", cookies=cookies)).json() + by_name = {entry["name"]: entry for entry in body["formats"]} + assert by_name["outline"] == {"name": "outline", "available": True, "sink": "outline"} + assert by_name["markdown"] == { + "name": "markdown", + "available": True, + "sink": "object_store", + } + assert by_name["html"] == {"name": "html", "available": True, "sink": "object_store"} + + +async def test_an_unbuilt_format_is_reported_unavailable_with_a_null_sink( + aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] +) -> None: + """`null` rather than a missing key, so that "nothing has decided what + would carry a PDF" is distinguishable from a response that dropped a + field.""" + client = await aiohttp_client(api()) + body = await (await client.get("/api/export-formats", cookies=cookies)).json() + by_name = {entry["name"]: entry for entry in body["formats"]} + assert by_name["pdf"] == {"name": "pdf", "available": False, "sink": None} + assert by_name["confluence"] == {"name": "confluence", "available": False, "sink": None} + + +async def test_the_catalogue_agrees_with_what_a_refusal_says_is_supported( + aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] +) -> None: + """The two answers come from one registry, and this is what keeps them + from becoming two. Anything the catalogue calls available must be + something a create accepts -- otherwise the endpoint is offering a + choice its neighbour refuses, which is the exact failure it exists to + remove.""" + client = await aiohttp_client(api()) + body = await (await client.get("/api/export-formats", cookies=cookies)).json() + available = {entry["name"] for entry in body["formats"] if entry["available"]} + + refused = await client.post( + f"/api/guilds/{GUILD}/export-targets", + json=outline_body(format="pdf"), + cookies=cookies, + ) + assert set((await refused.json())["supported"]) == available + + +async def test_the_catalogue_carries_no_renderer_detail( + aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] +) -> None: + """Name, availability and sink family. A media type is read by the + store that holds the bytes and by the route that serves them back, and + a `target_pattern` is a Python regular expression -- a dialect rather + than a rule, and the rule is enforced here whatever a caller + believes.""" + client = await aiohttp_client(api()) + body = await (await client.get("/api/export-formats", cookies=cookies)).json() + for entry in body["formats"]: + assert set(entry) == {"name", "available", "sink"} + + +async def test_the_catalogue_does_not_depend_on_administering_a_guild( + aiohttp_client: AiohttpClientFactory, +) -> None: + """Ben administers nothing and every other route here answers him 404. + + This one answers him the same as anybody: it is a fact about the build, + identical for every guild, and refusing it would only mean the console + could not render a format picker until it had first found a guild. + """ + client = await aiohttp_client(api()) + response = await client.get("/api/export-formats", cookies={SESSION_COOKIE: signed_cookie(BEN)}) + assert response.status == 200 + + async def test_a_format_this_deployment_cannot_publish_is_refused( aiohttp_client: AiohttpClientFactory, cookies: dict[str, str] ) -> None: From 2f24be7c6990a9e5850fb4348b2bdc08128292bc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 18:24:32 +0200 Subject: [PATCH 2/2] feat(console): offer the export formats the deployment reports, unbuilt ones included The destinations page asks `GET /api/export-formats` and hands the answer to the destination form. `EXPORT_FORMATS` -- the hard-coded array shipped in #150, with its own copy of every format's name, note, target kind and readability -- is gone, and so is `TargetKind`, which was this console's private word for what the API already calls a sink family. What stays here is words: a name-to-translation-key map, because the API has no message catalogue and cannot serve "Outline-Dokument" to one reader and "Outline document" to another. A format this build cannot run is now a disabled row saying so, where #150 made it absent. That PR's decisive argument was that the console could not see the registry, so a "PDF, not built" row would have been a claim about a build it could not inspect -- one it would have gone on making after `pdf` was built. Neither holds now: the row is the deployment's own answer and stops being unavailable the moment the deployment says so. Its other argument, that such a row is a trap under the cursor, is an argument about a row that looks choosable; this one is stepped over by the keyboard, rendered unchoosable, and refused by `draftProblems` with the reason under the field. What is left is that PDF exists and this build has none, which is what somebody who came here looking for PDF needs told. The two properties #150 tested are unchanged. A stored format the catalogue does not report keeps its row with its raw name and stays choosable while that destination is edited, and `primaryTarget` filters against nothing -- which matters more now, not less: the reported list can arrive empty because the request failed, and a page that filtered against it would rewrite every destination in a guild the first time it did. --- console/app/components/ExportTargetForm.vue | 113 ++++- console/app/pages/admin/destinations.vue | 45 +- console/app/utils/exportTargets.ts | 463 ++++++++++++++------ console/app/utils/sessionDocuments.ts | 19 +- console/i18n/locales/de.json | 21 +- console/i18n/locales/en.json | 21 +- console/test/exportTargets.spec.ts | 382 ++++++++++++---- console/test/sessionDocuments.spec.ts | 19 +- 8 files changed, 814 insertions(+), 269 deletions(-) diff --git a/console/app/components/ExportTargetForm.vue b/console/app/components/ExportTargetForm.vue index 8d87145..59cc070 100644 --- a/console/app/components/ExportTargetForm.vue +++ b/console/app/components/ExportTargetForm.vue @@ -10,10 +10,21 @@ * * **It shows what this format needs, never the union of every field.** * Outline wants a collection and offers the picker Bot Settings already - * has for `document_target`; the two object-store formats want a key - * prefix, which has no directory to browse and is therefore typed. Which - * of the two a format wants is `~/utils/exportTargets`' answer, so adding - * a format is adding an entry there rather than a branch here. + * has for `document_target`; the object-store formats want a key prefix, + * which has no directory to browse and is therefore typed. Which of the + * two a format wants follows from the *sink family* the deployment + * reports for it, so a format built tomorrow gets the right field with + * nothing added here and nothing added in `~/utils/exportTargets` either. + * + * **Which formats exist is the deployment's answer, and it arrives as a + * prop.** `formats` is `GET /api/export-formats`, parsed. This component + * holds no list of its own and no belief about what is buildable, which is + * what lets it render an unavailable format as a disabled row saying so — + * see the argument at the top of `~/utils/exportTargets`, and #150 for the + * position it replaces. When the catalogue could not be read the picker is + * empty and says so; it never falls back to a list of guesses, because a + * guess is what this component existed for two releases without being able + * to correct. * * **There is no credential field, and that is the point.** A `PUT` on a * destination does not touch its secret — the API gives the credential a @@ -39,7 +50,9 @@ import UiSelect from '~/components/ui/UiSelect.vue' import { singleChoices, type NamedRow } from '~/utils/directory' import { type DraftField, + type FormatInfo, type TargetDraft, + SINK_OUTLINE, draftProblems, formatChoices, formatSpec, @@ -53,6 +66,12 @@ const props = withDefaults( initial: TargetDraft /** Names this guild already uses, this destination's own excluded. */ taken?: readonly string[] + /** What this deployment reports it knows of, buildable or not. */ + formats?: readonly FormatInfo[] + /** The catalogue could not be read. Unlike the collections below this + * is not decoration: the format is a required field with a closed set + * of legal values, and this form has no honest way to guess it. */ + formatsFailed?: boolean /** Outline's collections, for the one format that addresses one. */ collections?: readonly NamedRow[] /** The collections could not be read. Decoration failing, not the @@ -60,7 +79,14 @@ const props = withDefaults( collectionsFailed?: boolean busy?: boolean }>(), - { taken: () => [], collections: () => [], collectionsFailed: false, busy: false }, + { + taken: () => [], + formats: () => [], + formatsFailed: false, + collections: () => [], + collectionsFailed: false, + busy: false, + }, ) const emit = defineEmits<{ submit: [TargetDraft]; cancel: [] }>() @@ -94,8 +120,25 @@ watch( }, ) -const spec = computed(() => formatSpec(draft.value.format)) -const wantsCollection = computed(() => spec.value?.targetKind === 'collection') +const spec = computed(() => formatSpec(draft.value.format, props.formats)) +const wantsCollection = computed(() => spec.value?.sink === SINK_OUTLINE) + +/** + * What the address field is called, and the sentence under it. + * + * Neutral where the deployment has reported no sink family for this + * format — because it does not build it, because it has never heard of it, + * or because the catalogue could not be read. The previous version fell + * back to the object-store wording, which named a rule ("letters, digits, + * dot, dash…") that this console has no reason to believe applies. A field + * that states a constraint it invented is worse than one that states none: + * the API enforces the real rule either way, and only one of the two + * teaches the reader something false while they type. + */ +const targetLabelKey = computed( + () => spec.value?.targetLabelKey ?? 'admin.destinations.addressLabel', +) +const targetHintKey = computed(() => spec.value?.targetHintKey ?? 'admin.destinations.addressHint') /** Whether there is a list to pick from at all. An empty select would say * "this installation has no collections", which is a claim, and the wrong @@ -105,7 +148,16 @@ const pickerAvailable = computed( ) const picking = computed(() => pickerAvailable.value && !manual.value) -const formatOptions = computed(() => formatChoices(t, props.initial.format)) +/** Whether the deployment reported anything it cannot run. The sentence + * explaining the greyed rows is shown only where there are some. */ +const hasUnavailable = computed(() => props.formats.some((entry) => !entry.available)) + +// Keyed on `initial.format` rather than on the draft's current one: the +// row kept for a format the catalogue does not report is kept because this +// destination *stores* it, and a list that recomputed on every change would +// drop that row the moment somebody looked at another format and leave +// nothing to go back to. +const formatOptions = computed(() => formatChoices(t, props.formats, props.initial.format)) /** The collections, plus the stored id when the copy has no row for it. * Dropping it would render as though nothing were configured and rewrite @@ -118,7 +170,7 @@ const collectionOptions = computed(() => })), ) -const problems = computed(() => draftProblems(draft.value, props.taken)) +const problems = computed(() => draftProblems(draft.value, props.taken, props.formats)) const ready = computed(() => problems.value.length === 0) /** The complaint about one field, once it is the reader's to see. */ @@ -194,8 +246,10 @@ function submit() {

- +
{{ $t('admin.destinations.formatLabel') }}
@@ -210,14 +264,33 @@ function submit() {

{{ $t('admin.destinations.formatHint') }}

- -

+ +

{{ $t('admin.destinations.formatsNote') }}

+ +

+ {{ $t('admin.destinations.formatsUnavailable') }} +

{{ say(complaint('format')) }}

@@ -231,10 +304,10 @@ function submit() { class="block text-sm font-medium" :for="`${base}-target`" > - {{ $t(spec?.targetLabelKey ?? 'admin.destinations.prefixLabel') }} + {{ $t(targetLabelKey) }} - {{ $t(spec?.targetLabelKey ?? 'admin.destinations.collectionLabel') }} + {{ $t(targetLabelKey) }}
@@ -264,7 +337,7 @@ function submit() { >

- {{ $t(spec?.targetHintKey ?? 'admin.destinations.prefixHint') }} + {{ $t(targetHintKey) }}