Skip to content

Commit 1ebf9b0

Browse files
authored
Transcript editor persist (#830)
1 parent 97ebf6f commit 1ebf9b0

File tree

12 files changed

+48662
-8189
lines changed

12 files changed

+48662
-8189
lines changed

apps/desktop/src/components/right-panel/hooks/useTranscript.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function useTranscript(sessionId: string | null) {
3838
: dbCommands.getWords;
3939

4040
const words = await fn(sessionId);
41-
setWords(words);
41+
setWords(words as Word[]);
4242
} finally {
4343
setIsLoading(false);
4444
}
@@ -56,7 +56,7 @@ export function useTranscript(sessionId: string | null) {
5656

5757
listenerEvents.sessionEvent.listen(({ payload }) => {
5858
if (payload.type === "words") {
59-
setWords((words) => [...words, ...payload.words]);
59+
setWords((words) => [...words, ...payload.words] as Word[]);
6060
}
6161
}).then((fn) => {
6262
unlisten = fn;

apps/desktop/src/components/toolbar/index.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ import { useMatch } from "@tanstack/react-router";
22

33
import { useEditMode } from "@/contexts/edit-mode-context";
44
import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows";
5-
import { CalendarToolbar, DefaultToolbar, EntityToolbar, MainToolbar, NoteToolbar, TranscriptToolbar } from "./bars";
5+
import { CalendarToolbar, DefaultToolbar, EntityToolbar, MainToolbar, NoteToolbar } from "./bars";
66

77
export default function Toolbar() {
88
const noteMatch = useMatch({ from: "/app/note/$id", shouldThrow: false });
99
const organizationMatch = useMatch({ from: "/app/organization/$id", shouldThrow: false });
1010
const humanMatch = useMatch({ from: "/app/human/$id", shouldThrow: false });
1111
const calendarMatch = useMatch({ from: "/app/calendar", shouldThrow: false });
1212
const plansMatch = useMatch({ from: "/app/plans", shouldThrow: false });
13-
const transcriptMatch = useMatch({ from: "/app/transcript/$id", shouldThrow: false });
1413

1514
const { isEditing, toggleEditMode } = useEditMode();
1615

@@ -20,7 +19,6 @@ export default function Toolbar() {
2019
const isHuman = !!humanMatch;
2120
const isCalendar = !!calendarMatch;
2221
const isPlans = !!plansMatch;
23-
const isTranscript = !!transcriptMatch;
2422

2523
if (isCalendar) {
2624
const date = calendarMatch?.search?.date ? new Date(calendarMatch.search.date as string) : new Date();
@@ -45,10 +43,6 @@ export default function Toolbar() {
4543
);
4644
}
4745

48-
if (isTranscript) {
49-
return <TranscriptToolbar />;
50-
}
51-
5246
return null;
5347
}
5448

apps/desktop/src/routes/app.note.$id.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ function Component() {
6161
const shouldDelete = !session.title
6262
&& isEmpty(session.raw_memo_html)
6363
&& isEmpty(session.enhanced_memo_html)
64-
&& session.conversations.length === 0
64+
&& session.words.length === 0
6565
&& ongoingSessionId !== session.id;
6666

6767
if (shouldDelete) {
Lines changed: 208 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { createFileRoute } from "@tanstack/react-router";
1+
import { useQuery } from "@tanstack/react-query";
2+
import { createFileRoute, useParams } from "@tanstack/react-router";
23
import { ReplaceAllIcon } from "lucide-react";
4+
import { PencilIcon } from "lucide-react";
35
import { useEffect, useRef, useState } from "react";
46

5-
import { commands as dbCommands } from "@hypr/plugin-db";
7+
import { commands as dbCommands, type SpeakerIdentity, type Word } from "@hypr/plugin-db";
8+
import { commands as windowsCommands } from "@hypr/plugin-windows";
69
import TranscriptEditor from "@hypr/tiptap/transcript";
710
import { Button } from "@hypr/ui/components/ui/button";
811
import { Input } from "@hypr/ui/components/ui/input";
@@ -12,17 +15,22 @@ export const Route = createFileRoute("/app/transcript/$id")({
1215
component: Component,
1316
loader: async ({ params: { id }, context: { onboardingSessionId } }) => {
1417
const participants = await dbCommands.sessionListParticipants(id);
15-
const words = onboardingSessionId
18+
const words = id === onboardingSessionId
1619
? await dbCommands.getWordsOnboarding()
1720
: await dbCommands.getWords(id);
1821

1922
return { participants, words };
2023
},
2124
});
2225

26+
type EditorContent = {
27+
type: "doc";
28+
content: SpeakerContent[];
29+
};
30+
2331
type SpeakerContent = {
2432
type: "speaker";
25-
attrs: { label: string };
33+
attrs: { "speaker-index": number | null; "speaker-id": string | null; "speaker-label": string | null };
2634
content: WordContent[];
2735
};
2836

@@ -35,29 +43,89 @@ function Component() {
3543
const { participants, words } = Route.useLoaderData();
3644
const editorRef = useRef(null);
3745

38-
const content = {
39-
type: "doc",
40-
content: words.reduce<{ cur: number | null; acc: SpeakerContent[] }>((state, word) => {
41-
if (state.cur !== word.speaker) {
42-
state.cur = word.speaker;
43-
state.acc.push({
44-
type: "speaker",
45-
attrs: { label: word.speaker === null ? "" : `Speaker ${word.speaker}` },
46-
content: [],
47-
});
48-
}
46+
const [content, _d] = useState(fromWordsToEditor(words));
4947

50-
if (state.acc.length > 0) {
51-
state.acc[state.acc.length - 1].content.push({
52-
type: "word",
53-
content: [{ type: "text", text: word.text }],
48+
return (
49+
<div className="px-6 py-2">
50+
<TranscriptToolbar editorRef={editorRef} />
51+
52+
<div className="flex-1 overflow-auto min-h-0">
53+
<TranscriptEditor
54+
ref={editorRef}
55+
initialContent={content}
56+
speakers={participants.map((p) => ({ id: p.id, name: p.full_name ?? "Unknown" }))}
57+
/>
58+
</div>
59+
</div>
60+
);
61+
}
62+
63+
function TranscriptToolbar({ editorRef }: { editorRef: React.RefObject<any> }) {
64+
const { id } = useParams({ from: "/app/transcript/$id" });
65+
66+
const title = useQuery({
67+
queryKey: ["session-title", id],
68+
queryFn: () => dbCommands.getSession({ id }).then((v) => v?.title),
69+
});
70+
71+
const handleSave = () => {
72+
const content = editorRef.current?.editor.getJSON();
73+
const words = fromEditorToWords(content);
74+
75+
dbCommands.getSession({ id }).then((session) => {
76+
if (session) {
77+
dbCommands.upsertSession({
78+
...session,
79+
words,
5480
});
5581
}
82+
}).then(() => {
83+
windowsCommands.windowDestroy({ type: "transcript", value: id });
84+
});
85+
};
5686

57-
return state;
58-
}, { cur: null, acc: [] }).acc,
87+
const handleCancel = () => {
88+
windowsCommands.windowDestroy({ type: "transcript", value: id });
5989
};
6090

91+
return (
92+
<header
93+
data-tauri-drag-region
94+
className="flex w-full items-center justify-between min-h-11 p-1 px-3 border-b border-border bg-background/80 backdrop-blur-sm"
95+
>
96+
<div className="w-20 ml-20">
97+
<SearchAndReplace editorRef={editorRef} />
98+
</div>
99+
100+
<div
101+
className="flex-1 flex items-center justify-center"
102+
data-tauri-drag-region
103+
>
104+
<div className="flex items-center gap-2">
105+
<PencilIcon className="w-3 h-3 text-muted-foreground" />
106+
<h1 className="text-sm font-light truncate max-w-md" data-tauri-drag-region>
107+
(Transcript) {title.data}
108+
</h1>
109+
</div>
110+
</div>
111+
112+
<div className="w-40 flex justify-end gap-2">
113+
<Button
114+
variant="ghost"
115+
size="sm"
116+
onClick={handleCancel}
117+
>
118+
Cancel
119+
</Button>
120+
<Button size="sm" onClick={handleSave}>
121+
Save
122+
</Button>
123+
</div>
124+
</header>
125+
);
126+
}
127+
128+
function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
61129
const [expanded, setExpanded] = useState(false);
62130
const [searchTerm, setSearchTerm] = useState("");
63131
const [replaceTerm, setReplaceTerm] = useState("");
@@ -84,55 +152,132 @@ function Component() {
84152
if (editorRef.current && searchTerm) {
85153
// @ts-ignore
86154
editorRef.current.editor.commands.replaceAll(replaceTerm);
87-
// setExpanded(false);
88-
// TODO: we need editor state updated first.
155+
setExpanded(false);
156+
setSearchTerm("");
157+
setReplaceTerm("");
89158
}
90159
};
91160

92161
return (
93-
<div className="p-6 flex-1 flex flex-col overflow-hidden">
94-
<Popover open={expanded} onOpenChange={setExpanded}>
95-
<PopoverTrigger asChild>
162+
<Popover open={expanded} onOpenChange={setExpanded}>
163+
<PopoverTrigger asChild>
164+
<Button
165+
className="w-8"
166+
variant="default"
167+
size="icon"
168+
>
169+
<ReplaceAllIcon size={12} />
170+
</Button>
171+
</PopoverTrigger>
172+
<PopoverContent className="w-full p-2">
173+
<div className="flex flex-row gap-2">
174+
<Input
175+
className="h-6"
176+
value={searchTerm}
177+
onChange={(e) => setSearchTerm(e.target.value)}
178+
placeholder="Search"
179+
/>
180+
<Input
181+
className="h-6"
182+
value={replaceTerm}
183+
onChange={(e) => setReplaceTerm(e.target.value)}
184+
placeholder="Replace"
185+
/>
96186
<Button
97-
className="w-8"
187+
className="h-6"
98188
variant="default"
99-
size="icon"
189+
onClick={handleReplaceAll}
100190
>
101-
<ReplaceAllIcon size={12} />
191+
Replace
102192
</Button>
103-
</PopoverTrigger>
104-
<PopoverContent className="w-full p-2">
105-
<div className="flex flex-row gap-2">
106-
<Input
107-
className="h-6"
108-
value={searchTerm}
109-
onChange={(e) => setSearchTerm(e.target.value)}
110-
placeholder="Search"
111-
/>
112-
<Input
113-
className="h-6"
114-
value={replaceTerm}
115-
onChange={(e) => setReplaceTerm(e.target.value)}
116-
placeholder="Replace"
117-
/>
118-
<Button
119-
className="h-6"
120-
variant="default"
121-
onClick={handleReplaceAll}
122-
>
123-
Replace
124-
</Button>
125-
</div>
126-
</PopoverContent>
127-
</Popover>
128-
129-
<div className="h-full overflow-auto">
130-
<TranscriptEditor
131-
ref={editorRef}
132-
initialContent={content}
133-
speakers={participants.map((p) => ({ id: p.id, name: p.full_name ?? "Unknown" }))}
134-
/>
135-
</div>
136-
</div>
193+
</div>
194+
</PopoverContent>
195+
</Popover>
137196
);
138197
}
198+
199+
const fromWordsToEditor = (words: Word[]): EditorContent => {
200+
return {
201+
type: "doc",
202+
content: words.reduce<{ cur: SpeakerIdentity | null; acc: SpeakerContent[] }>((state, word) => {
203+
const isFirst = state.acc.length === 0;
204+
205+
const isSameSpeaker = (!state.cur && !word.speaker)
206+
|| (state.cur?.type === "unassigned" && word.speaker?.type === "unassigned"
207+
&& state.cur.value.index === word.speaker.value.index)
208+
|| (state.cur?.type === "assigned" && word.speaker?.type === "assigned"
209+
&& state.cur.value.id === word.speaker.value.id);
210+
211+
if (isFirst || !isSameSpeaker) {
212+
state.cur = word.speaker;
213+
214+
state.acc.push({
215+
type: "speaker",
216+
attrs: {
217+
"speaker-index": word.speaker?.type === "unassigned" ? word.speaker.value?.index : null,
218+
"speaker-id": word.speaker?.type === "assigned" ? word.speaker.value?.id : null,
219+
"speaker-label": word.speaker?.type === "assigned" ? word.speaker.value?.label || "" : null,
220+
},
221+
content: [],
222+
});
223+
}
224+
225+
if (state.acc.length > 0) {
226+
state.acc[state.acc.length - 1].content.push({
227+
type: "word",
228+
content: [{ type: "text", text: word.text }],
229+
});
230+
}
231+
232+
return state;
233+
}, { cur: null, acc: [] }).acc,
234+
};
235+
};
236+
237+
const fromEditorToWords = (content: EditorContent): Word[] => {
238+
if (!content?.content) {
239+
return [];
240+
}
241+
242+
const words: Word[] = [];
243+
244+
for (const speakerBlock of content.content) {
245+
if (speakerBlock.type !== "speaker" || !speakerBlock.content) {
246+
continue;
247+
}
248+
249+
let speaker: SpeakerIdentity | null = null;
250+
if (speakerBlock.attrs["speaker-id"]) {
251+
speaker = {
252+
type: "assigned",
253+
value: {
254+
id: speakerBlock.attrs["speaker-id"],
255+
label: speakerBlock.attrs["speaker-label"] ?? "",
256+
},
257+
};
258+
} else if (typeof speakerBlock.attrs["speaker-index"] === "number") {
259+
speaker = {
260+
type: "unassigned",
261+
value: {
262+
index: speakerBlock.attrs["speaker-index"],
263+
},
264+
};
265+
}
266+
267+
for (const wordBlock of speakerBlock.content) {
268+
if (wordBlock.type !== "word" || !wordBlock.content?.[0]?.text) {
269+
continue;
270+
}
271+
272+
words.push({
273+
text: wordBlock.content[0].text,
274+
speaker,
275+
confidence: 1,
276+
start_ms: 0,
277+
end_ms: 0,
278+
});
279+
}
280+
}
281+
282+
return words;
283+
};

0 commit comments

Comments
 (0)