Skip to content

feat(api): let the console ask which export formats this build can run - #152

Open
TheMeinerLP wants to merge 2 commits into
mainfrom
feat/api-export-formats
Open

feat(api): let the console ask which export formats this build can run#152
TheMeinerLP wants to merge 2 commits into
mainfrom
feat/api-export-formats

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Gives the API a way to say which export formats exist and which of them this build can actually run, and makes the destinations page ask instead of believe.

What it does

GET /api/export-formats answers with every format sturnus.application.export_formats knows of — outline, markdown, html, and also pdf and confluence, which are specified in §3.4 and deliberately not built. Each row carries three things: the name that guild_export_target.format stores, an available flag, and the sink family that carries the bytes (outline, object_store, or null where nothing has decided).

The console's destinations page reads it once per visit and hands it to the destination form. EXPORT_FORMATS — the hard-coded array #150 shipped, with its own copy of every format's name, note, target kind and readability — is gone. So is TargetKind, the console's private word for what the API already calls a sink family. What is left on the console side 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 the deployment reports and this console has no word for renders as its own raw name.

Two properties #150 argued for and tested are unchanged, and one of them is stronger now. A stored format the catalogue does not report keeps its row with its raw name and stays choosable while that destination is being edited; primaryTarget is still enabled-and-oldest with no filtering against any list. There is now a way for the reported list to arrive empty that did not exist before — the request failing — so a page that filtered stored values against it would rewrite every destination in a guild the first time the endpoint timed out.

Why it is shaped that way

The registry gained a second half rather than a second home. pdf and confluence were documented as unbuilt in the export_formats module docstring, restated as an absence in the console's format array, and restated a third time as an English sentence under the picker. Three copies of one fact, and building pdf would have falsified the second and third with nothing failing anywhere. They are now named once, in UNBUILT, beside FORMATS in the module that already promised adding a format would be one edit in one file. catalogue() reads both halves out; a test asserts a name cannot be in both.

Three fields on the wire, and each of the candidates for a fourth was rejected for a reason. media_type and file_extension are read by the object-store sink and by the route that serves an artefact back, both inside this process. A label is a word in a language this process has no catalogue for. And target_pattern is a compiled Python regular expression: handing one to a browser to re-compile hands over a dialect rather than a rule — Python and JavaScript disagree about \d under Unicode, about what a bare $ matches, about inline flags — so the sink family goes over instead and the console keeps one pattern per family. That copy was always a courtesy rather than a control, and _requested_target still refuses a bad target whatever the console believes.

The route lives in routes_exports and behind a session. It is the only one there that names no guild, and it is there because it reads the registry those five routes enforce: a caller cannot be told what a create will accept by any list except the one the create accepts from. A test asserts the catalogue's available set equals the supported list a 400 carries, so the two answers cannot drift into disagreement. Behind require_session for the reason routes_setup gives about the invite link — public is not the same as unauthenticated, and an endpoint of this API that answered without a session would be the only one. It answers somebody who administers no guild, unlike every other route in the file, because it is a fact about the binary and refusing it would only mean the console could not draw a format picker until it had first found a guild. No audit line: there is nothing an access record could establish about a compiled-in constant, and no guild id to scope one to.

An unavailable format is now a disabled row that says why, and this reverses #150. That PR chose absence, and its argument had two halves. The decisive half was that the console could not see the registry: a "PDF — not built" row would have been the console asserting a fact about a build it cannot inspect, and it would have gone on asserting it after pdf was built, until somebody remembered to edit a second list. Neither is true any more. The row's text is the deployment's own answer, and it stops being unavailable the moment the deployment says so, with nothing in the console to change.

The other half — a dropdown row that a save would refuse is "a trap laid under the cursor of somebody deciding" — is an argument against a row that looks choosable and is not. This one does not look choosable. UiOption.disabled is a first-class state in this console: stepEnabled walks past it, the control renders it unchoosable, and draftProblems refuses a draft naming an unavailable format so that Save is off and the reason sits under the field rather than arriving later as a bare 400 that apiError has stripped of its explanation. What is left is the fact that PDF exists and this deployment does not build it — which is what somebody who came to this page looking for PDF needs to be told, and what an absent row leaves them to conclude wrongly. "Sturnus has no PDF" and "this build has no PDF" are different things to walk away believing, and only the second is true.

The precedent that cuts the other way, video_consent_offered, does not reach this case. That is a guild's policy choice about its own server, and an absent option there is a correct statement about that guild. This is a property of the binary, which is not something an administrator can go and change — so telling them it exists and is not here is the only version of the sentence that helps.

One exception, and it is about the control rather than about honesty: where the unavailable format is the one the destination already stores, its row is left choosable. A disabled row that the picker's own value sits on is a control pointing at something it may not select, which is how a picker silently reports the wrong value.

The sink family is what decides the address field, so a format built tomorrow needs no console change. formatMarkdownNote and formatHtmlNote held the same sentence twice, which is what a per-name table decays into; they are one sinkObjectStoreNote now. Label, hint, note and whether the console can serve the bytes back all follow the family. A format with no reported family gets a neutral "Where it writes" label and a plain box — the old code fell back to the object-store wording, which stated a rule ("letters, digits, dot, dash…") that nothing had said applied.

What it deliberately refuses to do

  • It does not report what a target may look like. See the dialect argument above. The console keeps its own two patterns, per family, and they remain a courtesy: the API is the only thing that decides.
  • It does not fall back to a built-in list when the catalogue cannot be read. A guess is exactly what this page held for two releases with no way to correct it. The picker is empty, one sentence says the formats could not be read and that the configured destinations are unaffected, and a blank draft opens on no format at all rather than on a default a save might refuse. Everything that does not need the catalogue still works — switching a destination off, removing it, writing its credential.
  • It does not refuse a format merely because the catalogue never mentioned it. The new draft complaint fires only where the deployment has said available: false. "This console has not been told about it" is evidence of nothing, and an unreadable catalogue must not make every stored destination look like a broken configuration.
  • It does not make the recordings page fetch the catalogue. sessionDocuments asks for the word for a format and nothing else. That page renders what was already published — the document exists and its link works — so whether this build still produces that format is not a fact about the row, and a request per recording view would buy nothing.
  • It does not log who asked. Every other route in routes_exports reads or writes one guild's configuration; this one reads a constant identical for every administrator on the deployment.
  • It does not build pdf or confluence. The reasons in export_formats stand: a PDF renderer is a large native dependency in an image that today holds Python and a Whisper model, and Confluence is a second wiki API with its own auth and its own document model. This change only makes their absence something the deployment says rather than something the console assumes.

Checks

Measured on eed19d7 (v0.16.0) before and on this branch after.

pytest -q -m "not slow" 2742 → 2754 passed, 4 deselected (12 new) · vitest run 1719 → 1735 passed (16 new) · ruff check . clean · mypy src clean, 140 files · eslint . clean · nuxt typecheck clean · nuxt build clean.

`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.
…lt 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant