From c89a63bd289c7ffdedebff6eb974a2cb583f6c95 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 16:37:09 +0200 Subject: [PATCH] feat(console): let a guild say where its protocols should go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guild has been able to publish to several destinations in several formats since the export-target API landed, and none of it was reachable from the interface: `document_target` on Bot Settings still looked like the only answer, when it is now the fallback for a guild that configures nothing else. Adds a Destinations page to the Admin View over the CRUD routes, a write-only credential control, and — on the recording page — the list of what one meeting was actually published as. The decisions live in two pure modules and are tested without mounting anything: which formats are offered and why `pdf` and `confluence` are absent rather than disabled, what each format will accept as an address, which destination Discord announces, what may be done to a credential, and how a partly-published meeting reads. --- console/app/components/ExportTargetForm.vue | 331 +++++++++ console/app/components/ExportTargetSecret.vue | 202 ++++++ console/app/pages/admin/destinations.vue | 636 ++++++++++++++++++ console/app/pages/recordings/[id].vue | 143 +++- console/app/utils/exportTargets.ts | 634 +++++++++++++++++ console/app/utils/navigation.ts | 11 + console/app/utils/sessionDocuments.ts | 256 +++++++ console/app/utils/settings.ts | 28 + console/i18n/README.md | 9 + console/i18n/locales/de.json | 112 ++- console/i18n/locales/en.json | 112 ++- console/test/exportTargets.spec.ts | 542 +++++++++++++++ console/test/i18n.spec.ts | 3 + console/test/navigation.spec.ts | 20 + console/test/sessionDocuments.spec.ts | 248 +++++++ console/test/settings.spec.ts | 16 + 16 files changed, 3288 insertions(+), 15 deletions(-) create mode 100644 console/app/components/ExportTargetForm.vue create mode 100644 console/app/components/ExportTargetSecret.vue create mode 100644 console/app/pages/admin/destinations.vue create mode 100644 console/app/utils/exportTargets.ts create mode 100644 console/app/utils/sessionDocuments.ts create mode 100644 console/test/exportTargets.spec.ts create mode 100644 console/test/sessionDocuments.spec.ts diff --git a/console/app/components/ExportTargetForm.vue b/console/app/components/ExportTargetForm.vue new file mode 100644 index 0000000..8d87145 --- /dev/null +++ b/console/app/components/ExportTargetForm.vue @@ -0,0 +1,331 @@ + + + diff --git a/console/app/components/ExportTargetSecret.vue b/console/app/components/ExportTargetSecret.vue new file mode 100644 index 0000000..47a079b --- /dev/null +++ b/console/app/components/ExportTargetSecret.vue @@ -0,0 +1,202 @@ + + + diff --git a/console/app/pages/admin/destinations.vue b/console/app/pages/admin/destinations.vue new file mode 100644 index 0000000..9401a2e --- /dev/null +++ b/console/app/pages/admin/destinations.vue @@ -0,0 +1,636 @@ + + + diff --git a/console/app/pages/recordings/[id].vue b/console/app/pages/recordings/[id].vue index 114ff12..dbb14b6 100644 --- a/console/app/pages/recordings/[id].vue +++ b/console/app/pages/recordings/[id].vue @@ -50,6 +50,12 @@ import { type RecordedSession, } from '~/utils/recordings' import { recordingTabQuery, recordingTabs } from '~/utils/recordingTabs' +import { + parseSessionDocuments, + publishedProtocols, + sessionDocumentsPath, + type SessionDocument, +} from '~/utils/sessionDocuments' import { transcriptAudioErased, transcriptPath, type SessionTranscript } from '~/utils/transcript' import type { SessionName } from '~/utils/sessionNaming' import { ApiError } from '~/utils/apiError' @@ -61,6 +67,17 @@ interface Recording { * The session is authoritative for the page's existence; a transcript * that failed is one tab's problem and not five. */ transcript: SessionTranscript | null + /** + * Every destination this meeting reached, or `null` when the listing + * could not be read. + * + * `null` and `[]` are deliberately different answers. An empty list is + * a real state — a meeting still being transcribed, a guild that has + * configured nowhere to publish — and drawing a failed read as one + * would tell somebody their meeting was published nowhere when in fact + * nobody asked. See `~/utils/sessionDocuments`. + */ + documents: SessionDocument[] | null } const { t } = useI18n() @@ -77,7 +94,7 @@ const { data, status, error, refresh } = await useAsyncData( // In parallel. They are two independent reads behind the same // authorisation, and doing them one after the other would double the // wait for no answer either of them needs from the other. - const [session, transcript] = await Promise.all([ + const [session, transcript, documents] = await Promise.all([ api(`/sessions/${encodeURIComponent(sessionId)}`), // Swallowed on purpose, and only here: a transcript that cannot be // read must not take down the audio, the tags or the metadata. What @@ -86,8 +103,14 @@ const { data, status, error, refresh } = await useAsyncData( // — "the words could not be read" covers both, and the session's own // 404 is what decides whether this recording is anybody's at all. api(transcriptPath(sessionId)).catch(() => null), + // Swallowed for the same reason, and `null` rather than `[]`: the + // difference between "nothing was published" and "where this went + // could not be read" is the whole point of this list existing. + api(sessionDocumentsPath(sessionId)) + .then(parseSessionDocuments) + .catch(() => null), ]) - return { session, transcript } + return { session, transcript, documents } }, { watch: [id] }, ) @@ -111,6 +134,23 @@ const session = computed(() => { return renamed.value === null ? found : { ...found, ...renamed.value } }) const transcript = computed(() => data.value?.transcript ?? null) + +/** The listing could not be read, as opposed to being empty. Only ever + * true once the page itself has loaded, so it does not flash during the + * first render. */ +const documentsFailed = computed(() => data.value !== null && data.value?.documents === null) + +/** + * What this meeting was published as, and how that squares with the one + * link the Discord announcement carries. + * + * `~/utils/sessionDocuments` decides all of it, including the state this + * section exists for: documents with no announced link means the + * destination Sturnus announces produced nothing while the others did. + */ +const protocols = computed(() => + publishedProtocols(session.value?.document_url, data.value?.documents ?? []), +) const length = computed(() => (session.value ? sessionLength(session.value) : null)) const naming = computed(() => (session.value ? recordingNaming(session.value) : null)) @@ -336,27 +376,112 @@ function onRenamed(name: SessionName) { +
-

{{ $t('recordings.aboutHeading') }}

+

{{ $t('recordings.protocolsHeading') }}

-
+ + + + +
+ +
+

{{ $t('recordings.aboutHeading') }}