feat(gitmost-bridge): вставка transcript в страницу записи (#377) #378
@@ -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);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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 { Bold } from "@tiptap/extension-bold";
|
||||
import { Italic } from "@tiptap/extension-italic";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||
|
||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
|
||||
/**
|
||||
* #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 + Bold/Italic/Link marks so an HTML-parsing
|
||||
* regression would be caught), asserting the resulting document rather than
|
||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||
* paragraphs; absent/empty/non-string -> no-op.
|
||||
*/
|
||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
const makeEditor = () =>
|
||||
new Editor({
|
||||
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||
// text assertions below would fail.
|
||||
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||
// 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("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||
const editor = makeEditor();
|
||||
const line =
|
||||
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||
expect(inserted).toBe(true);
|
||||
|
||||
const paras = (editor.getJSON().content ?? []).filter(
|
||||
(n: any) => n.type === "paragraph",
|
||||
) as any[];
|
||||
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||
const content = paras[paras.length - 1].content;
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0].type).toBe("text");
|
||||
expect(content[0].marks ?? []).toEqual([]);
|
||||
expect(content[0].text).toBe(line);
|
||||
// And no bold/italic/link mark exists anywhere in the document.
|
||||
const html = editor.getHTML();
|
||||
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||
// The angle brackets survived as escaped entities (literal text), not a live
|
||||
// <script>/<b> element.
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||
const editor = makeEditor();
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||
editor,
|
||||
[
|
||||
"- dash",
|
||||
" > quote", // leading indent must be trimmed then neutralized
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(inserted).toBe(true);
|
||||
|
||||
const texts = (editor.getJSON().content ?? [])
|
||||
.filter((n: any) => n.type === "paragraph")
|
||||
.map((n: any) => n.content?.[0]?.text)
|
||||
.filter((t: any) => typeof t === "string") as string[];
|
||||
|
||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||
// trimmed first); the normal `You:` line is left byte-exact.
|
||||
expect(texts).toEqual([
|
||||
ZWSP + "- dash",
|
||||
ZWSP + "> quote",
|
||||
ZWSP + "# hash",
|
||||
ZWSP + "1. one",
|
||||
ZWSP + "> [!info] note",
|
||||
ZWSP + "```js",
|
||||
ZWSP + "---",
|
||||
ZWSP + "***",
|
||||
ZWSP + "___",
|
||||
"You: normal line",
|
||||
]);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,83 @@ export async function gitmostUploadFileToEditor(
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||
const GITMOST_ZWSP = "";
|
||||
|
||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||
// and never matches, so prefixed lines are left byte-exact.
|
||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||
|
||||
// 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 — line text is
|
||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||
// serializer's missing block-escape). 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;
|
||||
const lines = transcript
|
||||
.split("\n")
|
||||
// Trim each line and drop blank (whitespace-only) ones.
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||
// `Speaker N:`) never match and stay byte-exact.
|
||||
.map((line) =>
|
||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||
);
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
||||
// collaboration.ts and mutates global DOM at import time).
|
||||
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
||||
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
||||
|
||||
/**
|
||||
* gitmost #377 (round-1 review, finding #1) — proof, against the REAL
|
||||
* converter, that the transcript-insert boundary defense survives git-sync.
|
||||
*
|
||||
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
|
||||
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
|
||||
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
|
||||
* that text VERBATIM with no block-escape, so a line whose text begins with a
|
||||
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
|
||||
* cycle, silently re-parse into a heading / list / quote / callout / code block.
|
||||
* That missing block-escape is the pre-existing root cause; the bridge's
|
||||
* boundary defense prepends an invisible zero-width space (U+200B) to a line
|
||||
* that begins with such a trigger, shifting it off column 0.
|
||||
*
|
||||
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
|
||||
* different package and can't be imported here) and asserts:
|
||||
* 1. bare trigger lines DO corrupt (documents the root cause), and
|
||||
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
|
||||
* text byte-preserved.
|
||||
*/
|
||||
|
||||
const ZWSP = ""; // U+200B
|
||||
|
||||
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
|
||||
const MD_BLOCK_TRIGGER_RE =
|
||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||
|
||||
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
|
||||
const para = (t: string) => ({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: t }],
|
||||
});
|
||||
|
||||
const roundtrip = async (text: string) => {
|
||||
const md = convertProseMirrorToMarkdown(doc(para(text)));
|
||||
const back = await markdownToProseMirror(md);
|
||||
return back.content as any[];
|
||||
};
|
||||
|
||||
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
|
||||
// Lines that, at column 0, the serializer's missing block-escape would let
|
||||
// git-sync re-parse into a non-paragraph block.
|
||||
const triggerLines = [
|
||||
"- dash",
|
||||
"* star",
|
||||
"+ plus",
|
||||
"> quote",
|
||||
"# hash",
|
||||
"1. one",
|
||||
"1) one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"~~~",
|
||||
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
|
||||
// which carries NO text, so a bare separator line LOSES its text entirely
|
||||
// (round-2 finding). `_` also only forms a block via this construct.
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
|
||||
"_ _ _",
|
||||
];
|
||||
|
||||
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
|
||||
for (const line of triggerLines) {
|
||||
const blocks = await roundtrip(line);
|
||||
// At least one produced block is NOT a paragraph — i.e. corruption.
|
||||
const allParagraphs = blocks.every((b) => b.type === "paragraph");
|
||||
expect(
|
||||
allParagraphs,
|
||||
`expected "${line}" to corrupt when inserted bare`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
|
||||
// The severe case: no text node survives. Documents why neutralization
|
||||
// matters more here than for list/quote (where the text survived).
|
||||
for (const line of ["---", "***", "___"]) {
|
||||
const blocks = await roundtrip(line);
|
||||
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
|
||||
// No block carries the original text anywhere.
|
||||
const flat = JSON.stringify(blocks);
|
||||
expect(flat).not.toContain(line);
|
||||
}
|
||||
});
|
||||
|
||||
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
|
||||
for (const line of triggerLines) {
|
||||
// The regex must actually classify each as a trigger.
|
||||
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
|
||||
true,
|
||||
);
|
||||
const neutralized = ZWSP + line;
|
||||
const blocks = await roundtrip(neutralized);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe("paragraph");
|
||||
// Text is byte-preserved (ZWSP + original line), so the display is the
|
||||
// original line with only an invisible leading character.
|
||||
expect(blocks[0].content[0].text).toBe(neutralized);
|
||||
}
|
||||
});
|
||||
|
||||
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
|
||||
for (const line of [
|
||||
"You: hello there",
|
||||
"Speaker 1: - and then a dash mid-line",
|
||||
"Speaker 2: 1. not a list",
|
||||
]) {
|
||||
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
|
||||
const blocks = await roundtrip(line);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe("paragraph");
|
||||
expect(blocks[0].content[0].text).toBe(line);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user