diff --git a/apps/desktop/src/chat/tools/index.test.ts b/apps/desktop/src/chat/tools/index.test.ts index 2ad37ba04f..7db434737d 100644 --- a/apps/desktop/src/chat/tools/index.test.ts +++ b/apps/desktop/src/chat/tools/index.test.ts @@ -34,6 +34,7 @@ describe("chat tool registration", () => { expect(tools).toHaveProperty("find_related_meetings"); expect(tools).toHaveProperty("edit_memo"); expect(tools).toHaveProperty("edit_summary"); + expect(tools).toHaveProperty("move_meeting_contents"); expect(tools).not.toHaveProperty("search_sessions"); expect(tools).not.toHaveProperty("grep_notes"); expect(tools).not.toHaveProperty("read_note"); diff --git a/apps/desktop/src/chat/tools/index.ts b/apps/desktop/src/chat/tools/index.ts index 65be11bd55..91a7a6b541 100644 --- a/apps/desktop/src/chat/tools/index.ts +++ b/apps/desktop/src/chat/tools/index.ts @@ -17,6 +17,7 @@ import { buildGetRecurringMeetingHistoryTool, buildListMeetingsTool, } from "./meetings"; +import { buildMoveMeetingContentsTool } from "./move-meeting-contents"; import { buildFindRelatedMeetingsTool, buildSearchMeetingContentTool, @@ -107,6 +108,10 @@ export const buildChatTools = (deps: ToolDependencies) => ({ "apply_session_correction", buildApplySessionCorrectionTool(deps), ), + move_meeting_contents: withToolLogging( + "move_meeting_contents", + buildMoveMeetingContentsTool(deps), + ), }); type LocalTools = { @@ -254,6 +259,27 @@ type LocalTools = { }; }; }; + move_meeting_contents: { + input: { + sourceMeetingId?: string; + targetMeetingId: string; + }; + output: { + status: string; + message?: string; + sourceMeetingId?: string; + targetMeetingId?: string; + sourceTitle?: string; + targetTitle?: string; + moved?: { + recording: boolean; + transcripts: number; + summaries: number; + notes: boolean; + actionItems: number; + }; + }; + }; }; export type Tools = LocalTools; diff --git a/apps/desktop/src/chat/tools/move-meeting-contents.test.ts b/apps/desktop/src/chat/tools/move-meeting-contents.test.ts new file mode 100644 index 0000000000..17bc813f5a --- /dev/null +++ b/apps/desktop/src/chat/tools/move-meeting-contents.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + moveSessionContents: vi.fn(), +})); + +vi.mock("~/session/move-contents", () => ({ + moveSessionContents: mocks.moveSessionContents, +})); + +import { buildMoveMeetingContentsTool } from "./move-meeting-contents"; + +describe("move meeting contents chat tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.moveSessionContents.mockResolvedValue({ + status: "moved", + sourceMeetingId: "source", + targetMeetingId: "target", + sourceTitle: "Standup", + targetTitle: "Board", + moved: { + recording: true, + transcripts: 1, + summaries: 1, + notes: true, + actionItems: 0, + }, + }); + }); + + it("defaults the source meeting to the current session", async () => { + const tool = buildMoveMeetingContentsTool({ + getSessionId: () => "source", + }); + + await expect( + (tool as any).execute({ targetMeetingId: "target" }), + ).resolves.toMatchObject({ status: "moved" }); + + expect(mocks.moveSessionContents).toHaveBeenCalledWith({ + sourceSessionId: "source", + targetSessionId: "target", + }); + }); + + it("requires an explicit source when no meeting is open", async () => { + const tool = buildMoveMeetingContentsTool({ + getSessionId: () => undefined, + }); + + await expect( + (tool as any).execute({ targetMeetingId: "target" }), + ).resolves.toEqual({ + status: "error", + message: + "No source meeting selected. Provide sourceMeetingId explicitly when calling move_meeting_contents.", + }); + expect(mocks.moveSessionContents).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/chat/tools/move-meeting-contents.ts b/apps/desktop/src/chat/tools/move-meeting-contents.ts new file mode 100644 index 0000000000..165e75912d --- /dev/null +++ b/apps/desktop/src/chat/tools/move-meeting-contents.ts @@ -0,0 +1,47 @@ +import { tool } from "ai"; +import { z } from "zod"; + +import type { ToolDependencies } from "./types"; + +import { moveSessionContents } from "~/session/move-contents"; + +export const buildMoveMeetingContentsTool = ( + deps: Pick, +) => + tool({ + description: + "Move a finished recording, transcript, generated summaries, notes, and action items from one meeting onto another existing meeting. Use this when the user says a recording or notes landed on the wrong meeting. Resolve both meeting IDs with list_meetings or search_meetings first and never guess IDs. The target meeting must not already have a recording or transcript.", + inputSchema: z.object({ + sourceMeetingId: z + .string() + .optional() + .describe( + "Meeting that currently has the recording or notes. Defaults to the current meeting.", + ), + targetMeetingId: z + .string() + .describe( + "Existing meeting that should receive the recording and notes.", + ), + }), + execute: async (params: { + sourceMeetingId?: string; + targetMeetingId: string; + }) => { + const sourceMeetingId = params.sourceMeetingId ?? deps.getSessionId(); + const targetMeetingId = params.targetMeetingId; + + if (!sourceMeetingId) { + return { + status: "error", + message: + "No source meeting selected. Provide sourceMeetingId explicitly when calling move_meeting_contents.", + }; + } + + return moveSessionContents({ + sourceSessionId: sourceMeetingId, + targetSessionId: targetMeetingId, + }); + }, + }); diff --git a/apps/desktop/src/chat/transport/use-transport.test.ts b/apps/desktop/src/chat/transport/use-transport.test.ts index 7aca0079ef..3f2e22fbf7 100644 --- a/apps/desktop/src/chat/transport/use-transport.test.ts +++ b/apps/desktop/src/chat/transport/use-transport.test.ts @@ -23,6 +23,8 @@ describe("chat transport prompt guidance", () => { expect(prompt).toContain( "Use apply_session_correction for narrow exact old-to-new corrections and edit_summary for broader summary rewrites", ); + expect(prompt).toContain("call move_meeting_contents"); + expect(prompt).toContain("Do not guess IDs"); expect(prompt).toContain( "Use edit_summary only for existing generated post-meeting summaries", ); diff --git a/apps/desktop/src/chat/transport/use-transport.ts b/apps/desktop/src/chat/transport/use-transport.ts index 78549be4d8..611bfdb0ab 100644 --- a/apps/desktop/src/chat/transport/use-transport.ts +++ b/apps/desktop/src/chat/transport/use-transport.ts @@ -24,6 +24,7 @@ Context and local meeting tool guidance: - When the user asks to rewrite, revise, refocus, shorten, or restructure an existing summary, call edit_summary with the complete replacement markdown so they can review and apply it. Do not return the rewrite only as a fenced markdown block. - Use edit_summary only for existing generated post-meeting summaries. Use apply_session_correction for narrow exact old-to-new corrections and edit_summary for broader summary rewrites. Only return a draft without calling edit_memo or edit_summary when the user explicitly asks not to change the meeting content or no target session can be resolved. - When the user corrects note content with wording like "it's not X but Y", use apply_session_correction to update the current session summary and transcript unless they explicitly ask for one target only. Add uncommon names, companies, products, acronyms, or jargon from the correction to dictionaryTerms so future transcription can prefer them; skip common names. If the tool reports partial, use get_meeting or retry with the exact remaining text instead of claiming both were updated. +- When the user asks to move a recording, transcript, or notes onto a different existing meeting, resolve both meeting IDs with list_meetings or search_meetings, then call move_meeting_contents. Default the source to the current meeting when they are looking at the misplaced recording. Do not guess IDs. If the target already has a recording or transcript, explain that and stop. - Do not ask the user to open or share a meeting until list_meetings, search_meetings, search_meeting_content, and get_meeting cannot find enough local context. - Use typed meeting tools instead of constructing shell commands, crawling files, or accessing SQLite directly. - Do not assume meeting contents from chat history when a typed tool can read the current source of truth. diff --git a/apps/desktop/src/session/move-contents.test.ts b/apps/desktop/src/session/move-contents.test.ts new file mode 100644 index 0000000000..9cd214738b --- /dev/null +++ b/apps/desktop/src/session/move-contents.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadSessionContentSnapshot: vi.fn(), + executeTransaction: vi.fn(), + liveQueryExecute: vi.fn(), + audioExist: vi.fn(), + audioCopy: vi.fn(), + catalogLocalSessionAudio: vi.fn(), + deleteSessionAudio: vi.fn(), + live: { + sessionId: null as string | null, + status: "inactive" as string, + finalizingBySession: {} as Record, + batchTranscriptionPendingBySession: {} as Record, + postStopProcessingBySession: {} as Record, + }, +})); + +vi.mock("./content-queries", () => ({ + loadSessionContentSnapshot: mocks.loadSessionContentSnapshot, +})); + +vi.mock("./attachments", () => ({ + catalogLocalSessionAudio: mocks.catalogLocalSessionAudio, + deleteSessionAudio: mocks.deleteSessionAudio, +})); + +vi.mock("~/db", () => ({ + executeTransaction: mocks.executeTransaction, + liveQueryClient: { + execute: mocks.liveQueryExecute, + }, +})); + +vi.mock("@anlg/plugin-fs-sync", () => ({ + commands: { + audioExist: mocks.audioExist, + audioCopy: mocks.audioCopy, + }, +})); + +vi.mock("~/store/zustand/listener/instance", () => ({ + listenerStore: { + getState: () => ({ live: mocks.live }), + }, +})); + +import { moveSessionContents } from "./move-contents"; + +function snapshot({ + sessionId, + title, + rawMarkdown = "", + rawContent = "", + transcripts = 0, + summaries = 0, +}: { + sessionId: string; + title: string; + rawMarkdown?: string; + rawContent?: string; + transcripts?: number; + summaries?: number; +}) { + return { + sessionId, + ownerUserId: "user-1", + title, + createdAt: "2026-08-19T12:00:00.000Z", + event: null, + eventId: null, + rawNoteId: sessionId, + rawTemplateId: "", + rawContent, + rawContentFormat: "prosemirror_json", + rawMarkdown, + enhancedNotes: Array.from({ length: summaries }, (_, index) => ({ + id: `summary-${index}`, + title: "Summary", + markdown: "Notes", + content: "{}", + contentFormat: "prosemirror_json", + templateId: "", + position: index, + })), + transcripts: Array.from({ length: transcripts }, (_, index) => ({ + id: `transcript-${index}`, + started_at: 0, + ended_at: 1000, + memo: "hello", + wordsJson: "[]", + speakerHintsJson: "[]", + words: [], + speaker_hints: [], + })), + participants: [], + }; +} + +describe("moveSessionContents", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.live.sessionId = null; + mocks.live.status = "inactive"; + mocks.live.finalizingBySession = {}; + mocks.live.batchTranscriptionPendingBySession = {}; + mocks.live.postStopProcessingBySession = {}; + mocks.executeTransaction.mockResolvedValue([1, 1, 1, 0, 0]); + mocks.liveQueryExecute.mockResolvedValue([{ action_item_count: 2 }]); + mocks.audioExist.mockImplementation(async (sessionId: string) => ({ + status: "ok", + data: sessionId === "source", + })); + mocks.audioCopy.mockResolvedValue({ status: "ok", data: true }); + mocks.catalogLocalSessionAudio.mockResolvedValue(undefined); + mocks.deleteSessionAudio.mockResolvedValue(true); + mocks.loadSessionContentSnapshot.mockImplementation( + async (sessionId: string) => { + if (sessionId === "source") { + return snapshot({ + sessionId: "source", + title: "Standup", + rawMarkdown: "Wrong place", + rawContent: '{"type":"doc"}', + transcripts: 1, + summaries: 1, + }); + } + if (sessionId === "target") { + return snapshot({ + sessionId: "target", + title: "Board", + }); + } + return null; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("moves recording, transcript, summary, notes, and action items", async () => { + await expect( + moveSessionContents({ + sourceSessionId: "source", + targetSessionId: "target", + }), + ).resolves.toEqual({ + status: "moved", + sourceMeetingId: "source", + targetMeetingId: "target", + sourceTitle: "Standup", + targetTitle: "Board", + moved: { + recording: true, + transcripts: 1, + summaries: 1, + notes: true, + actionItems: 2, + }, + }); + + expect(mocks.audioCopy).toHaveBeenCalledWith("source", "target"); + expect(mocks.catalogLocalSessionAudio).toHaveBeenCalledWith("target"); + expect(mocks.deleteSessionAudio).toHaveBeenCalledWith( + "source", + expect.any(Function), + ); + + const statements = mocks.executeTransaction.mock.calls[0][0]; + expect(statements[0].sql).toContain("UPDATE transcripts"); + expect(statements[0].params).toEqual([ + "target", + 1, + "session-audio:source", + "session-audio:target", + expect.any(String), + "source", + ]); + expect(statements[1].sql).toContain( + "kind IN ('summary', 'template_output')", + ); + expect(statements[2].sql).toContain("UPDATE action_items"); + expect(statements[5].params[0]).toBe('{"type":"doc"}'); + expect(statements[5].params[2]).toBe("target"); + expect(statements[6].params[2]).toBe("source"); + }); + + it("refuses to overwrite a target that already has a transcript", async () => { + mocks.audioExist.mockResolvedValue({ status: "ok", data: false }); + mocks.loadSessionContentSnapshot.mockImplementation( + async (sessionId: string) => { + if (sessionId === "source") { + return snapshot({ + sessionId: "source", + title: "Standup", + transcripts: 1, + }); + } + return snapshot({ + sessionId: "target", + title: "Board", + transcripts: 1, + }); + }, + ); + + await expect( + moveSessionContents({ + sourceSessionId: "source", + targetSessionId: "target", + }), + ).resolves.toMatchObject({ + status: "error", + message: expect.stringContaining("already has a recording or transcript"), + }); + expect(mocks.audioCopy).not.toHaveBeenCalled(); + expect(mocks.executeTransaction).not.toHaveBeenCalled(); + }); + + it("refuses while either meeting is still recording", async () => { + mocks.live.sessionId = "source"; + mocks.live.status = "active"; + + await expect( + moveSessionContents({ + sourceSessionId: "source", + targetSessionId: "target", + }), + ).resolves.toMatchObject({ + status: "error", + message: expect.stringContaining("recording and transcription finish"), + }); + expect(mocks.audioCopy).not.toHaveBeenCalled(); + }); + + it("rolls back a copied recording if the database write fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.executeTransaction.mockRejectedValue(new Error("busy")); + + await expect( + moveSessionContents({ + sourceSessionId: "source", + targetSessionId: "target", + }), + ).resolves.toMatchObject({ + status: "error", + message: "The move could not be completed. Nothing was changed.", + }); + expect(mocks.deleteSessionAudio).toHaveBeenCalledWith( + "target", + expect.any(Function), + ); + }); +}); diff --git a/apps/desktop/src/session/move-contents.ts b/apps/desktop/src/session/move-contents.ts new file mode 100644 index 0000000000..04da5333e6 --- /dev/null +++ b/apps/desktop/src/session/move-contents.ts @@ -0,0 +1,411 @@ +import { md2json } from "@anlg/editor/markdown"; +import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync"; + +import { catalogLocalSessionAudio, deleteSessionAudio } from "./attachments"; +import { enqueueSessionAudioOperation } from "./audio-operations"; +import { loadSessionContentSnapshot } from "./content-queries"; + +import { executeTransaction, liveQueryClient } from "~/db"; +import { enqueueDatabaseWrite } from "~/db/write-queue"; +import { listenerStore } from "~/store/zustand/listener/instance"; + +export type MoveSessionContentsResult = + | { + status: "moved"; + sourceMeetingId: string; + targetMeetingId: string; + sourceTitle: string; + targetTitle: string; + moved: { + recording: boolean; + transcripts: number; + summaries: number; + notes: boolean; + actionItems: number; + }; + } + | { + status: "error"; + message: string; + sourceMeetingId?: string; + targetMeetingId?: string; + } + | { + status: "nothing_to_move"; + message: string; + sourceMeetingId: string; + targetMeetingId: string; + }; + +type ActionItemCountRow = { action_item_count: number | boolean }; + +function hasNoteContent(markdown: string): boolean { + const trimmed = markdown.trim(); + return Boolean(trimmed && trimmed !== " "); +} + +function emptyNoteBody(): string { + return JSON.stringify(md2json("")); +} + +function isSessionBusy(sessionId: string): boolean { + const live = listenerStore.getState().live; + if ( + live.sessionId === sessionId && + (live.status === "active" || live.status === "finalizing") + ) { + return true; + } + + return Boolean( + live.finalizingBySession[sessionId] || + live.batchTranscriptionPendingBySession[sessionId] || + live.postStopProcessingBySession[sessionId], + ); +} + +function withOrderedLocks( + lock: (sessionId: string, operation: () => Promise) => Promise, + sessionIds: string[], + operation: () => Promise, +): Promise { + const unique = [...new Set(sessionIds)].sort(); + let run = operation; + for (let index = unique.length - 1; index >= 0; index--) { + const sessionId = unique[index]; + const inner = run; + run = () => lock(sessionId, inner); + } + return run(); +} + +async function sessionAudioExists(sessionId: string): Promise { + const result = await fsSyncCommands.audioExist(sessionId); + if (result.status === "error") { + throw new Error(result.error); + } + return result.data; +} + +async function copySessionAudio( + sourceSessionId: string, + targetSessionId: string, +): Promise { + const result = await fsSyncCommands.audioCopy( + sourceSessionId, + targetSessionId, + ); + if (result.status === "error") { + throw new Error(result.error); + } + return result.data; +} + +async function rollbackCopiedAudio(targetSessionId: string): Promise { + try { + await deleteSessionAudio(targetSessionId, () => true); + } catch (error) { + console.error( + "[session] failed to roll back copied recording", + targetSessionId, + error, + ); + } +} + +export async function moveSessionContents({ + sourceSessionId, + targetSessionId, +}: { + sourceSessionId: string; + targetSessionId: string; +}): Promise { + if (!sourceSessionId || !targetSessionId) { + return { + status: "error", + message: "Both the source and target meetings are required.", + sourceMeetingId: sourceSessionId || undefined, + targetMeetingId: targetSessionId || undefined, + }; + } + + if (sourceSessionId === targetSessionId) { + return { + status: "error", + message: "Choose two different meetings.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + if (isSessionBusy(sourceSessionId) || isSessionBusy(targetSessionId)) { + return { + status: "error", + message: + "Wait until recording and transcription finish on both meetings, then try again.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + const [source, target] = await Promise.all([ + loadSessionContentSnapshot(sourceSessionId), + loadSessionContentSnapshot(targetSessionId), + ]); + + if (!source) { + return { + status: "error", + message: "The source meeting could not be loaded.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + if (!target) { + return { + status: "error", + message: "The target meeting could not be loaded.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + const [sourceHasAudio, targetHasAudio, actionItemRows] = await Promise.all([ + sessionAudioExists(sourceSessionId), + sessionAudioExists(targetSessionId), + liveQueryClient.execute( + ` + SELECT COUNT(*) AS action_item_count + FROM action_items + WHERE session_id = ? AND deleted_at IS NULL + `, + [sourceSessionId], + ), + ]); + + const sourceActionItems = Number(actionItemRows[0]?.action_item_count ?? 0); + const sourceHasNotes = hasNoteContent(source.rawMarkdown); + const hasAnythingToMove = + sourceHasAudio || + source.transcripts.length > 0 || + source.enhancedNotes.length > 0 || + sourceHasNotes || + sourceActionItems > 0; + + if (!hasAnythingToMove) { + return { + status: "nothing_to_move", + message: + "The source meeting has no recording, transcript, or notes to move.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + if (targetHasAudio || target.transcripts.length > 0) { + return { + status: "error", + message: + "The target meeting already has a recording or transcript. Move into an empty meeting, or delete the existing recording first.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + let copiedAudio = false; + try { + if (sourceHasAudio) { + copiedAudio = await withOrderedLocks( + enqueueSessionAudioOperation, + [sourceSessionId, targetSessionId], + () => copySessionAudio(sourceSessionId, targetSessionId), + ); + if (copiedAudio) { + await catalogLocalSessionAudio(targetSessionId); + } + } + + const now = new Date().toISOString(); + const sourceAudioId = `session-audio:${sourceSessionId}`; + const targetAudioId = `session-audio:${targetSessionId}`; + const shouldRewriteAudioIds = copiedAudio; + const targetHasNotes = hasNoteContent(target.rawMarkdown); + const nextTargetNote = sourceHasNotes + ? targetHasNotes + ? JSON.stringify( + md2json( + [target.rawMarkdown.trim(), source.rawMarkdown.trim()].join( + "\n\n", + ), + ), + ) + : source.rawContentFormat === "prosemirror_json" && source.rawContent + ? source.rawContent + : JSON.stringify(md2json(source.rawMarkdown)) + : null; + + await withOrderedLocks( + enqueueDatabaseWrite, + [`session:${sourceSessionId}`, `session:${targetSessionId}`], + () => + executeTransaction([ + { + sql: ` + UPDATE transcripts + SET + session_id = ?, + audio_attachment_id = CASE + WHEN ? = 1 AND audio_attachment_id = ? THEN ? + ELSE audio_attachment_id + END, + updated_at = ? + WHERE session_id = ? AND deleted_at IS NULL + `, + params: [ + targetSessionId, + shouldRewriteAudioIds ? 1 : 0, + sourceAudioId, + targetAudioId, + now, + sourceSessionId, + ], + }, + { + sql: ` + UPDATE session_documents + SET session_id = ?, updated_at = ? + WHERE session_id = ? + AND kind IN ('summary', 'template_output') + AND deleted_at IS NULL + `, + params: [targetSessionId, now, sourceSessionId], + }, + { + sql: ` + UPDATE action_items + SET session_id = ?, updated_at = ? + WHERE session_id = ? AND deleted_at IS NULL + `, + params: [targetSessionId, now, sourceSessionId], + }, + { + sql: ` + UPDATE voiceprint_exemplars + SET + source_session_id = ?, + source_attachment_id = CASE + WHEN ? = 1 AND source_attachment_id = ? THEN ? + ELSE source_attachment_id + END, + updated_at = ? + WHERE source_session_id = ? AND deleted_at IS NULL + `, + params: [ + targetSessionId, + shouldRewriteAudioIds ? 1 : 0, + sourceAudioId, + targetAudioId, + now, + sourceSessionId, + ], + }, + { + sql: ` + UPDATE voiceprint_candidates + SET + source_session_id = ?, + source_attachment_id = CASE + WHEN ? = 1 AND source_attachment_id = ? THEN ? + ELSE source_attachment_id + END, + updated_at = ? + WHERE source_session_id = ? AND deleted_at IS NULL + `, + params: [ + targetSessionId, + shouldRewriteAudioIds ? 1 : 0, + sourceAudioId, + targetAudioId, + now, + sourceSessionId, + ], + }, + ...(nextTargetNote + ? [ + { + sql: ` + UPDATE session_documents + SET body = ?, body_format = 'prosemirror_json', updated_at = ? + WHERE id = ? + AND session_id = ? + AND kind = 'note' + AND deleted_at IS NULL + `, + params: [ + nextTargetNote, + now, + targetSessionId, + targetSessionId, + ], + expectedRowsAffected: 1, + }, + { + sql: ` + UPDATE session_documents + SET body = ?, body_format = 'prosemirror_json', updated_at = ? + WHERE id = ? + AND session_id = ? + AND kind = 'note' + AND deleted_at IS NULL + `, + params: [ + emptyNoteBody(), + now, + sourceSessionId, + sourceSessionId, + ], + expectedRowsAffected: 1, + }, + ] + : []), + ]), + ); + } catch (error) { + console.error("Failed to move meeting contents", error); + if (copiedAudio) { + await rollbackCopiedAudio(targetSessionId); + } + return { + status: "error", + message: "The move could not be completed. Nothing was changed.", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + }; + } + + if (copiedAudio) { + try { + await deleteSessionAudio(sourceSessionId, () => true); + } catch (error) { + console.error( + "[session] moved recording but failed to remove the source file", + error, + ); + } + } + + return { + status: "moved", + sourceMeetingId: sourceSessionId, + targetMeetingId: targetSessionId, + sourceTitle: source.title, + targetTitle: target.title, + moved: { + recording: copiedAudio, + transcripts: source.transcripts.length, + summaries: source.enhancedNotes.length, + notes: sourceHasNotes, + actionItems: sourceActionItems, + }, + }; +} diff --git a/crates/fs-sync-core/src/audio/mod.rs b/crates/fs-sync-core/src/audio/mod.rs index 413135349b..5ed3856623 100644 --- a/crates/fs-sync-core/src/audio/mod.rs +++ b/crates/fs-sync-core/src/audio/mod.rs @@ -51,6 +51,33 @@ pub fn delete(session_dir: &Path) -> std::io::Result { delete_with(session_dir, |path| std::fs::remove_file(path)) } +pub fn copy(source_dir: &Path, target_dir: &Path) -> std::io::Result { + let Some(source_path) = path(source_dir) else { + return Ok(false); + }; + if exists(target_dir)? { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "audio_target_exists", + )); + } + + std::fs::create_dir_all(target_dir)?; + let Some(filename) = source_path.file_name() else { + return Err(std::io::Error::other("audio_source_filename_missing")); + }; + let target_path = target_dir.join(filename); + if source_path == target_path { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "audio_copy_same_path", + )); + } + + std::fs::copy(&source_path, &target_path)?; + Ok(true) +} + pub fn path(session_dir: &Path) -> Option { AUDIO_FORMATS .iter() @@ -338,6 +365,50 @@ mod tests { std::fs::write(path, b"audio").unwrap(); } + #[test] + fn copy_duplicates_primary_audio_without_artifacts() { + let temp = TempDir::new().unwrap(); + let source_dir = temp.path().join("source"); + let target_dir = temp.path().join("target"); + std::fs::create_dir_all(&source_dir).unwrap(); + write_audio(&source_dir.join("audio.wav")); + write_audio(&source_dir.join("audio_mic.wav")); + + assert!(copy(&source_dir, &target_dir).unwrap()); + assert_eq!( + std::fs::read(source_dir.join("audio.wav")).unwrap(), + std::fs::read(target_dir.join("audio.wav")).unwrap() + ); + assert!(!target_dir.join("audio_mic.wav").exists()); + assert!(source_dir.join("audio.wav").exists()); + } + + #[test] + fn copy_without_source_audio_returns_false() { + let temp = TempDir::new().unwrap(); + let source_dir = temp.path().join("source"); + let target_dir = temp.path().join("target"); + std::fs::create_dir_all(&source_dir).unwrap(); + + assert!(!copy(&source_dir, &target_dir).unwrap()); + assert!(!target_dir.exists() || path(&target_dir).is_none()); + } + + #[test] + fn copy_refuses_when_target_already_has_audio() { + let temp = TempDir::new().unwrap(); + let source_dir = temp.path().join("source"); + let target_dir = temp.path().join("target"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + write_audio(&source_dir.join("audio.mp3")); + write_audio(&target_dir.join("audio.wav")); + + let error = copy(&source_dir, &target_dir).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(error.to_string(), "audio_target_exists"); + } + #[test] fn test_delete_removes_audio_artifacts() { let temp = TempDir::new().unwrap(); diff --git a/plugins/fs-sync/build.rs b/plugins/fs-sync/build.rs index 37b984615d..52ae9e8e79 100644 --- a/plugins/fs-sync/build.rs +++ b/plugins/fs-sync/build.rs @@ -16,6 +16,7 @@ const COMMANDS: &[&str] = &[ "audio_import_data", "audio_source_metadata", "audio_path", + "audio_copy", "session_dir", "load_session_content", "delete_session_folder", diff --git a/plugins/fs-sync/js/bindings.gen.ts b/plugins/fs-sync/js/bindings.gen.ts index da4ff649b8..a17d754662 100644 --- a/plugins/fs-sync/js/bindings.gen.ts +++ b/plugins/fs-sync/js/bindings.gen.ts @@ -250,6 +250,23 @@ export const commands = { else return { status: "error", error: e as any }; } }, + async audioCopy( + sourceSessionId: string, + targetSessionId: string, + ): Promise> { + try { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:fs-sync|audio_copy", { + sourceSessionId, + targetSessionId, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, async sessionDir(sessionId: string): Promise> { try { return { diff --git a/plugins/fs-sync/permissions/autogenerated/commands/audio_copy.toml b/plugins/fs-sync/permissions/autogenerated/commands/audio_copy.toml new file mode 100644 index 0000000000..48a2ca07d8 --- /dev/null +++ b/plugins/fs-sync/permissions/autogenerated/commands/audio_copy.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-audio-copy" +description = "Enables the audio_copy command without any pre-configured scope." +commands.allow = ["audio_copy"] + +[[permission]] +identifier = "deny-audio-copy" +description = "Denies the audio_copy command without any pre-configured scope." +commands.deny = ["audio_copy"] diff --git a/plugins/fs-sync/permissions/autogenerated/reference.md b/plugins/fs-sync/permissions/autogenerated/reference.md index 7cb5663800..6eeb269ce1 100644 --- a/plugins/fs-sync/permissions/autogenerated/reference.md +++ b/plugins/fs-sync/permissions/autogenerated/reference.md @@ -21,6 +21,7 @@ Default permissions for the fs-sync plugin - `allow-audio-import-data` - `allow-audio-source-metadata` - `allow-audio-path` +- `allow-audio-copy` - `allow-session-dir` - `allow-load-session-content` - `allow-delete-session-folder` @@ -330,6 +331,32 @@ Denies the audio_path command without any pre-configured scope. +`fs-sync:allow-audio-copy` + + + + +Enables the audio_copy command without any pre-configured scope. + + + + + + + +`fs-sync:deny-audio-copy` + + + + +Denies the audio_copy command without any pre-configured scope. + + + + + + + `fs-sync:allow-audio-source-metadata` diff --git a/plugins/fs-sync/permissions/default.toml b/plugins/fs-sync/permissions/default.toml index 0ae6a831e7..a572ddc20e 100644 --- a/plugins/fs-sync/permissions/default.toml +++ b/plugins/fs-sync/permissions/default.toml @@ -18,6 +18,7 @@ permissions = [ "allow-audio-import-data", "allow-audio-source-metadata", "allow-audio-path", + "allow-audio-copy", "allow-session-dir", "allow-load-session-content", "allow-delete-session-folder", diff --git a/plugins/fs-sync/permissions/schemas/schema.json b/plugins/fs-sync/permissions/schemas/schema.json index d812ed322a..5dcd76efd8 100644 --- a/plugins/fs-sync/permissions/schemas/schema.json +++ b/plugins/fs-sync/permissions/schemas/schema.json @@ -414,6 +414,18 @@ "const": "deny-audio-metadata", "markdownDescription": "Denies the audio_metadata command without any pre-configured scope." }, + { + "description": "Enables the audio_copy command without any pre-configured scope.", + "type": "string", + "const": "allow-audio-copy", + "markdownDescription": "Enables the audio_copy command without any pre-configured scope." + }, + { + "description": "Denies the audio_copy command without any pre-configured scope.", + "type": "string", + "const": "deny-audio-copy", + "markdownDescription": "Denies the audio_copy command without any pre-configured scope." + }, { "description": "Enables the audio_path command without any pre-configured scope.", "type": "string", @@ -631,10 +643,10 @@ "markdownDescription": "Denies the write_json_batch command without any pre-configured scope." }, { - "description": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`", + "description": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`" + "markdownDescription": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`" } ] } diff --git a/plugins/fs-sync/src/commands.rs b/plugins/fs-sync/src/commands.rs index 9619fe9389..7f0c323c9d 100644 --- a/plugins/fs-sync/src/commands.rs +++ b/plugins/fs-sync/src/commands.rs @@ -428,6 +428,22 @@ pub(crate) async fn audio_path( .ok_or_else(|| "audio_path_not_found".to_string()) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn audio_copy( + app: tauri::AppHandle, + source_session_id: String, + target_session_id: String, +) -> Result { + if source_session_id == target_session_id { + return Err("audio_copy_same_session".into()); + } + + let source_dir = resolve_session_dir(&app, &source_session_id)?; + let target_dir = resolve_session_dir(&app, &target_session_id)?; + spawn_blocking!({ crate::audio::copy(&source_dir, &target_dir).map_err(|e| e.to_string()) }) +} + #[tauri::command] #[specta::specta] pub(crate) async fn session_dir( diff --git a/plugins/fs-sync/src/lib.rs b/plugins/fs-sync/src/lib.rs index 7a4655e8b3..0521947419 100644 --- a/plugins/fs-sync/src/lib.rs +++ b/plugins/fs-sync/src/lib.rs @@ -28,6 +28,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::audio_import_data::, commands::audio_source_metadata, commands::audio_path::, + commands::audio_copy::, commands::session_dir::, commands::load_session_content::, commands::delete_session_folder::,