diff --git a/Cargo.lock b/Cargo.lock index 9ad24581153..dd998abe23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6005,6 +6005,7 @@ dependencies = [ "glob", "predicates", "rayon", + "rodio", "serde", "serde_json", "serde_yaml", @@ -6016,6 +6017,7 @@ dependencies = [ "tiptap", "tracing", "uuid", + "vad", ] [[package]] diff --git a/apps/desktop/src/stt/capture-lifecycle-storage.test.ts b/apps/desktop/src/stt/capture-lifecycle-storage.test.ts index f8a6bfcf117..37efcf7e9b1 100644 --- a/apps/desktop/src/stt/capture-lifecycle-storage.test.ts +++ b/apps/desktop/src/stt/capture-lifecycle-storage.test.ts @@ -72,6 +72,21 @@ test("loads a valid capture marker", async () => { ); }); +test("preserves automatic capture provenance and its pre-recording audio state", async () => { + const automaticMarker = { + ...marker, + automatic: true, + preserveExistingAudio: false, + initialTitle: "Standup", + }; + mocks.execute.mockResolvedValue([ + { value_json: JSON.stringify(automaticMarker) }, + ]); + await expect(loadCaptureLifecycleMarker("session-1")).resolves.toEqual( + automaticMarker, + ); +}); + test("loads the exact durable summary recovery mode", async () => { const summaryMarker = { ...marker, diff --git a/apps/desktop/src/stt/capture-lifecycle-storage.ts b/apps/desktop/src/stt/capture-lifecycle-storage.ts index a8caa960101..400f911ccb6 100644 --- a/apps/desktop/src/stt/capture-lifecycle-storage.ts +++ b/apps/desktop/src/stt/capture-lifecycle-storage.ts @@ -12,6 +12,9 @@ export type CaptureLifecycleMarker = { createdAt: string; audioOffsetMs: number; preserveExistingTranscript: boolean; + automatic?: boolean; + preserveExistingAudio?: boolean; + initialTitle?: string; ownerUserId: string; memo: string; provider?: string; @@ -154,6 +157,15 @@ function parseCaptureLifecycleMarker( createdAt: parsed.createdAt, audioOffsetMs: Math.max(0, parsed.audioOffsetMs), preserveExistingTranscript: parsed.preserveExistingTranscript, + ...(typeof parsed.automatic === "boolean" + ? { automatic: parsed.automatic } + : {}), + ...(typeof parsed.preserveExistingAudio === "boolean" + ? { preserveExistingAudio: parsed.preserveExistingAudio } + : {}), + ...(typeof parsed.initialTitle === "string" + ? { initialTitle: parsed.initialTitle } + : {}), ownerUserId: parsed.ownerUserId, memo: parsed.memo, ...(parsed.phase === "capturing" || parsed.phase === "finalizing" diff --git a/apps/desktop/src/stt/capture-lifecycle.ts b/apps/desktop/src/stt/capture-lifecycle.ts index 803994ce129..0b4b6c3eaf0 100644 --- a/apps/desktop/src/stt/capture-lifecycle.ts +++ b/apps/desktop/src/stt/capture-lifecycle.ts @@ -5,6 +5,7 @@ import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync"; import { sonnerToast } from "@anlg/ui/components/ui/toast"; import { useListener } from "./contexts"; +import { discardEmptyAutomaticCapture } from "./empty-automatic-capture"; import { cancelMeetingRecordingDisclosure } from "./meeting-disclosure"; import { persistTranscriptWrite } from "./persist-retry"; import { createTranscriptPersistenceWorker } from "./transcript-persistence-worker"; @@ -204,7 +205,24 @@ export function useCaptureLifecycle(sessionId: string) { ); const createCaptureLifecycle = useCallback( - (recoveredMarker?: CaptureLifecycleMarker) => { + ( + recoveredMarker?: CaptureLifecycleMarker, + startedAutomatically = false, + ) => { + const automatic = recoveredMarker + ? recoveredMarker.automatic === true + : startedAutomatically; + const initialTitle = recoveredMarker + ? recoveredMarker.initialTitle + : session?.title; + const existingAudioPromise = recoveredMarker + ? Promise.resolve(recoveredMarker.preserveExistingAudio ?? true) + : automatic + ? fsSyncCommands + .audioExist(sessionId) + .then((result) => (result.status === "ok" ? result.data : true)) + .catch(() => true) + : Promise.resolve(true); const transcriptId = recoveredMarker?.transcriptId ?? id(); let transcriptCreated: boolean | null = recoveredMarker ? null : false; let transcriptTouched = false; @@ -401,6 +419,9 @@ export function useCaptureLifecycle(sessionId: string) { createdAt, audioOffsetMs: await existingAudioDurationPromise, preserveExistingTranscript, + automatic, + preserveExistingAudio: await existingAudioPromise, + initialTitle, ownerUserId, memo: memoMd, ...(provider ? { provider } : {}), @@ -478,6 +499,32 @@ export function useCaptureLifecycle(sessionId: string) { }; cancelMeetingRecordingDisclosure(sessionId); await stopMeetingChatTasks(); + await transcriptPersistence.flush(); + if ( + details.audioPath && + (await discardEmptyAutomaticCapture({ + sessionId, + automatic, + preserveExistingAudio: await existingAudioPromise, + preserveExistingTranscript, + initialTitle, + transcriptTouched, + transcriptionComplete: + (!details.requestedLiveTranscription || + details.liveTranscriptionActive) && + !details.needsBatchRepair && + !transcriptWriteError, + })) + ) { + await clearCaptureLifecycleMarker(sessionId, transcriptId); + recoveryPending = false; + recoveryStateCleared = true; + return; + } + trackSessionCompletion( + details, + recoveredMarker ? "recovered_capture_stopped" : "capture_stopped", + ); if (details.audioPath) { try { await enqueueSessionAudioOperation(sessionId, () => @@ -487,7 +534,6 @@ export function useCaptureLifecycle(sessionId: string) { console.error("[listener] failed to catalog recorded audio", error); } } - await transcriptPersistence.flush(); transcriptCreated ??= await transcriptExists(transcriptId); const useLocalBatchForSpeakerDiarization = shouldUseLocalBatchForSpeakerDiarization(); @@ -844,10 +890,6 @@ export function useCaptureLifecycle(sessionId: string) { } }; const onStopped: OnStoppedCallback = (_sessionId, details) => { - trackSessionCompletion( - details, - recoveredMarker ? "recovered_capture_stopped" : "capture_stopped", - ); recoveryPending = false; markExpectedPostStopBatch(details); return finalizeStopped(details, true); @@ -867,7 +909,6 @@ export function useCaptureLifecycle(sessionId: string) { } }; const recoverStopped: OnStoppedCallback = (_sessionId, details) => { - trackSessionCompletion(details, "recovered_capture_stopped"); markExpectedPostStopBatch(details); return finalizeStopped(details, false); }; @@ -887,7 +928,10 @@ export function useCaptureLifecycle(sessionId: string) { handlePersist, onStopped, recoverStopped, - ready: existingAudioDurationPromise.then(() => undefined), + ready: Promise.all([ + existingAudioDurationPromise, + existingAudioPromise, + ]).then(() => undefined), persistMarker: async () => { await persistTranscriptWrite(async () => { await saveCaptureLifecycleMarker(await marker()); diff --git a/apps/desktop/src/stt/empty-automatic-capture.test.ts b/apps/desktop/src/stt/empty-automatic-capture.test.ts new file mode 100644 index 00000000000..fafabbd9844 --- /dev/null +++ b/apps/desktop/src/stt/empty-automatic-capture.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +import { discardEmptyAutomaticCapture } from "./empty-automatic-capture"; + +const mocks = vi.hoisted(() => ({ + speech: vi.fn(), + remove: vi.fn(), + execute: vi.fn(), + empty: vi.fn(), + flush: vi.fn(), +})); +vi.mock("@anlg/plugin-fs-sync", () => ({ + commands: { + audioHasSpeech: mocks.speech, + audioDelete: mocks.remove, + }, +})); +vi.mock("~/db", () => ({ liveQueryClient: { execute: mocks.execute } })); +vi.mock("~/session/queries", () => ({ isSessionEmpty: mocks.empty })); +vi.mock("~/session-sharing/editor-activity", () => ({ + flushCanonicalSessionEditorChanges: mocks.flush, +})); +vi.mock("~/session/audio-operations", () => ({ + enqueueSessionAudioOperation: (_: string, operation: () => unknown) => + operation(), +})); + +const input = { + sessionId: "meeting", + automatic: true, + preserveExistingAudio: false, + preserveExistingTranscript: false, + initialTitle: "Standup", + transcriptTouched: false, + transcriptionComplete: true, +}; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.speech.mockResolvedValue({ status: "ok", data: false }); + mocks.remove.mockResolvedValue({ status: "ok", data: true }); + mocks.execute.mockResolvedValue([{ title: "Standup", has_attachments: 0 }]); + mocks.empty.mockResolvedValue(true); + mocks.flush.mockResolvedValue(undefined); +}); + +it.each([0, false])( + "discards silent automatic audio when the attachment flag is %j", + async (hasAttachments) => { + mocks.execute.mockResolvedValue([ + { title: "Standup", has_attachments: hasAttachments }, + ]); + expect(await discardEmptyAutomaticCapture(input)).toBe(true); + expect(mocks.remove).toHaveBeenCalledWith("meeting"); + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(mocks.execute.mock.calls[0][0]).toMatch(/^SELECT /); + }, +); + +it.each([ + { automatic: false }, + { preserveExistingAudio: true }, + { preserveExistingTranscript: true }, + { transcriptTouched: true }, + { transcriptionComplete: false }, + { initialTitle: undefined }, +])("keeps captures that cannot safely be discarded: %j", async (override) => { + expect(await discardEmptyAutomaticCapture({ ...input, ...override })).toBe( + false, + ); + expect(mocks.speech).not.toHaveBeenCalled(); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it.each([ + { status: "ok", data: true }, + { status: "error", error: "decoder failed" }, +])("keeps speech and uncertain analysis: %j", async (result) => { + mocks.speech.mockResolvedValue(result); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it("keeps recordings when the user adds notes", async () => { + mocks.empty.mockResolvedValue(false); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it.each([1, true, null, undefined, "0"])( + "keeps audio when the attachment flag is present or uncertain: %j", + async (hasAttachments) => { + mocks.execute.mockResolvedValue([ + { title: "Standup", has_attachments: hasAttachments }, + ]); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); + }, +); + +it("flushes edits made during audio analysis before deciding whether to discard", async () => { + mocks.speech.mockImplementation(async () => { + mocks.flush.mockImplementation(async () => { + mocks.empty.mockResolvedValue(false); + }); + return { status: "ok", data: false }; + }); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it.each([{ rows: [{ title: "Renamed by the user" }] }, { rows: [] }])( + "keeps renamed or deleted sessions", + async ({ rows }) => { + mocks.execute.mockResolvedValue(rows); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); + }, +); diff --git a/apps/desktop/src/stt/empty-automatic-capture.ts b/apps/desktop/src/stt/empty-automatic-capture.ts new file mode 100644 index 00000000000..248834c6299 --- /dev/null +++ b/apps/desktop/src/stt/empty-automatic-capture.ts @@ -0,0 +1,72 @@ +import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync"; + +import { liveQueryClient } from "~/db"; +import { flushCanonicalSessionEditorChanges } from "~/session-sharing/editor-activity"; +import { enqueueSessionAudioOperation } from "~/session/audio-operations"; +import { isSessionEmpty } from "~/session/queries"; + +export async function discardEmptyAutomaticCapture({ + sessionId, + automatic, + preserveExistingAudio, + preserveExistingTranscript, + initialTitle, + transcriptTouched, + transcriptionComplete, +}: { + sessionId: string; + automatic: boolean; + preserveExistingAudio: boolean; + preserveExistingTranscript: boolean; + initialTitle: string | undefined; + transcriptTouched: boolean; + transcriptionComplete: boolean; +}): Promise { + if ( + !automatic || + preserveExistingAudio || + preserveExistingTranscript || + initialTitle === undefined || + transcriptTouched || + !transcriptionComplete + ) { + return false; + } + try { + await flushCanonicalSessionEditorChanges(sessionId); + return await enqueueSessionAudioOperation(sessionId, async () => { + const speech = await fsSyncCommands.audioHasSpeech(sessionId); + if (speech.status !== "ok" || speech.data) return false; + await flushCanonicalSessionEditorChanges(sessionId); + const [session] = await liveQueryClient.execute<{ + title: string; + has_attachments: boolean | number; + }>( + `SELECT title, EXISTS ( + SELECT 1 FROM session_attachments + WHERE session_id = sessions.id AND deleted_at IS NULL + ) AS has_attachments + FROM sessions WHERE id = ? AND deleted_at IS NULL`, + [sessionId], + ); + if ( + !session || + session.title !== initialTitle || + (session.has_attachments !== 0 && session.has_attachments !== false) || + !(await isSessionEmpty(sessionId)) + ) { + return false; + } + // Only remove this device's un-catalogued audio. The calendar note and + // anything another device has contributed stay in the shared database. + const result = await fsSyncCommands.audioDelete(sessionId); + return result.status === "ok" && result.data; + }); + } catch (error) { + console.warn( + "[listener] keeping automatic capture after an inconclusive activity check", + error, + ); + return false; + } +} diff --git a/apps/desktop/src/stt/scheduled-session-auto-start.tsx b/apps/desktop/src/stt/scheduled-session-auto-start.tsx index b40fdb1466f..849dd148cd7 100644 --- a/apps/desktop/src/stt/scheduled-session-auto-start.tsx +++ b/apps/desktop/src/stt/scheduled-session-auto-start.tsx @@ -65,7 +65,10 @@ function PendingScheduledSessionAutoStart({ } function ReadyScheduledSessionAutoStart({ sessionId }: { sessionId: string }) { - const { connectionReady, startListening } = useStartListeningState(sessionId); + const { connectionReady, startListening } = useStartListeningState( + sessionId, + { automatic: true }, + ); const attemptedRef = useRef(false); useMountEffect(() => { diff --git a/apps/desktop/src/stt/useStartListening.test.ts b/apps/desktop/src/stt/useStartListening.test.ts index aba3e5803f4..1b61d2c49bd 100644 --- a/apps/desktop/src/stt/useStartListening.test.ts +++ b/apps/desktop/src/stt/useStartListening.test.ts @@ -13,6 +13,7 @@ import { sendMeetingRecordingDisclosure, useResumeListeningLifecycle, useStartListening, + useStartListeningState, } from "./useStartListening"; import { enqueueSessionAudioOperation } from "~/session/audio-operations"; @@ -70,7 +71,9 @@ const { flushCanonicalSessionEditorChangesMock, idMock, openNewMock, + emptyCaptureMock, } = vi.hoisted(() => ({ + emptyCaptureMock: vi.fn(), queueAutoEnhanceMock: vi.fn(), queueAutoEnhanceIfSummaryEmptyMock: vi.fn(), resetEnhanceTasksMock: vi.fn(), @@ -156,8 +159,13 @@ vi.mock("./meeting-consent-store", () => ({ persistParticipantConsent: vi.fn(async () => {}), })); +vi.mock("./empty-automatic-capture", () => ({ + discardEmptyAutomaticCapture: emptyCaptureMock, +})); + vi.mock("@anlg/plugin-fs-sync", () => ({ commands: { + audioExist: vi.fn().mockResolvedValue({ status: "ok", data: false }), audioPath: audioPathMock, audioSourceMetadata: audioSourceMetadataMock, }, @@ -438,6 +446,7 @@ describe("getPostCaptureAction", () => { describe("useStartListening", () => { beforeEach(() => { vi.clearAllMocks(); + emptyCaptureMock.mockResolvedValue(false); idMock.mockReturnValue("generated-id"); getEnhancerServiceMock.mockImplementation(() => ({ @@ -900,6 +909,67 @@ describe("useStartListening", () => { expect(saveCaptureLifecycleMarkerMock).toHaveBeenCalledBefore(startMock); }); + test("manual recording stays manual while a scheduled start is waiting for the same note", async () => { + renderHook(() => useStartListeningState("session-1", { automatic: true })); + const { result } = renderHook(() => useStartListening("session-1")); + await act(async () => { + await result.current(); + }); + expect(saveCaptureLifecycleMarkerMock).toHaveBeenCalledWith( + expect.objectContaining({ + automatic: false, + preserveExistingAudio: true, + }), + ); + }); + + test.each([true, false])( + "discards an empty scheduled capture before publishing audio or running batch transcription (live=%s)", + async (live) => { + emptyCaptureMock.mockResolvedValue(true); + useSessionMock.mockReturnValue({ + id: "session-1", + user_id: "user-1", + raw_md: "", + title: "Standup", + }); + const { result } = renderHook( + () => + useStartListeningState("session-1", { automatic: true }) + .startListening, + ); + await act(async () => { + await result.current(); + }); + const onStopped = startMock.mock.calls[0]?.[1]?.onStopped; + await act(async () => { + await onStopped("session-1", { + durationSeconds: 42, + audioPath: "/tmp/session.wav", + requestedLiveTranscription: live, + liveTranscriptionActive: live, + needsBatchRepair: false, + }); + }); + expect(emptyCaptureMock).toHaveBeenCalledWith( + expect.objectContaining({ + automatic: true, + preserveExistingAudio: false, + initialTitle: "Standup", + transcriptionComplete: true, + }), + ); + expect(catalogLocalSessionAudioMock).not.toHaveBeenCalled(); + expect(runBatchMock).not.toHaveBeenCalled(); + expect(requestMainAutoEnhanceMock).not.toHaveBeenCalled(); + expect(clearCaptureLifecycleMarkerMock).toHaveBeenCalledWith( + "session-1", + "generated-id", + ); + expect(endCloudsyncActivityMock).toHaveBeenCalled(); + }, + ); + test("runs batch transcription after record-only capture stops", async () => { const { result } = renderHook(() => useStartListening("session-1")); diff --git a/apps/desktop/src/stt/useStartListening.ts b/apps/desktop/src/stt/useStartListening.ts index a373b62f7a7..659ed3e7b41 100644 --- a/apps/desktop/src/stt/useStartListening.ts +++ b/apps/desktop/src/stt/useStartListening.ts @@ -39,7 +39,10 @@ export function useStartListening(sessionId: string) { return useStartListeningState(sessionId).startListening; } -export function useStartListeningState(sessionId: string) { +export function useStartListeningState( + sessionId: string, + { automatic = false }: { automatic?: boolean } = {}, +) { const { conn, connectionReady, @@ -71,7 +74,7 @@ export function useStartListeningState(sessionId: string) { return; } await stopMeetingChatTasks(); - const lifecycle = createCaptureLifecycle(); + const lifecycle = createCaptureLifecycle(undefined, automatic); // A fresh note or a just-focused window starts listening right as a sync // round begins; waiting for that round to yield made the start feel slow // and sometimes refused to record at all. @@ -266,6 +269,7 @@ export function useStartListeningState(sessionId: string) { : {}), }); }, [ + automatic, aiLanguage, canStartLiveSession, conn, diff --git a/crates/db-app/migrations/20260907120000_session_documents_content_version.sql b/crates/db-app/migrations/20260907120000_session_documents_content_version.sql new file mode 100644 index 00000000000..dcbf76cba95 --- /dev/null +++ b/crates/db-app/migrations/20260907120000_session_documents_content_version.sql @@ -0,0 +1,79 @@ +ALTER TABLE session_documents ADD COLUMN content_version TEXT NOT NULL DEFAULT ''; + +-- This identifies content the user has seen when deleting. Replicated fields +-- keep their originating version; tombstones and transfer metadata do not edit it. +CREATE TRIGGER session_documents_content_version_insert +AFTER INSERT ON session_documents +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_documents SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + +CREATE TRIGGER session_documents_content_version_update +AFTER UPDATE OF body, body_format ON session_documents +WHEN (NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format) + AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_documents SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_session_documents_update; +CREATE TRIGGER IF NOT EXISTS search_index_session_documents_update +AFTER UPDATE ON session_documents +WHEN NEW.content_version IS OLD.content_version OR NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', OLD.session_id + WHERE OLD.session_id <> '' + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.session_id + WHERE NEW.session_id <> '' AND NEW.session_id <> OLD.session_id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_session_documents_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_session_documents_update +AFTER UPDATE ON session_documents +WHEN NEW.content_version IS OLD.content_version OR NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'session_documents', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'session_documents' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'session_documents', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'session_documents' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120100_transcripts_content_version.sql b/crates/db-app/migrations/20260907120100_transcripts_content_version.sql new file mode 100644 index 00000000000..ec450324bfb --- /dev/null +++ b/crates/db-app/migrations/20260907120100_transcripts_content_version.sql @@ -0,0 +1,79 @@ +ALTER TABLE transcripts ADD COLUMN content_version TEXT NOT NULL DEFAULT ''; + +-- This identifies content the user has seen when deleting. Replicated fields +-- keep their originating version; tombstones and transfer metadata do not edit it. +CREATE TRIGGER transcripts_content_version_insert +AFTER INSERT ON transcripts +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE transcripts SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + +CREATE TRIGGER transcripts_content_version_update +AFTER UPDATE OF words_json, content_revision ON transcripts +WHEN (NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision) + AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE transcripts SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_transcripts_update; +CREATE TRIGGER IF NOT EXISTS search_index_transcripts_update +AFTER UPDATE ON transcripts +WHEN NEW.content_version IS OLD.content_version OR NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', OLD.session_id + WHERE OLD.session_id <> '' + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.session_id + WHERE NEW.session_id <> '' AND NEW.session_id <> OLD.session_id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_transcripts_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_transcripts_update +AFTER UPDATE ON transcripts +WHEN NEW.content_version IS OLD.content_version OR NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'transcripts', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'transcripts' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'transcripts', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'transcripts' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120200_session_content_observations.sql b/crates/db-app/migrations/20260907120200_session_content_observations.sql new file mode 100644 index 00000000000..f5fb1f29a94 --- /dev/null +++ b/crates/db-app/migrations/20260907120200_session_content_observations.sql @@ -0,0 +1,22 @@ +CREATE VIEW session_content_observations AS +SELECT workspace_id, id AS session_id, 'title:' || id AS content_id, + title AS content_version, deleted_at +FROM sessions WHERE trim(title) <> '' +UNION ALL +SELECT workspace_id, session_id, 'document:' || id, content_version, deleted_at +FROM session_documents +WHERE trim(body) <> '' AND ( + body_format <> 'prosemirror_json' OR NOT json_valid(body) OR EXISTS ( + SELECT 1 FROM json_tree(CASE WHEN json_valid(body) THEN body ELSE '{}' END) + WHERE (key = 'text' AND trim(CAST(atom AS TEXT)) <> '') + OR (key = 'type' AND atom NOT IN ('doc', 'paragraph', 'text', 'hardBreak')) + ) +) +UNION ALL +SELECT workspace_id, session_id, 'transcript:' || id, content_version, deleted_at +FROM transcripts +WHERE trim(words_json) NOT IN ('', '[]') +UNION ALL +SELECT workspace_id, session_id, 'attachment:' || id, + json_array(sha256, size_bytes, relative_path, source_type, source_id), deleted_at +FROM session_attachments WHERE size_bytes > 0; diff --git a/crates/db-app/migrations/20260907120300_session_deletion_context.sql b/crates/db-app/migrations/20260907120300_session_deletion_context.sql new file mode 100644 index 00000000000..09cbd77b936 --- /dev/null +++ b/crates/db-app/migrations/20260907120300_session_deletion_context.sql @@ -0,0 +1,73 @@ +ALTER TABLE sessions ADD COLUMN deletion_context TEXT NOT NULL DEFAULT ''; + +-- Capture what this device deleted, including children tombstoned earlier in +-- the same transaction. Incoming deletions carry the sender's observations. +CREATE TRIGGER sessions_deletion_context +AFTER UPDATE OF deleted_at ON sessions +WHEN NEW.deleted_at IS NOT OLD.deleted_at AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'sessions' AND row_id = NEW.id +) +BEGIN + UPDATE sessions SET deletion_context = CASE WHEN NEW.deleted_at IS NULL THEN '' + ELSE json_object('version', 1, 'deletedAt', NEW.deleted_at, 'observed', json(( + SELECT json_group_object(content_id, content_version) + FROM session_content_observations + WHERE session_id = NEW.id AND workspace_id = NEW.workspace_id + AND (deleted_at IS NULL OR deleted_at = NEW.deleted_at) + ))) END + WHERE id = NEW.id; +END; + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_sessions_update; +CREATE TRIGGER IF NOT EXISTS search_index_sessions_update +AFTER UPDATE ON sessions +WHEN NEW.deletion_context IS OLD.deletion_context OR NEW.deleted_at IS NOT OLD.deleted_at OR NEW.title IS NOT OLD.title OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + VALUES ('session', OLD.id) + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.id + WHERE NEW.id <> OLD.id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_sessions_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_sessions_update +AFTER UPDATE ON sessions +WHEN NEW.deletion_context IS OLD.deletion_context OR NEW.deleted_at IS NOT OLD.deleted_at OR NEW.title IS NOT OLD.title OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'sessions', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'sessions' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'sessions', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'sessions' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql b/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql new file mode 100644 index 00000000000..d109a287965 --- /dev/null +++ b/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql @@ -0,0 +1,73 @@ +-- Local content writes must restore a stale deletion before returning, including +-- while offline. Keep one restoration body for every content table. +CREATE VIEW session_deletion_conflicts AS +SELECT s.workspace_id, s.id AS session_id, s.deleted_at +FROM sessions AS s +WHERE s.deleted_at IS NOT NULL + AND CASE WHEN json_valid(s.deletion_context) THEN + json_extract(s.deletion_context, '$.version') = 1 + AND json_extract(s.deletion_context, '$.deletedAt') = s.deleted_at + AND json_type(s.deletion_context, '$.observed') = 'object' + AND EXISTS ( + SELECT 1 FROM session_content_observations AS content + WHERE content.workspace_id = s.workspace_id AND content.session_id = s.id + AND (content.deleted_at IS NULL OR content.deleted_at = s.deleted_at) + AND NOT EXISTS ( + SELECT 1 FROM json_each(s.deletion_context, '$.observed') AS observed + WHERE observed.key = content.content_id AND observed.value = content.content_version + ) + ) + ELSE 0 END; + +CREATE TRIGGER session_deletion_conflicts_restore +INSTEAD OF UPDATE OF deleted_at ON session_deletion_conflicts +WHEN NEW.deleted_at IS NULL AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id AND table_name = 'sessions' AND row_id = OLD.session_id +) +BEGIN + -- Preserve the deletion observation so later incoming child tombstones can + -- still be reconciled against the same intent. + INSERT INTO e2ee_apply_guard (workspace_id, table_name, row_id) + VALUES (OLD.workspace_id, 'sessions', OLD.session_id); + + UPDATE sessions SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND id = OLD.session_id; + + UPDATE session_documents SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE transcripts SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_attachments SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_participants SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_tags SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE action_items SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE entity_mentions SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND deleted_at = OLD.deleted_at + AND ((source_type = 'session' AND source_id = OLD.session_id) + OR (target_type = 'session' AND target_id = OLD.session_id)); + + DELETE FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id AND table_name = 'sessions' AND row_id = OLD.session_id; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + VALUES (OLD.workspace_id, 'sessions', OLD.session_id) + ON CONFLICT (workspace_id, table_name, row_id) + DO UPDATE SET generation = generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql b/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql new file mode 100644 index 00000000000..b29dbd0f093 --- /dev/null +++ b/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql @@ -0,0 +1,13 @@ +-- Content versions are assigned after both local inserts and content edits. +-- Waiting for that write avoids depending on sibling trigger execution order. +CREATE TRIGGER session_documents_restore_session +AFTER UPDATE OF content_version ON session_documents +WHEN NEW.content_version IS NOT OLD.content_version AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql b/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql new file mode 100644 index 00000000000..e40a7e054d6 --- /dev/null +++ b/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql @@ -0,0 +1,11 @@ +CREATE TRIGGER transcripts_restore_session +AFTER UPDATE OF content_version ON transcripts +WHEN NEW.content_version IS NOT OLD.content_version AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql b/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql new file mode 100644 index 00000000000..24e02f1573a --- /dev/null +++ b/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql @@ -0,0 +1,22 @@ +CREATE TRIGGER session_attachments_restore_session_insert +AFTER INSERT ON session_attachments +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_attachments' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + +CREATE TRIGGER session_attachments_restore_session_update +AFTER UPDATE OF sha256, size_bytes, relative_path, source_type, source_id ON session_attachments +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_attachments' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql b/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql new file mode 100644 index 00000000000..047988aa920 --- /dev/null +++ b/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql @@ -0,0 +1,11 @@ +CREATE TRIGGER sessions_restore_after_title_edit +AFTER UPDATE OF title ON sessions +WHEN NEW.title IS NOT OLD.title AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'sessions' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.id; +END; + diff --git a/crates/db-app/src/e2ee/replica_apply.rs b/crates/db-app/src/e2ee/replica_apply.rs index 8df7bd24633..bc0d82f13f8 100644 --- a/crates/db-app/src/e2ee/replica_apply.rs +++ b/crates/db-app/src/e2ee/replica_apply.rs @@ -680,6 +680,14 @@ pub(super) async fn apply_e2ee_replica_changes_inner( remove_apply_guard(&mut transaction, &workspace_id, &table, &row_id).await?; rollback_if_cancelled!(transaction, is_cancelled); pending.retain(|(record_id, _)| !deferred_pending_ids.contains(record_id)); + crate::session_deletion::reconcile_session_deletion( + &mut transaction, + &workspace_id, + &table, + &row_id, + ) + .await?; + rollback_if_cancelled!(transaction, is_cancelled); delete_reconciled_replica_entries_in_transaction(&mut transaction, &pending).await?; rollback_if_cancelled!(transaction, is_cancelled); commit_e2ee_apply_transaction(transaction, is_cancelled).await?; diff --git a/crates/db-app/src/e2ee/tests/mod.rs b/crates/db-app/src/e2ee/tests/mod.rs index 12a7e121d15..dd198909168 100644 --- a/crates/db-app/src/e2ee/tests/mod.rs +++ b/crates/db-app/src/e2ee/tests/mod.rs @@ -41,5 +41,6 @@ mod dirty_rows; mod replica_apply; mod revision_conflicts; mod roundtrip; +mod session_deletion; mod snapshots; mod witness_queue; diff --git a/crates/db-app/src/e2ee/tests/session_deletion.rs b/crates/db-app/src/e2ee/tests/session_deletion.rs new file mode 100644 index 00000000000..6a85007b87d --- /dev/null +++ b/crates/db-app/src/e2ee/tests/session_deletion.rs @@ -0,0 +1,418 @@ +use super::*; + +async fn seed(pool: &SqlitePool) { + sqlx::raw_sql( + "INSERT INTO sessions (id, workspace_id, title) VALUES ('meeting', 'workspace-a', 'Standup'); + INSERT INTO session_documents (id, workspace_id, session_id) + VALUES ('meeting', 'workspace-a', 'meeting');", + ) + .execute(pool) + .await + .unwrap(); +} + +async fn delete(pool: &SqlitePool, tombstone: &str) { + let mut tx = pool.begin().await.unwrap(); + for table in ["session_documents", "transcripts", "session_attachments"] { + let sql = format!( + "UPDATE {table} SET deleted_at = ?, updated_at = ? + WHERE session_id = 'meeting' AND deleted_at IS NULL" + ); + sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(tombstone) + .bind(tombstone) + .execute(&mut *tx) + .await + .unwrap(); + } + sqlx::query("UPDATE sessions SET deleted_at = ?, updated_at = ? WHERE id = 'meeting'") + .bind(tombstone) + .bind(tombstone) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +async fn sync(source: &SqlitePool, target: &SqlitePool) { + let workspace_keys = keys("workspace-a"); + encrypt_e2ee_replica_changes(source, &workspace_keys) + .await + .unwrap(); + copy_replica(source, target).await; + for _ in 0..12 { + encrypt_e2ee_replica_changes(target, &workspace_keys) + .await + .unwrap(); + let stats = apply_e2ee_replica_changes(target, &workspace_keys) + .await + .unwrap(); + if !stats.remaining_replica_changes { + return; + } + } + panic!("replica did not settle"); +} + +async fn assert_deleted(pool: &SqlitePool, deleted: bool) { + let actual: bool = + sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM sessions WHERE id = 'meeting'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(actual, deleted); +} + +#[tokio::test] +async fn unseen_recording_survives_stale_deletion_in_both_sync_orders() { + for delete_arrives_first in [true, false] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('capture-b', 'workspace-a', 'meeting', '[{\"text\":\"Useful meeting\"}]')", + ) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + if delete_arrives_first { + sync(a.pool(), b.pool()).await; + } else { + sync(b.pool(), a.pool()).await; + } + for _ in 0..3 { + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + } + for db in [&a, &b] { + assert_deleted(db.pool(), false).await; + let words: String = sqlx::query_scalar( + "SELECT words_json FROM transcripts WHERE id = 'capture-b' AND deleted_at IS NULL", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(words.contains("Useful meeting")); + } + // Once the populated note has been seen, intentional deletion must stick. + delete(a.pool(), "2100-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; + let fresh = test_db().await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; + } +} + +#[tokio::test] +async fn unseen_note_edits_and_audio_without_transcription_survive() { + for change in [ + "UPDATE session_documents SET body_format = 'markdown', body = 'User notes' WHERE id = 'meeting'", + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('audio-b', 'workspace-a', 'meeting', 1234, 'recorded-on-mobile')", + ] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql(sqlx::AssertSqlSafe(change)) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; + assert_deleted(b.pool(), false).await; + } +} + +#[tokio::test] +async fn empty_rows_and_metadata_do_not_resurrect_a_deleted_note() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql("INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('empty', 'workspace-a', 'meeting'); + UPDATE sessions SET ended_at = '2099-02-01' WHERE id = 'meeting'; + UPDATE session_documents SET body = '{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}' WHERE id = 'meeting';") + .execute(b.pool()).await.unwrap(); + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; +} + +#[tokio::test] +async fn local_content_after_a_synced_deletion_restores_without_another_sync() { + for change in [ + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Recording finished\"}]')", + "UPDATE transcripts SET words_json = '[{\"text\":\"Recording continued\"}]' WHERE id = 'capture'", + "UPDATE session_documents SET body_format = 'markdown', body = 'Local notes' WHERE id = 'meeting'", + "INSERT INTO session_documents (id, workspace_id, session_id, body_format, body) + VALUES ('new-notes', 'workspace-a', 'meeting', 'markdown', 'New notes')", + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('new-audio', 'workspace-a', 'meeting', 1234, 'new-recording')", + "UPDATE session_attachments SET size_bytes = 5678, sha256 = 'replacement' WHERE id = 'audio'", + "UPDATE sessions SET title = 'Edited meeting' WHERE id = 'meeting'", + ] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sqlx::raw_sql( + "INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('capture', 'workspace-a', 'meeting'); + INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('audio', 'workspace-a', 'meeting', 100, 'original');", + ) + .execute(a.pool()).await.unwrap(); + sync(a.pool(), b.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + assert_deleted(b.pool(), true).await; + + sqlx::raw_sql(sqlx::AssertSqlSafe(change)) + .execute(b.pool()) + .await + .unwrap(); + assert_deleted(b.pool(), false).await; + let (context, live_documents): (String, i64) = sqlx::query_as( + "SELECT deletion_context, (SELECT count(*) FROM session_documents WHERE id = 'meeting' AND deleted_at IS NULL) + FROM sessions WHERE id = 'meeting'", + ).fetch_one(b.pool()).await.unwrap(); + assert!(!context.is_empty()); + assert_eq!(live_documents, 1); + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; + delete(b.pool(), "2100-01-01").await; + assert_deleted(b.pool(), true).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + } +} + +#[tokio::test] +async fn local_empty_writes_after_a_synced_deletion_do_not_restore() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql( + "INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('empty', 'workspace-a', 'meeting'); + UPDATE transcripts SET content_revision = 1 WHERE id = 'empty'; + UPDATE session_documents SET body = '{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}' WHERE id = 'meeting'; + INSERT INTO session_attachments (id, workspace_id, session_id) VALUES ('empty-audio', 'workspace-a', 'meeting'); + UPDATE sessions SET ended_at = '2099-02-01' WHERE id = 'meeting';", + ).execute(b.pool()).await.unwrap(); + assert_deleted(b.pool(), true).await; +} + +#[tokio::test] +async fn local_content_preserves_unrecognized_deletion_contexts() { + for context in [ + "", + "invalid-json", + r#"{"version":2,"deletedAt":"2099-01-01","observed":{}}"#, + r#"{"version":1,"deletedAt":"2099-01-01","observed":[]}"#, + ] { + let db = test_db().await; + seed(db.pool()).await; + delete(db.pool(), "2099-01-01").await; + sqlx::query("UPDATE sessions SET deletion_context = ? WHERE id = 'meeting'") + .bind(context) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("UPDATE session_documents SET body_format = 'markdown', body = 'New notes' WHERE id = 'meeting'") + .execute(db.pool()).await.unwrap(); + assert_deleted(db.pool(), true).await; + } +} + +#[tokio::test] +async fn local_undo_clears_the_deletion_observation() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sqlx::query("UPDATE sessions SET deleted_at = NULL WHERE id = 'meeting'") + .execute(a.pool()) + .await + .unwrap(); + sync(a.pool(), b.pool()).await; + assert_deleted(b.pool(), false).await; +} + +#[tokio::test] +async fn replacement_audio_survives_deletion_of_the_older_recording() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sqlx::query( + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('session-audio:meeting', 'workspace-a', 'meeting', 100, 'empty-device-a')", + ) + .execute(a.pool()) + .await + .unwrap(); + sync(a.pool(), b.pool()).await; + sqlx::query( + "UPDATE session_attachments SET size_bytes = 12345, sha256 = 'useful-device-b' + WHERE id = 'session-audio:meeting'", + ) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + let context: String = + sqlx::query_scalar("SELECT deletion_context FROM sessions WHERE id = 'meeting'") + .fetch_one(a.pool()) + .await + .unwrap(); + let context: serde_json::Value = serde_json::from_str(&context).unwrap(); + assert_eq!( + context["observed"]["attachment:session-audio:meeting"], + serde_json::json!("[\"empty-device-a\",100,\"\",\"\",\"\"]") + ); + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + for db in [&a, &b] { + assert_deleted(db.pool(), false).await; + let hash: String = sqlx::query_scalar( + "SELECT sha256 FROM session_attachments + WHERE id = 'session-audio:meeting' AND deleted_at IS NULL", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(hash, "useful-device-b"); + } + delete(a.pool(), "2100-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; + let fresh = test_db().await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; +} + +#[tokio::test] +async fn recording_arriving_after_the_deleted_parent_restores_the_note() { + let a = test_db().await; + let b = test_db().await; + let fresh = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Late offline recording\"}]')", + ) + .execute(b.pool()) + .await + .unwrap(); + sync(b.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), false).await; + sync(fresh.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; +} + +#[tokio::test] +async fn late_related_tombstones_follow_the_restored_note() { + let db = test_db().await; + seed(db.pool()).await; + delete(db.pool(), "2099-01-01").await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Offline recording\"}]')", + ) + .execute(db.pool()) + .await + .unwrap(); + for (table, insert) in [ + ( + "session_tags", + "INSERT INTO session_tags (id, workspace_id, session_id, deleted_at) + VALUES ('related', 'workspace-a', 'meeting', '2099-01-01')", + ), + ( + "entity_mentions", + "INSERT INTO entity_mentions (id, workspace_id, target_type, target_id, deleted_at) + VALUES ('related', 'workspace-a', 'session', 'meeting', '2099-01-01')", + ), + ] { + let mut tx = db.pool().begin().await.unwrap(); + sqlx::raw_sql(sqlx::AssertSqlSafe(insert)) + .execute(&mut *tx) + .await + .unwrap(); + crate::session_deletion::reconcile_session_deletion( + &mut tx, + "workspace-a", + table, + "related", + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + let live: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT deleted_at IS NULL FROM {table} WHERE id = 'related'" + ))) + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(live); + } + assert_deleted(db.pool(), false).await; +} + +#[tokio::test] +async fn upgrade_preserves_existing_content_and_legacy_deletions() { + let db = anlg_db_core::Db::connect_memory_plain().await.unwrap(); + let index = crate::APP_MIGRATION_STEPS + .iter() + .position(|step| step.id == "20260907120000_session_documents_content_version") + .unwrap(); + anlg_db_migrate::migrate( + &db, + anlg_db_migrate::DbSchema { + steps: &crate::APP_MIGRATION_STEPS[..index], + validate_cloudsync_table: crate::cloudsync_alter_guard_required, + }, + ) + .await + .unwrap(); + seed(db.pool()).await; + sqlx::raw_sql("UPDATE session_documents SET body_format = 'markdown', body = 'Existing note'; + INSERT INTO sessions (id, workspace_id, deleted_at) VALUES ('legacy-deleted', 'workspace-a', 'old');") + .execute(db.pool()).await.unwrap(); + crate::prepare_schema(&db).await.unwrap(); + let content: (String, String) = + sqlx::query_as("SELECT body, content_version FROM session_documents WHERE id = 'meeting'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(content, ("Existing note".into(), "".into())); + let legacy: (String, String) = sqlx::query_as( + "SELECT deleted_at, deletion_context FROM sessions WHERE id = 'legacy-deleted'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(legacy, ("old".into(), "".into())); + delete(db.pool(), "2099-01-01").await; + let fresh = test_db().await; + sync(db.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; +} diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs index 34f213585ef..475ca94c98f 100644 --- a/crates/db-app/src/lib.rs +++ b/crates/db-app/src/lib.rs @@ -7,6 +7,7 @@ mod e2ee; mod event_ops; mod event_types; mod legacy_import; +mod session_deletion; mod session_ops; mod session_types; mod template_ops; @@ -424,6 +425,65 @@ pub const APP_MIGRATION_STEPS: &[anlg_db_migrate::MigrationStep] = &[ scope: anlg_db_migrate::MigrationScope::Plain, sql: include_str!("../migrations/20260903120000_voiceprint_exemplars_isolated_mic.sql"), }, + anlg_db_migrate::MigrationStep { + id: "20260907120000_session_documents_content_version", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_documents", + }, + sql: include_str!("../migrations/20260907120000_session_documents_content_version.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120100_transcripts_content_version", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "transcripts", + }, + sql: include_str!("../migrations/20260907120100_transcripts_content_version.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120200_session_content_observations", + scope: anlg_db_migrate::MigrationScope::Plain, + sql: include_str!("../migrations/20260907120200_session_content_observations.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120300_session_deletion_context", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "sessions", + }, + sql: include_str!("../migrations/20260907120300_session_deletion_context.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120400_session_local_content_restoration", + scope: anlg_db_migrate::MigrationScope::Plain, + sql: include_str!("../migrations/20260907120400_session_local_content_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120500_session_documents_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_documents", + }, + sql: include_str!("../migrations/20260907120500_session_documents_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120600_transcripts_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "transcripts", + }, + sql: include_str!("../migrations/20260907120600_transcripts_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120700_session_attachments_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_attachments", + }, + sql: include_str!("../migrations/20260907120700_session_attachments_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120800_sessions_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "sessions", + }, + sql: include_str!("../migrations/20260907120800_sessions_local_restoration.sql"), + }, ]; pub fn schema() -> anlg_db_migrate::DbSchema { diff --git a/crates/db-app/src/session_deletion.rs b/crates/db-app/src/session_deletion.rs new file mode 100644 index 00000000000..da4b676b5d2 --- /dev/null +++ b/crates/db-app/src/session_deletion.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde_json::Value; +use sqlx::{Sqlite, Transaction}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeletionContext { + version: u32, + deleted_at: String, + observed: BTreeMap, +} + +fn observed_content_version(value: Option<&Value>) -> Option { + match value { + Some(Value::String(value)) => Some(value.clone()), + Some(value) => serde_json::to_string(value).ok(), + None => None, + } +} + +pub(crate) async fn reconcile_session_deletion( + transaction: &mut Transaction<'_, Sqlite>, + workspace_id: &str, + table: &str, + row_id: &str, +) -> Result<(), sqlx::Error> { + let session_ids = match table { + "sessions" => vec![row_id.to_owned()], + "session_documents" + | "transcripts" + | "session_attachments" + | "session_participants" + | "session_tags" + | "action_items" => { + let query = format!("SELECT session_id FROM {table} WHERE id = ? AND workspace_id = ?"); + let Some(id) = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .bind(row_id) + .bind(workspace_id) + .fetch_optional(&mut **transaction) + .await? + else { + return Ok(()); + }; + vec![id] + } + "entity_mentions" => { + sqlx::query_scalar::<_, String>( + "SELECT source_id FROM entity_mentions + WHERE id = ?1 AND workspace_id = ?2 AND source_type = 'session' + UNION SELECT target_id FROM entity_mentions + WHERE id = ?1 AND workspace_id = ?2 AND target_type = 'session'", + ) + .bind(row_id) + .bind(workspace_id) + .fetch_all(&mut **transaction) + .await? + } + _ => return Ok(()), + }; + for session_id in session_ids { + reconcile_deleted_session(transaction, workspace_id, &session_id).await?; + } + Ok(()) +} + +async fn reconcile_deleted_session( + transaction: &mut Transaction<'_, Sqlite>, + workspace_id: &str, + session_id: &str, +) -> Result<(), sqlx::Error> { + let Some((context, current_deleted_at)) = sqlx::query_as::<_, (String, Option)>( + "SELECT deletion_context, deleted_at FROM sessions + WHERE id = ? AND workspace_id = ? AND deletion_context <> ''", + ) + .bind(session_id) + .bind(workspace_id) + .fetch_optional(&mut **transaction) + .await? + else { + return Ok(()); + }; + let Ok(context) = serde_json::from_str::(&context) else { + return Ok(()); + }; + if context.version != 1 || context.deleted_at.is_empty() { + return Ok(()); + } + + let content: Vec<(String, String)> = sqlx::query_as( + "SELECT content_id, content_version FROM session_content_observations + WHERE session_id = ? AND workspace_id = ? + AND (deleted_at IS NULL OR deleted_at = ?)", + ) + .bind(session_id) + .bind(workspace_id) + .bind(&context.deleted_at) + .fetch_all(&mut **transaction) + .await?; + let unseen_content = content.iter().any(|(id, version)| { + observed_content_version(context.observed.get(id)).as_deref() != Some(version) + }); + let deleted_at = (!unseen_content).then_some(context.deleted_at.as_str()); + + // Keep the original observation even after restoring the note. Later row + // groups must reach the same result regardless of their delivery order. + sqlx::query( + "INSERT OR IGNORE INTO e2ee_apply_guard (workspace_id, table_name, row_id) + VALUES (?, 'sessions', ?)", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + if current_deleted_at.as_deref() != deleted_at { + sqlx::query("UPDATE sessions SET deleted_at = ? WHERE id = ? AND workspace_id = ?") + .bind(deleted_at) + .bind(session_id) + .bind(workspace_id) + .execute(&mut **transaction) + .await?; + sqlx::query( + "INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + VALUES (?, 'sessions', ?) + ON CONFLICT(workspace_id, table_name, row_id) + DO UPDATE SET generation = generation + 1", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + } + let predicate = if unseen_content { + "deleted_at = ?" + } else { + "deleted_at IS NULL AND ? IS NOT NULL" + }; + for table in [ + "session_documents", + "transcripts", + "session_attachments", + "session_participants", + "session_tags", + "action_items", + ] { + let query = format!( + "UPDATE {table} SET deleted_at = ? + WHERE session_id = ? AND workspace_id = ? AND {predicate}" + ); + sqlx::query(sqlx::AssertSqlSafe(query)) + .bind(deleted_at) + .bind(session_id) + .bind(workspace_id) + .bind(&context.deleted_at) + .execute(&mut **transaction) + .await?; + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE entity_mentions SET deleted_at = ? + WHERE workspace_id = ? AND ( + (source_type = 'session' AND source_id = ?) + OR (target_type = 'session' AND target_id = ?) + ) AND {predicate}" + ))) + .bind(deleted_at) + .bind(workspace_id) + .bind(session_id) + .bind(session_id) + .bind(&context.deleted_at) + .execute(&mut **transaction) + .await?; + sqlx::query( + "DELETE FROM e2ee_apply_guard WHERE workspace_id = ? AND table_name = 'sessions' AND row_id = ?", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn observed_content_versions_accept_strings_and_json_values() { + let context: DeletionContext = serde_json::from_str( + r#"{ + "version": 1, + "deletedAt": "2099-01-01", + "observed": { + "document:meeting": "content-token", + "attachment:meeting": ["sha", 123, "", "", ""] + } + }"#, + ) + .unwrap(); + + assert_eq!( + observed_content_version(context.observed.get("document:meeting")), + Some("content-token".into()) + ); + assert_eq!( + observed_content_version(context.observed.get("attachment:meeting")), + Some(r#"["sha",123,"","",""]"#.into()) + ); + } +} diff --git a/crates/fs-sync-core/Cargo.toml b/crates/fs-sync-core/Cargo.toml index b95ea30348c..bfe4da65bfb 100644 --- a/crates/fs-sync-core/Cargo.toml +++ b/crates/fs-sync-core/Cargo.toml @@ -13,6 +13,7 @@ anlg-audio-utils = { workspace = true } anlg-frontmatter = { workspace = true } anlg-fs-format = { workspace = true } anlg-tiptap = { workspace = true } +anlg-vad = { workspace = true, features = ["earshot"] } serde = { workspace = true } serde_json = { workspace = true } @@ -23,6 +24,7 @@ tauri-specta = { workspace = true, optional = true, features = ["derive"] } glob = "0.3" rayon = { workspace = true } +rodio = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } diff --git a/crates/fs-sync-core/src/audio/activity.rs b/crates/fs-sync-core/src/audio/activity.rs new file mode 100644 index 00000000000..0476bb8fab0 --- /dev/null +++ b/crates/fs-sync-core/src/audio/activity.rs @@ -0,0 +1,120 @@ +use std::{io, path::Path}; + +use anlg_audio_utils::Source; +use anlg_vad::earshot::{FRAME_10MS, VoiceActivityDetector}; + +pub fn has_speech(path: &Path) -> io::Result { + let source = anlg_audio_utils::source_from_path(path).map_err(io::Error::other)?; + source_has_speech(source) +} + +fn source_has_speech(source: impl Source) -> io::Result { + let channels = source.channels(); + let sample_rate = source.sample_rate(); + let expected_frames = source + .total_duration() + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| io::Error::other("audio_duration_unknown"))? + .as_secs_f64() + * 16_000.0; + let mut source = rodio::conversions::SampleRateConverter::new( + source, + sample_rate, + std::num::NonZeroU32::new(16_000).unwrap(), + channels, + ); + let mut detectors = (0..channels.get()) + .map(|_| VoiceActivityDetector::new()) + .collect::>(); + let mut frames = vec![[0_i16; FRAME_10MS]; usize::from(channels.get())]; + let mut read_frames = 0; + loop { + let mut samples = 0; + for index in 0..FRAME_10MS { + for frame in &mut frames { + let Some(sample) = source.next() else { + // A truncated or undecodable recording is not proof of silence. + if (read_frames + samples) as f64 + 160.0 < expected_frames { + return Err(io::Error::other("audio_decode_incomplete")); + } + for (detector, frame) in detectors.iter_mut().zip(&mut frames) { + frame[index..].fill(0); + if detector.predict_16khz(frame).map_err(io::Error::other)? { + return Ok(true); + } + } + return Ok(false); + }; + if !sample.is_finite() { + return Err(io::Error::other("audio_sample_invalid")); + } + frame[index] = (sample * 32768.0).clamp(-32768.0, 32767.0) as i16; + } + samples += 1; + } + read_frames += samples; + // Inspect channels separately: downmixing could cancel opposite-phase speech. + for (detector, frame) in detectors.iter_mut().zip(&frames) { + if detector.predict_16khz(frame).map_err(io::Error::other)? { + return Ok(true); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speech_is_kept_in_wav_and_compressed_audio() { + for path in [ + anlg_data::english_1::AUDIO_PATH, + anlg_data::english_1::AUDIO_MP3_PATH, + ] { + assert!(has_speech(Path::new(path)).unwrap()); + } + } + + #[test] + fn opposite_phase_channels_do_not_cancel_speech() { + let samples: Vec = + anlg_audio_utils::source_from_path(anlg_data::english_1::AUDIO_PATH) + .unwrap() + .take(160_000) + .collect(); + for right_only in [true, false] { + let stereo = samples + .iter() + .flat_map(|sample| [if right_only { 0.0 } else { -*sample }, *sample]) + .collect::>(); + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(2).unwrap(), + std::num::NonZeroU32::new(16_000).unwrap(), + stereo, + ); + assert!(source_has_speech(source).unwrap()); + } + } + + #[test] + fn silence_in_both_channels_is_empty() { + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(2).unwrap(), + std::num::NonZeroU32::new(48_000).unwrap(), + vec![0.0; 96_000], + ); + assert!(!source_has_speech(source).unwrap()); + } + + #[test] + fn invalid_and_missing_audio_are_not_classified_as_empty() { + assert!(has_speech(Path::new("/nonexistent/automatic-capture.wav")).is_err()); + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(1).unwrap(), + std::num::NonZeroU32::new(16_000).unwrap(), + vec![f32::NAN; 160], + ); + assert!(source_has_speech(source).is_err()); + } +} diff --git a/crates/fs-sync-core/src/audio/mod.rs b/crates/fs-sync-core/src/audio/mod.rs index 5ed38566231..00f90b892a9 100644 --- a/crates/fs-sync-core/src/audio/mod.rs +++ b/crates/fs-sync-core/src/audio/mod.rs @@ -10,6 +10,9 @@ use crate::runtime::{AudioImportEvent, AudioImportRuntime}; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; +mod activity; +pub use activity::has_speech; + const AUDIO_FORMATS: [&str; 3] = ["audio.mp3", "audio.wav", "audio.ogg"]; const AUDIO_ARTIFACTS: [&str; 7] = [ "audio.mp3", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 449d7abc29d..0b727bdf140 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -172,6 +172,7 @@ export const sessions = sqliteTable( folderPath: text("folder_path").notNull().default(""), slug: text("slug").notNull().default(""), metadataJson: text("metadata_json").notNull().default("{}"), + deletionContext: text("deletion_context").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [ @@ -201,6 +202,7 @@ export const sessionDocuments = sqliteTable( updatedBy: text("updated_by").notNull().default(""), createdAt: text("created_at").notNull().default(currentTimestamp), updatedAt: text("updated_at").notNull().default(currentTimestamp), + contentVersion: text("content_version").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [ @@ -311,6 +313,7 @@ export const transcripts = sqliteTable( metadataJson: text("metadata_json").notNull().default("{}"), createdAt: text("created_at").notNull().default(currentTimestamp), updatedAt: text("updated_at").notNull().default(currentTimestamp), + contentVersion: text("content_version").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [index("idx_transcripts_session_id").on(table.sessionId)], diff --git a/plugins/fs-sync/build.rs b/plugins/fs-sync/build.rs index 4383bd12045..67982558558 100644 --- a/plugins/fs-sync/build.rs +++ b/plugins/fs-sync/build.rs @@ -15,6 +15,7 @@ const COMMANDS: &[&str] = &[ "audio_import", "audio_import_data", "audio_source_metadata", + "audio_has_speech", "audio_path", "audio_copy", "session_dir", diff --git a/plugins/fs-sync/js/bindings.gen.ts b/plugins/fs-sync/js/bindings.gen.ts index 3dea749c69c..9e060fe1c3f 100644 --- a/plugins/fs-sync/js/bindings.gen.ts +++ b/plugins/fs-sync/js/bindings.gen.ts @@ -1,668 +1,370 @@ // @ts-nocheck + // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ + export const commands = { - async deserialize(input: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|deserialize", { input }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async writeJsonBatch( - items: [JsonValue, string][], - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|write_json_batch", { items }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async writeDocumentBatch( - items: [ParsedDocument, string][], - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|write_document_batch", { - items, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async readDocumentBatch( - dirPath: string, - ): Promise, string>> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|read_document_batch", { - dirPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async listFolders(): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|list_folders"), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async moveSession( - sessionId: string, - fromFolderPath: string, - targetFolderPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|move_session", { - sessionId, - fromFolderPath, - targetFolderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async createFolder(folderPath: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|create_folder", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async renameFolder( - oldPath: string, - newPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|rename_folder", { - oldPath, - newPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async deleteFolder(folderPath: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|delete_folder", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioExist(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_exist", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioDelete(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_delete", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioMetadata( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_metadata", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioDeleteOrphanedExpired( - knownSessionIds: string[], - retentionMs: number, - nowMs: number, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE( - "plugin:fs-sync|audio_delete_orphaned_expired", - { knownSessionIds, retentionMs, nowMs }, - ), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioImport( - sessionId: string, - sourcePath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_import", { - sessionId, - sourcePath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioImportData( - sessionId: string, - data: number[], - filename: string, - contentType: string | null, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_import_data", { - sessionId, - data, - filename, - contentType, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioSourceMetadata( - sourcePath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_source_metadata", { - sourcePath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioPath(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_path", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - 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 { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|session_dir", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async loadSessionContent( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|load_session_content", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async deleteSessionFolder(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|delete_session_folder", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async scanAndRead( - scanDir: string, - filePatterns: string[], - recursive: boolean, - pathFilter: string | null, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|scan_and_read", { - scanDir, - filePatterns, - recursive, - pathFilter, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async chatDir(chatGroupId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|chat_dir", { chatGroupId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async entityDir(dirName: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|entity_dir", { dirName }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentSave( - sessionId: string, - data: number[], - filename: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_save", { - sessionId, - data, - filename, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentList( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_list", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentRead( - sessionId: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_read", { - sessionId, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentRemove( - sessionId: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_remove", { - sessionId, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentSave( - folderPath: string, - data: number[], - filename: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_save", { - folderPath, - data, - filename, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentList( - folderPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_list", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentRead( - folderPath: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_read", { - folderPath, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentRemove( - folderPath: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_remove", { - folderPath, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, -}; +async deserialize(input: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|deserialize", { input }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async writeJsonBatch(items: ([JsonValue, string])[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|write_json_batch", { items }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async writeDocumentBatch(items: ([ParsedDocument, string])[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|write_document_batch", { items }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async readDocumentBatch(dirPath: string) : Promise, string>> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|read_document_batch", { dirPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async listFolders() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|list_folders") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async moveSession(sessionId: string, fromFolderPath: string, targetFolderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|move_session", { sessionId, fromFolderPath, targetFolderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async createFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|create_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async renameFolder(oldPath: string, newPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|rename_folder", { oldPath, newPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|delete_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioExist(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_exist", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioDelete(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_delete", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioMetadata(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_metadata", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioDeleteOrphanedExpired(knownSessionIds: string[], retentionMs: number, nowMs: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_delete_orphaned_expired", { knownSessionIds, retentionMs, nowMs }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioImport(sessionId: string, sourcePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_import", { sessionId, sourcePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioImportData(sessionId: string, data: number[], filename: string, contentType: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_import_data", { sessionId, data, filename, contentType }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioSourceMetadata(sourcePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_source_metadata", { sourcePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioHasSpeech(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_has_speech", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioPath(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_path", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + 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 { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|session_dir", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async loadSessionContent(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|load_session_content", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteSessionFolder(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|delete_session_folder", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async scanAndRead(scanDir: string, filePatterns: string[], recursive: boolean, pathFilter: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|scan_and_read", { scanDir, filePatterns, recursive, pathFilter }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async chatDir(chatGroupId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|chat_dir", { chatGroupId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async entityDir(dirName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|entity_dir", { dirName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentSave(sessionId: string, data: number[], filename: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_save", { sessionId, data, filename }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentList(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_list", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentRead(sessionId: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_read", { sessionId, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentRemove(sessionId: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_remove", { sessionId, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentSave(folderPath: string, data: number[], filename: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_save", { folderPath, data, filename }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentList(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_list", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentRead(folderPath: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_read", { folderPath, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentRemove(folderPath: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_remove", { folderPath, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} /** user-defined events **/ + export const events = __makeEvents__<{ - audioImportEvent: AudioImportEvent; +audioImportEvent: AudioImportEvent }>({ - audioImportEvent: "plugin:fs-sync:audio-import-event", -}); +audioImportEvent: "plugin:fs-sync:audio-import-event" +}) /** user-defined constants **/ + + /** user-defined types **/ -export type AttachmentInfo = { - attachmentId: string; - path: string; - extension: string; - modifiedAt: string; -}; -export type AttachmentSaveResult = { path: string; attachmentId: string }; -export type AudioFileMetadata = { - filename: string; - contentType: string; - sizeBytes: number; - sha256: string; -}; -export type AudioImportEvent = - | { type: "audioImportStarted"; session_id: string } - | { type: "audioImportProgress"; session_id: string; percentage: number } - | { type: "audioImportCompleted"; session_id: string } - | { type: "audioImportFailed"; session_id: string; error: string }; -export type AudioSourceMetadata = { - createdAt: string | null; - modifiedAt: string | null; - durationMs: number | null; -}; -export type FolderInfo = { name: string; parent_folder_id: string | null }; -export type FolderSessionUpdate = { sessionId: string; folderId: string }; -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | Partial<{ [key in string]: JsonValue }>; -export type ListFoldersResult = { - folders: Partial<{ [key in string]: FolderInfo }>; - session_folder_map: Partial<{ [key in string]: string }>; -}; -export type MoveSessionResult = { sessionId: string; folderId: string }; -export type ParsedDocument = { - frontmatter: Partial<{ [key in string]: JsonValue }>; - content: string; -}; -export type RenameFolderResult = { updates: FolderSessionUpdate[] }; -export type ScanResult = { - files: Partial<{ [key in string]: string }>; - dirs: string[]; -}; -export type SessionContentData = { - sessionId: string; - meta: SessionMetaData | null; - rawMemoTiptapJson: JsonValue | null; - rawMemoMarkdown: string | null; - transcript: TranscriptJson | null; - notes: SessionNoteData[]; -}; -export type SessionMetaData = { - id: string; - userId: string; - createdAt: string | null; - title: string | null; - event: JsonValue | null; - eventId: string | null; - participants: SessionMetaParticipant[]; - tags: string[]; -}; -export type SessionMetaParticipant = { - id: string; - userId: string; - sessionId: string; - humanId: string; - source: string; -}; -export type SessionNoteData = { - id: string; - sessionId: string; - templateId: string | null; - position: number | null; - title: string | null; - tiptapJson: JsonValue; - markdown: string | null; -}; -export type TranscriptJson = { transcripts?: TranscriptWithData[] }; -export type TranscriptSpeakerHint = { - id?: string | null; - word_id: string; - type: string; - value?: JsonValue; -}; -export type TranscriptWithData = { - id: string; - user_id?: string; - created_at?: string; - session_id: string; - started_at?: number; - ended_at?: number | null; - memo_md?: string; - words?: TranscriptWord[]; - speaker_hints?: TranscriptSpeakerHint[]; -}; -export type TranscriptWord = { - id?: string | null; - text: string; - start_ms: number; - end_ms: number; - channel: number; - speaker?: string | null; - metadata?: Partial<{ [key in string]: JsonValue }> | null; -}; +export type AttachmentInfo = { attachmentId: string; path: string; extension: string; modifiedAt: string } +export type AttachmentSaveResult = { path: string; attachmentId: string } +export type AudioFileMetadata = { filename: string; contentType: string; sizeBytes: number; sha256: string } +export type AudioImportEvent = { type: "audioImportStarted"; session_id: string } | { type: "audioImportProgress"; session_id: string; percentage: number } | { type: "audioImportCompleted"; session_id: string } | { type: "audioImportFailed"; session_id: string; error: string } +export type AudioSourceMetadata = { createdAt: string | null; modifiedAt: string | null; durationMs: number | null } +export type FolderInfo = { name: string; parent_folder_id: string | null } +export type FolderSessionUpdate = { sessionId: string; folderId: string } +export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> +export type ListFoldersResult = { folders: Partial<{ [key in string]: FolderInfo }>; session_folder_map: Partial<{ [key in string]: string }> } +export type MoveSessionResult = { sessionId: string; folderId: string } +export type ParsedDocument = { frontmatter: Partial<{ [key in string]: JsonValue }>; content: string } +export type RenameFolderResult = { updates: FolderSessionUpdate[] } +export type ScanResult = { files: Partial<{ [key in string]: string }>; dirs: string[] } +export type SessionContentData = { sessionId: string; meta: SessionMetaData | null; rawMemoTiptapJson: JsonValue | null; rawMemoMarkdown: string | null; transcript: TranscriptJson | null; notes: SessionNoteData[] } +export type SessionMetaData = { id: string; userId: string; createdAt: string | null; title: string | null; event: JsonValue | null; eventId: string | null; participants: SessionMetaParticipant[]; tags: string[] } +export type SessionMetaParticipant = { id: string; userId: string; sessionId: string; humanId: string; source: string } +export type SessionNoteData = { id: string; sessionId: string; templateId: string | null; position: number | null; title: string | null; tiptapJson: JsonValue; markdown: string | null } +export type TranscriptJson = { transcripts?: TranscriptWithData[] } +export type TranscriptSpeakerHint = { id?: string | null; word_id: string; type: string; value?: JsonValue } +export type TranscriptWithData = { id: string; user_id?: string; created_at?: string; session_id: string; started_at?: number; ended_at?: number | null; memo_md?: string; words?: TranscriptWord[]; speaker_hints?: TranscriptSpeakerHint[] } +export type TranscriptWord = { id?: string | null; text: string; start_ms: number; end_ms: number; channel: number; speaker?: string | null; metadata?: Partial<{ [key in string]: JsonValue }> | null } /** tauri-specta globals **/ import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, } from "@tauri-apps/api/core"; import * as TAURI_API_EVENT from "@tauri-apps/api/event"; import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml b/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml new file mode 100644 index 00000000000..dcb5c031f36 --- /dev/null +++ b/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-audio-has-speech" +description = "Enables the audio_has_speech command without any pre-configured scope." +commands.allow = ["audio_has_speech"] + +[[permission]] +identifier = "deny-audio-has-speech" +description = "Denies the audio_has_speech command without any pre-configured scope." +commands.deny = ["audio_has_speech"] diff --git a/plugins/fs-sync/permissions/autogenerated/reference.md b/plugins/fs-sync/permissions/autogenerated/reference.md index c9289c85fcf..5b7dfd82149 100644 --- a/plugins/fs-sync/permissions/autogenerated/reference.md +++ b/plugins/fs-sync/permissions/autogenerated/reference.md @@ -20,6 +20,7 @@ Default permissions for the fs-sync plugin - `allow-audio-import` - `allow-audio-import-data` - `allow-audio-source-metadata` +- `allow-audio-has-speech` - `allow-audio-path` - `allow-audio-copy` - `allow-session-dir` @@ -257,6 +258,32 @@ Denies the audio_exist command without any pre-configured scope. +`fs-sync:allow-audio-has-speech` + + + + +Enables the audio_has_speech command without any pre-configured scope. + + + + + + + +`fs-sync:deny-audio-has-speech` + + + + +Denies the audio_has_speech command without any pre-configured scope. + + + + + + + `fs-sync:allow-audio-import` diff --git a/plugins/fs-sync/permissions/default.toml b/plugins/fs-sync/permissions/default.toml index 532bbe5f789..150d56d9244 100644 --- a/plugins/fs-sync/permissions/default.toml +++ b/plugins/fs-sync/permissions/default.toml @@ -17,6 +17,7 @@ permissions = [ "allow-audio-import", "allow-audio-import-data", "allow-audio-source-metadata", + "allow-audio-has-speech", "allow-audio-path", "allow-audio-copy", "allow-session-dir", diff --git a/plugins/fs-sync/permissions/schemas/schema.json b/plugins/fs-sync/permissions/schemas/schema.json index 0f0aa37d05d..fed877b1162 100644 --- a/plugins/fs-sync/permissions/schemas/schema.json +++ b/plugins/fs-sync/permissions/schemas/schema.json @@ -390,6 +390,18 @@ "const": "deny-audio-exist", "markdownDescription": "Denies the audio_exist command without any pre-configured scope." }, + { + "description": "Enables the audio_has_speech command without any pre-configured scope.", + "type": "string", + "const": "allow-audio-has-speech", + "markdownDescription": "Enables the audio_has_speech command without any pre-configured scope." + }, + { + "description": "Denies the audio_has_speech command without any pre-configured scope.", + "type": "string", + "const": "deny-audio-has-speech", + "markdownDescription": "Denies the audio_has_speech command without any pre-configured scope." + }, { "description": "Enables the audio_import command without any pre-configured scope.", "type": "string", @@ -691,10 +703,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-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`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-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-has-speech`\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`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-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-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`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-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-has-speech`\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`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-attachment-remove`" } ] } diff --git a/plugins/fs-sync/src/commands.rs b/plugins/fs-sync/src/commands.rs index a5e2e61c02d..092bb40df8d 100644 --- a/plugins/fs-sync/src/commands.rs +++ b/plugins/fs-sync/src/commands.rs @@ -416,6 +416,19 @@ pub(crate) async fn audio_source_metadata( }) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn audio_has_speech( + app: tauri::AppHandle, + session_id: String, +) -> Result { + let session_dir = resolve_session_dir(&app, &session_id)?; + spawn_blocking!({ + let path = crate::audio::path(&session_dir).ok_or("audio_not_found")?; + crate::audio::has_speech(&path).map_err(|error| error.to_string()) + }) +} + #[tauri::command] #[specta::specta] pub(crate) async fn audio_path( diff --git a/plugins/fs-sync/src/lib.rs b/plugins/fs-sync/src/lib.rs index 94fa3b089b2..3e9b1e441f8 100644 --- a/plugins/fs-sync/src/lib.rs +++ b/plugins/fs-sync/src/lib.rs @@ -27,6 +27,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::audio_import::, commands::audio_import_data::, commands::audio_source_metadata, + commands::audio_has_speech::, commands::audio_path::, commands::audio_copy::, commands::session_dir::,