diff --git a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx index a1bef61f..1ac79041 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx +++ b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx @@ -24,6 +24,7 @@ import { GitmostListPagesResult, GitmostListSpacesResult, gitmostDecodePayloadToFile, + gitmostInsertTranscriptIntoEditor, gitmostUploadFileToEditor, } from "@/features/editor/gitmost/gitmost-recording.ts"; @@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() { pageId: page.id, }; } + + // Best-effort: append the transcript (heading + one paragraph per line) + // below the just-inserted audio node. The audio insert already + // succeeded, so a transcript failure must NOT turn this into an error — + // wrap it and, on any throw, log and still return ok. A missing/empty/ + // non-string transcript is a no-op inside the helper (audio only). + try { + gitmostInsertTranscriptIntoEditor(editor, payload?.transcript); + } catch (err) { + console.error("[gitmost] transcript insert failed", err); + } + return { ok: true, pageId: page.id }; } catch (err: any) { console.error("[gitmost] createPageWithRecording failed", err); diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts new file mode 100644 index 00000000..29149822 --- /dev/null +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Heading } from "@tiptap/extension-heading"; +import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts"; + +/** + * #377 — the web-side bridge must append the native host's transcript below the + * recording. These exercise the pure insert helper through a REAL Tiptap editor + * (Document/Paragraph/Text/Heading), asserting the resulting document rather + * than mocking the editor: transcript present -> "Transcript" heading + one + * paragraph per non-empty line (verbatim); absent/empty/non-string -> no-op. + */ +describe("gitmostInsertTranscriptIntoEditor", () => { + const makeEditor = () => + new Editor({ + extensions: [Document, Paragraph, Text, Heading], + // Start from a single empty paragraph (a fresh page's baseline). The + // helper appends at the end of the doc, i.e. below existing content. + content: { type: "doc", content: [{ type: "paragraph" }] }, + }); + + it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => { + const editor = makeEditor(); + + const inserted = gitmostInsertTranscriptIntoEditor( + editor, + "You: hello there\nSpeaker 1: hi\n\nYou: bye", + ); + + expect(inserted).toBe(true); + + const nodes = (editor.getJSON().content ?? []) as any[]; + // A level-2 "Transcript" heading is present. + const heading = nodes.find((n) => n.type === "heading"); + expect(heading?.attrs?.level).toBe(2); + expect(heading?.content?.[0]?.text).toBe("Transcript"); + + // Every non-empty transcript line becomes a paragraph, in order, verbatim; + // the blank line between them is dropped. + const texts = nodes + .filter((n) => n.type === "paragraph") + .map((n) => n.content?.[0]?.text) + .filter((t) => typeof t === "string"); + expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]); + + editor.destroy(); + }); + + it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => { + for (const value of [undefined, "", " \n \n", 42, {}, null]) { + const editor = makeEditor(); + const before = JSON.stringify(editor.getJSON()); + + const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any); + + expect(inserted).toBe(false); + // Document is untouched (audio-only behavior preserved). + expect(JSON.stringify(editor.getJSON())).toBe(before); + editor.destroy(); + } + }); +}); diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.ts index c9acec5f..2b5017cb 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.ts @@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload { base64: string; filename: string; mimeType: string; + // Optional transcript for the recording: plain text, `\n`-separated, each + // line already formatted as `You: ...` / `Speaker N: ...` by the native host + // (ready to insert, no parsing needed). Omitted (no speech / no models) -> + // audio only. + transcript?: string; } export interface GitmostCreatePageResult { @@ -235,6 +240,45 @@ export async function gitmostUploadFileToEditor( } } +// Append a transcript block BELOW the recording's audio node in a live editor: +// a "Transcript" heading followed by one paragraph per non-empty transcript +// line. The transcript is plain text, `\n`-separated, each line already +// formatted as `You: ...` / `Speaker N: ...` by the native host — lines are +// inserted VERBATIM as text nodes (never HTML), so there is no injection +// surface. This is best-effort and meant to run AFTER the audio has already +// been inserted; the caller must guard against a throw so a transcript failure +// never fails the (already successful) recording. Returns true when a block was +// inserted, false when there was nothing to insert (transcript undefined/empty/ +// not-a-string). A non-string value is a no-op, not an error. +export function gitmostInsertTranscriptIntoEditor( + editor: Editor, + transcript: unknown, +): boolean { + if (typeof transcript !== "string") return false; + // Keep each line's text verbatim; only drop blank (whitespace-only) lines. + const lines = transcript.split("\n").filter((line) => line.trim().length > 0); + if (lines.length === 0) return false; + + const content = [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Transcript" }], + }, + ...lines.map((line) => ({ + type: "paragraph", + content: [{ type: "text", text: line }], + })), + ]; + + // Append at the end of the document. On a freshly-created recording page the + // audio node is the last block, so the end position places the transcript + // directly below it. + const endPos = editor.state.doc.content.size; + editor.chain().focus().insertContentAt(endPos, content).run(); + return true; +} + // Full insert path used by the open-page bridge (insertRecording): guard the // editor, validate/decode the payload, then upload. Never throws — resolves to // a result code.