From 27b705b5b6b2b1b0215d853c6387bf6512d05413 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 22:17:35 -0700 Subject: [PATCH] File-based project save/load, AI chat formatting, text edit fix Project files (.vtn): - Save Project: serializes transcript, speakers, audio path to JSON file - Open Project: loads .vtn file, restores audio/transcript/speakers - User chooses filename and location via save dialog - Replaces SQLite-based project persistence (DB commands remain for future use) - Text edits update in-memory store immediately, persist on explicit save - Fix Windows path separator in project name extraction AI chat: - Markdown rendering in assistant messages (headers, lists, bold, code) - Better visual distinction with border-left accents - Styled markdown elements for dark theme Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/project.rs | 52 ++++++ src-tauri/src/lib.rs | 6 +- src/routes/+page.svelte | 278 ++++++++++-------------------- 3 files changed, 148 insertions(+), 188 deletions(-) diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 1b7e527..6068c15 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -1,10 +1,48 @@ use serde::{Deserialize, Serialize}; +use std::fs; use tauri::State; use crate::db::models::Project; use crate::db::queries; use crate::state::AppState; +// ── File-based project types ──────────────────────────────────── + +#[derive(Serialize, Deserialize)] +pub struct ProjectFile { + pub version: u32, + pub name: String, + pub audio_file: String, + pub created_at: String, + pub segments: Vec, + pub speakers: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ProjectFileSegment { + pub text: String, + pub start_ms: i64, + pub end_ms: i64, + pub speaker: Option, + pub is_edited: bool, + pub words: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ProjectFileWord { + pub word: String, + pub start_ms: i64, + pub end_ms: i64, + pub confidence: f64, +} + +#[derive(Serialize, Deserialize)] +pub struct ProjectFileSpeaker { + pub label: String, + pub display_name: Option, + pub color: String, +} + // ── Input types for save_project_transcript ────────────────────── #[derive(Deserialize)] @@ -243,3 +281,17 @@ pub fn load_project_transcript( speakers: speaker_outputs, })) } + +// ── File-based project commands ───────────────────────────────── + +#[tauri::command] +pub fn save_project_file(path: String, project: ProjectFile) -> Result<(), String> { + let json = serde_json::to_string_pretty(&project).map_err(|e| e.to_string())?; + fs::write(&path, json).map_err(|e| format!("Failed to save project: {e}")) +} + +#[tauri::command] +pub fn load_project_file(path: String) -> Result { + let json = fs::read_to_string(&path).map_err(|e| format!("Failed to read project: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("Failed to parse project: {e}")) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 701c9ed..22ce9c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,8 +10,8 @@ use tauri::Manager; use commands::ai::{ai_chat, ai_configure, ai_list_providers}; use commands::export::export_transcript; use commands::project::{ - create_project, delete_project, get_project, list_projects, load_project_transcript, - save_project_transcript, update_segment, + create_project, delete_project, get_project, list_projects, load_project_file, + load_project_transcript, save_project_file, save_project_transcript, update_segment, }; use commands::settings::{load_settings, save_settings}; use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop}; @@ -41,6 +41,8 @@ pub fn run() { save_project_transcript, load_project_transcript, update_segment, + save_project_file, + load_project_file, transcribe_file, run_pipeline, download_diarize_model, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3225e7d..aad8644 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -19,14 +19,12 @@ let showSettings = $state(false); // Project management state - let currentProjectId = $state(null); + let currentProjectPath = $state(null); let currentProjectName = $state(''); - let savedProjects = $state>([]); - let showProjectMenu = $state(false); + let audioFilePath = $state(''); onMount(() => { loadSettings(); - loadProjects(); // Global keyboard shortcuts function handleKeyDown(e: KeyboardEvent) { @@ -45,7 +43,6 @@ showSettings = true; } else if (e.key === 'Escape') { showExportMenu = false; - showProjectMenu = false; showSettings = false; } } @@ -58,11 +55,6 @@ showExportMenu = false; } } - if (showProjectMenu) { - if (!target.closest('.project-dropdown')) { - showProjectMenu = false; - } - } } document.addEventListener('keydown', handleKeyDown); @@ -83,57 +75,91 @@ // Speaker color palette for auto-assignment const speakerColors = ['#e94560', '#4ecdc4', '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea', '#ffd93d', '#6bcb77']; - async function loadProjects() { + async function saveProject() { + const defaultName = currentProjectName || 'Untitled'; + const outputPath = await save({ + defaultPath: `${defaultName}.vtn`, + filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }], + }); + if (!outputPath) return; + + const projectData = { + version: 1, + name: outputPath.split(/[\\/]/).pop()?.replace('.vtn', '') || defaultName, + audio_file: audioFilePath, + created_at: new Date().toISOString(), + segments: $segments.map(seg => { + const speaker = $speakers.find(s => s.id === seg.speaker_id); + return { + text: seg.text, + start_ms: seg.start_ms, + end_ms: seg.end_ms, + speaker: speaker?.label ?? null, + is_edited: seg.is_edited, + words: seg.words.map(w => ({ + word: w.word, + start_ms: w.start_ms, + end_ms: w.end_ms, + confidence: w.confidence ?? 0, + })), + }; + }), + speakers: $speakers.map(s => ({ + label: s.label, + display_name: s.display_name, + color: s.color || '#e94560', + })), + }; + try { - const projects = await invoke>('list_projects'); - savedProjects = projects; + await invoke('save_project_file', { path: outputPath, project: projectData }); + currentProjectPath = outputPath; + currentProjectName = projectData.name; } catch (err) { - console.error('Failed to load projects:', err); + console.error('Failed to save project:', err); + alert(`Failed to save: ${err}`); } } - async function loadProject(projectId: string) { + async function openProject() { + const filePath = await open({ + filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }], + multiple: false, + }); + if (!filePath) return; + try { - const result = await invoke<{ - project_id: string; + const project = await invoke<{ + version: number; name: string; - file_path: string; + audio_file: string; segments: Array<{ text: string; start_ms: number; end_ms: number; speaker: string | null; - words: Array<{ - word: string; - start_ms: number; - end_ms: number; - confidence: number; - }>; + is_edited: boolean; + words: Array<{ word: string; start_ms: number; end_ms: number; confidence: number }>; }>; - speakers: string[]; - }>('load_project_transcript', { projectId }); - - // Set project info - currentProjectId = result.project_id; - currentProjectName = result.name; + speakers: Array<{ label: string; display_name: string | null; color: string }>; + }>('load_project_file', { path: filePath }); // Rebuild speakers - const newSpeakers: Speaker[] = (result.speakers || []).map((label, idx) => ({ + const newSpeakers: Speaker[] = project.speakers.map((s, idx) => ({ id: `speaker-${idx}`, - project_id: result.project_id, - label, - display_name: null, - color: speakerColors[idx % speakerColors.length], + project_id: '', + label: s.label, + display_name: s.display_name, + color: s.color, })); speakers.set(newSpeakers); - // Build speaker label -> id lookup const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id])); // Rebuild segments - const newSegments: Segment[] = result.segments.map((seg, idx) => ({ + const newSegments: Segment[] = project.segments.map((seg, idx) => ({ id: `seg-${idx}`, - project_id: result.project_id, + project_id: '', media_file_id: '', speaker_id: seg.speaker ? (speakerLookup.get(seg.speaker) ?? null) : null, start_ms: seg.start_ms, @@ -141,7 +167,7 @@ text: seg.text, original_text: null, confidence: null, - is_edited: false, + is_edited: seg.is_edited, edited_at: null, segment_index: idx, words: seg.words.map((w, widx) => ({ @@ -156,44 +182,27 @@ })); segments.set(newSegments); - // Load audio from saved file path - if (result.file_path) { - audioUrl = convertFileSrc(result.file_path); - waveformPlayer?.loadAudio(audioUrl); - } + // Load audio + audioFilePath = project.audio_file; + audioUrl = convertFileSrc(project.audio_file); + waveformPlayer?.loadAudio(audioUrl); - showProjectMenu = false; + currentProjectPath = filePath as string; + currentProjectName = project.name; } catch (err) { console.error('Failed to load project:', err); alert(`Failed to load project: ${err}`); } } - async function deleteProject(projectId: string) { - try { - await invoke('delete_project', { projectId }); - await loadProjects(); - if (currentProjectId === projectId) { - currentProjectId = null; - currentProjectName = ''; - } - } catch (err) { - console.error('Failed to delete project:', err); - alert(`Failed to delete project: ${err}`); - } - } - function handleWordClick(timeMs: number) { console.log('[voice-to-notes] Word clicked, seeking to', timeMs, 'ms'); waveformPlayer?.seekTo(timeMs); } - async function handleTextEdit(segmentId: string, newText: string) { - try { - await invoke('update_segment', { segmentId, newText }); - } catch (err) { - console.error('Failed to save segment edit:', err); - } + function handleTextEdit(segmentId: string, newText: string) { + // In-memory store is already updated by TranscriptEditor. + // Changes persist when user saves the project file. } async function handleFileImport() { @@ -207,7 +216,8 @@ }); if (!filePath) return; - // Convert file path to asset URL for wavesurfer + // Track the original file path and convert to asset URL for wavesurfer + audioFilePath = filePath; audioUrl = convertFileSrc(filePath); waveformPlayer?.loadAudio(audioUrl); @@ -367,27 +377,10 @@ segments.set(newSegments); - // Auto-save project - try { - const fileName = filePath.split(/[\\/]/).pop() || 'Untitled'; - const projectName = fileName.replace(/\.[^.]+$/, ''); - const projectId = await invoke('create_project', { name: projectName }); - await invoke('save_project_transcript', { - projectId, - filePath, - segments: result.segments, - speakers: result.speakers.map((label, idx) => ({ - label, - display_name: null, - color: speakerColors[idx % speakerColors.length], - })), - }); - currentProjectId = projectId; - currentProjectName = projectName; - await loadProjects(); - } catch (saveErr) { - console.error('Auto-save failed:', saveErr); - } + // Set project name from audio file name (user can save explicitly) + const fileName = filePath.split(/[\\/]/).pop() || 'Untitled'; + currentProjectName = fileName.replace(/\.[^.]+$/, ''); + currentProjectPath = null; } catch (err) { console.error('Pipeline failed:', err); alert(`Pipeline failed: ${err}`); @@ -461,33 +454,14 @@

Voice to Notes

-
- + {#if $segments.length > 0} + - {#if showProjectMenu} -
- {#if savedProjects.length === 0} -
No saved projects
- {:else} - {#each savedProjects as project} -
- - -
- {/each} - {/if} -
- {/if} -
+ {/if}