@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 +1 -1
- package/src/components/SidebarDrawer.vue +4 -4
- package/src/components/TabBar.vue +0 -1
- package/src/components/TranscriptUI.vue +2 -2
- package/src/components/VerbatimPanel.vue +24 -11
- package/src/core/createCore.ts +12 -0
- package/src/core/types.ts +19 -0
- package/src/utils/buildVerbatimText.ts +28 -0
- package/src/utils/downloadTextFile.ts +13 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/test/buildVerbatimText.test.ts +46 -0
package/package.json
CHANGED
|
@@ -17,11 +17,11 @@ const { t } = useI18n()
|
|
|
17
17
|
<template>
|
|
18
18
|
<DialogRoot v-model:open="open">
|
|
19
19
|
<DialogPortal disabled>
|
|
20
|
-
<DialogOverlay class="
|
|
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,7 @@ defineExpose({ core })
|
|
|
45
45
|
</script>
|
|
46
46
|
|
|
47
47
|
<template>
|
|
48
|
-
<div class="
|
|
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
|
-
.
|
|
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,
|
|
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
|
|
69
|
+
<DocumentArticle>
|
|
57
70
|
<template #toolbar-right>
|
|
58
|
-
<DownloadMenu :formats="
|
|
71
|
+
<DownloadMenu :formats="core.verbatimFormats" @select="onExport" />
|
|
59
72
|
</template>
|
|
60
73
|
|
|
61
74
|
<article class="verbatim-panel__content">
|
package/src/core/createCore.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
})
|