@linto-ai/transcript-ui-core 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linto-ai/transcript-ui-core",
3
- "version": "0.9.3",
3
+ "version": "0.9.6",
4
4
  "description": "State layer and UI shell for @linto-ai/transcript-ui — core, stores, format adapters, and the TranscriptUI component",
5
5
  "keywords": [
6
6
  "vue",
@@ -17,11 +17,11 @@ const { t } = useI18n()
17
17
  <template>
18
18
  <DialogRoot v-model:open="open">
19
19
  <DialogPortal disabled>
20
- <DialogOverlay class="editor-overlay" />
21
- <DialogContent class="sidebar-drawer">
22
- <DialogTitle class="sr-only">{{ t("sidebar.speakers") }}</DialogTitle>
20
+ <DialogOverlay class="transcript-ui-overlay" />
21
+ <DialogContent class="transcript-ui-sidebar-drawer">
22
+ <DialogTitle class="transcript-ui-sr-only">{{ t("sidebar.speakers") }}</DialogTitle>
23
23
  <DialogClose
24
- class="sidebar-close"
24
+ class="transcript-ui-sidebar-close"
25
25
  :aria-label="t('header.closeSidebar')">
26
26
  <X :size="20" />
27
27
  </DialogClose>
@@ -45,7 +45,6 @@ function onSelect(value: string): void {
45
45
 
46
46
  <template>
47
47
  <Tabs
48
- v-if="core.llmServices"
49
48
  :tabs="tabs"
50
49
  :model-value="modelValue"
51
50
  @update:model-value="onSelect" />
@@ -45,7 +45,7 @@ defineExpose({ core })
45
45
  </script>
46
46
 
47
47
  <template>
48
- <div class="editor-root">
48
+ <div class="transcript-ui-root">
49
49
  <Layout
50
50
  v-if="core.channels.size"
51
51
  :show-header="!props.noHeader" />
@@ -61,7 +61,7 @@ defineExpose({ core })
61
61
  @import "@linto-ai/transcript-ui-ui/styles/popover-list.css";
62
62
 
63
63
  /* Positioning context for the absolute loading overlay. */
64
- .editor-root {
64
+ .transcript-ui-root {
65
65
  position: relative;
66
66
  height: 100%;
67
67
  }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { DocumentArticle, type DownloadFormat, DownloadMenu } from "@linto-ai/transcript-ui-ui"
2
+ import { DocumentArticle, DownloadMenu } from "@linto-ai/transcript-ui-ui"
3
3
  import { computed } from "vue"
4
4
  import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
5
  import { useCore } from "../core"
@@ -8,14 +8,6 @@ import * as utils from "../utils"
8
8
  const core = useCore()
9
9
  const { locale } = useI18n()
10
10
 
11
- const formats: DownloadFormat[] = [
12
- { format: "docx", labelKey: "format.docx" },
13
- { format: "pdf", labelKey: "format.pdf" },
14
- { format: "txt", labelKey: "format.txt" },
15
- { format: "json", labelKey: "format.json" },
16
- { format: "whisperx", labelKey: "format.whisperx" },
17
- ]
18
-
19
11
  const turns = computed(
20
12
  () => core.activeChannel.value?.activeTranslation.value.turns.value ?? [],
21
13
  )
@@ -45,17 +37,38 @@ function turnText(turn: {
45
37
  return turn.words.map((w) => w.text).join(" ")
46
38
  }
47
39
 
40
+ // "txt" needs no backend — it's just the turns joined into text — so it's
41
+ // the one format the core always fulfills itself. Anything else (docx, pdf,
42
+ // whisperx…) only exists once the host lists it in verbatimFormats, and is
43
+ // then always the host's to fulfill via "verbatim:export".
44
+ function exportAsText(): void {
45
+ const text = utils.buildVerbatimText(
46
+ title.value,
47
+ turns.value.map((turn) => ({
48
+ speakerName: speakerName(turn.speakerId),
49
+ time: turn.startTime != null ? utils.formatTime(turn.startTime) : null,
50
+ languageName: turn.language ? languageName(turn.language) : null,
51
+ text: turnText(turn),
52
+ })),
53
+ )
54
+ utils.downloadTextFile(`${title.value || "transcript"}.txt`, text)
55
+ }
56
+
48
57
  function onExport(format?: string): void {
49
58
  if (!format) return
59
+ if (format === "txt" && core.verbatimFormatsAreDefault) {
60
+ exportAsText()
61
+ return
62
+ }
50
63
  core.emit("verbatim:export", { format })
51
64
  }
52
65
  </script>
53
66
 
54
67
  <template>
55
68
  <section class="verbatim-panel">
56
- <DocumentArticle :formats="formats" @export="onExport">
69
+ <DocumentArticle>
57
70
  <template #toolbar-right>
58
- <DownloadMenu :formats="formats" @select="onExport" />
71
+ <DownloadMenu :formats="core.verbatimFormats" @select="onExport" />
59
72
  </template>
60
73
 
61
74
  <article class="verbatim-panel__content">
@@ -1,4 +1,5 @@
1
1
  import { ref, computed, shallowReactive, type Component } from "vue"
2
+ import type { DownloadFormat } from "@linto-ai/transcript-ui-ui"
2
3
  import type { Channel, EditorDocument } from "../types/editor"
3
4
  import type {
4
5
  Core,
@@ -17,6 +18,12 @@ import { ensureDocumentSpeakers } from "./helpers/ensureDocumentSpeakers"
17
18
  import { ensureSpeakersFromTurns } from "./helpers/ensureSpeakersFromTurns"
18
19
  import * as utils from "../utils"
19
20
 
21
+ // No backend can generate this — it's just the turns joined into text — so
22
+ // it's the one format the core always offers and fulfills itself.
23
+ const DEFAULT_VERBATIM_FORMATS: DownloadFormat[] = [
24
+ { format: "txt", labelKey: "format.txt" },
25
+ ]
26
+
20
27
  export function createCore(options: CoreOptions = {}): Core {
21
28
  // ── State ──────────────────────────────────────────────────────────
22
29
 
@@ -26,6 +33,9 @@ export function createCore(options: CoreOptions = {}): Core {
26
33
  const capabilities = ref<CoreCapabilities>(
27
34
  options.capabilities ?? { text: "edit", speakers: "edit" },
28
35
  )
36
+ const verbatimFormatsAreDefault = options.verbatimFormats == null
37
+ const verbatimFormats =
38
+ options.verbatimFormats ?? DEFAULT_VERBATIM_FORMATS
29
39
 
30
40
  // ── Event bus ──────────────────────────────────────────────────────
31
41
 
@@ -143,6 +153,8 @@ export function createCore(options: CoreOptions = {}): Core {
143
153
  date,
144
154
  activeChannelId,
145
155
  capabilities,
156
+ verbatimFormats,
157
+ verbatimFormatsAreDefault,
146
158
  speakers,
147
159
  channels,
148
160
  activeChannel,
package/src/core/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Component, ComputedRef, Ref, ShallowRef } from "vue"
2
+ import type { DownloadFormat } from "@linto-ai/transcript-ui-ui"
2
3
  import type {
3
4
  AudioSource,
4
5
  Channel,
@@ -149,6 +150,19 @@ export interface CoreOptions {
149
150
  document?: EditorDocument
150
151
  activeChannelId?: string
151
152
  capabilities?: CoreCapabilities
153
+ /**
154
+ * Formats offered in the verbatim panel's download menu. The host knows
155
+ * which export backends actually exist (docx/pdf generation, whisperx
156
+ * alignment…) — the core doesn't assume any of them, so it stays empty by
157
+ * default in the sense that the host owns the whole list once it supplies
158
+ * one: every format then goes through the "verbatim:export" event for the
159
+ * host to fulfill.
160
+ *
161
+ * Left unset, the core falls back to a single "txt" entry it fulfills
162
+ * itself — no backend needed, it just joins the turns into a plain-text
163
+ * file client-side.
164
+ */
165
+ verbatimFormats?: DownloadFormat[]
152
166
  }
153
167
 
154
168
  // ── Audio Plugin API ────────────────────────────────────────────────────
@@ -556,6 +570,11 @@ export interface Core {
556
570
  readonly date: Ref<string | number | null>
557
571
  readonly activeChannelId: Ref<string>
558
572
  readonly capabilities: Ref<CoreCapabilities>
573
+ /** Formats shown in the verbatim panel's download menu (see CoreOptions.verbatimFormats). */
574
+ readonly verbatimFormats: DownloadFormat[]
575
+ /** True when verbatimFormats is the built-in default — the verbatim panel
576
+ * then fulfills "txt" itself instead of emitting "verbatim:export". */
577
+ readonly verbatimFormatsAreDefault: boolean
559
578
 
560
579
  // ── Stores ───────────────────────────────────────────────────────────
561
580
  readonly speakers: SpeakersStore
@@ -0,0 +1,28 @@
1
+ /** One turn's data, already resolved to display strings — this function only
2
+ * formats/joins, it doesn't know about speakers, i18n, or date formatting. */
3
+ export interface VerbatimTextTurn {
4
+ speakerName: string
5
+ time: string | null
6
+ languageName: string | null
7
+ text: string
8
+ }
9
+
10
+ /** Plain-text rendering of a verbatim document: the title, then each turn as
11
+ * a "Speaker · time · language" header line followed by its text. */
12
+ export function buildVerbatimText(
13
+ title: string,
14
+ turns: VerbatimTextTurn[],
15
+ ): string {
16
+ const lines: string[] = []
17
+ if (title) lines.push(title, "")
18
+
19
+ for (const turn of turns) {
20
+ const meta = [turn.speakerName, turn.time, turn.languageName]
21
+ .filter(Boolean)
22
+ .join(" · ")
23
+ if (meta) lines.push(meta)
24
+ lines.push(turn.text, "")
25
+ }
26
+
27
+ return lines.join("\n").trimEnd() + "\n"
28
+ }
@@ -0,0 +1,13 @@
1
+ // Browser file-download helper (Blob + <a download>) for exports the core
2
+ // fulfills itself client-side — no backend involved.
3
+
4
+ export function downloadTextFile(filename: string, content: string): void {
5
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
6
+ const url = URL.createObjectURL(blob)
7
+ const link = document.createElement("a")
8
+ link.href = url
9
+ link.download = filename
10
+ link.click()
11
+ // Deferred: revoking synchronously can cancel the download in some browsers.
12
+ setTimeout(() => URL.revokeObjectURL(url), 0)
13
+ }
@@ -15,5 +15,8 @@ export { renderWaveform, normalizePeaks } from "./waveform"
15
15
  export { findActiveWord, hasWordTimestamps, firstWordStart, lastWordEnd } from "./words"
16
16
  export { speakText, stopTTS, unlockTTS, isTTSSupported, hasVoices } from "./tts"
17
17
  export { computeTurnPlainText } from "./computeTurnPlainText"
18
+ export { buildVerbatimText } from "./buildVerbatimText"
19
+ export type { VerbatimTextTurn } from "./buildVerbatimText"
20
+ export { downloadTextFile } from "./downloadTextFile"
18
21
  export { wordsFromText, wordsFromApi, carryWordTimes, layoutWords, parseWordId, wordId } from "./turnWords"
19
22
  export type { TimedText } from "./turnWords"
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { buildVerbatimText, type VerbatimTextTurn } from "../buildVerbatimText"
3
+
4
+ function makeTurn(partial: Partial<VerbatimTextTurn>): VerbatimTextTurn {
5
+ return {
6
+ speakerName: "",
7
+ time: null,
8
+ languageName: null,
9
+ text: "",
10
+ ...partial,
11
+ }
12
+ }
13
+
14
+ describe("buildVerbatimText", () => {
15
+ it("starts with the title followed by a blank line before the first turn", () => {
16
+ const result = buildVerbatimText("Réunion projet X", [
17
+ makeTurn({ text: "Bonjour" }),
18
+ ])
19
+ expect(result).toStartWith("Réunion projet X\n\nBonjour")
20
+ })
21
+
22
+ it("omits the title block when there is none", () => {
23
+ const result = buildVerbatimText("", [makeTurn({ text: "Bonjour" })])
24
+ expect(result).not.toStartWith("\n")
25
+ })
26
+
27
+ it("joins the present meta fields with a middle dot, skipping missing ones", () => {
28
+ const result = buildVerbatimText("", [
29
+ makeTurn({ speakerName: "Marie", time: "00:12", text: "Salut" }),
30
+ ])
31
+ expect(result).toContain("Marie · 00:12\nSalut")
32
+ })
33
+
34
+ it("drops the meta line entirely when nothing resolved", () => {
35
+ const result = buildVerbatimText("", [makeTurn({ text: "Texte seul" })])
36
+ expect(result).toBe("Texte seul\n")
37
+ })
38
+
39
+ it("renders one block per turn, in order", () => {
40
+ const result = buildVerbatimText("", [
41
+ makeTurn({ speakerName: "Marie", text: "Bonjour" }),
42
+ makeTurn({ speakerName: "Thomas", text: "Salut" }),
43
+ ])
44
+ expect(result).toBe("Marie\nBonjour\n\nThomas\nSalut\n")
45
+ })
46
+ })