Phase 0 QC found two real blockers in the baseline commits: 1. crates/llm/src/lib.rs:215 — early-break guard was inverted. The guard fired when grammar.is_some() (where grammar already enforces shape, making the check redundant) and was disabled for grammar.is_none() (the content-tags path that actually needs it). Flipped to grammar.is_none() so the JSON-envelope short-circuit fires for free-form JSON generation as the commit message implied. 2. src/lib/pages/FilesPage.svelte — handleExport() success path had no toast, asymmetric with DictationPage which does. Added the toasts import and a toasts.success() call after the native save so both export surfaces give consistent feedback. cargo test -p magnotia-llm --lib: 21 pass. npm run check: 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
9.5 KiB
Svelte
287 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
// @ts-nocheck
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { settings, addToHistory } from "$lib/stores/page.svelte.js";
|
|
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
|
import Card from "$lib/components/Card.svelte";
|
|
import EmptyState from "$lib/components/EmptyState.svelte";
|
|
import { exportTranscript } from "$lib/utils/export.js";
|
|
import { hasTauriRuntime } from "$lib/utils/runtime";
|
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
|
import { Upload } from 'lucide-svelte';
|
|
|
|
let fileTranscript = $state("");
|
|
let segments = $state([]);
|
|
let isDragOver = $state(false);
|
|
let progress = $state(0);
|
|
let progressText = $state("");
|
|
let fileName = $state("");
|
|
let error = $state("");
|
|
let transcribing = $state(false);
|
|
let showExportMenu = $state(false);
|
|
|
|
let unlistenDrop = null;
|
|
let unlistenEnter = null;
|
|
let unlistenLeave = null;
|
|
|
|
onMount(async () => {
|
|
unlistenDrop = await listen("tauri://drag-drop", async (event) => {
|
|
isDragOver = false;
|
|
if (event.payload?.paths?.length > 0) {
|
|
await transcribeFiles(event.payload.paths);
|
|
}
|
|
});
|
|
unlistenEnter = await listen("tauri://drag-enter", () => { isDragOver = true; });
|
|
unlistenLeave = await listen("tauri://drag-leave", () => { isDragOver = false; });
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (unlistenDrop) unlistenDrop();
|
|
if (unlistenEnter) unlistenEnter();
|
|
if (unlistenLeave) unlistenLeave();
|
|
});
|
|
|
|
async function handleBrowse() {
|
|
try {
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const paths = await open({
|
|
multiple: true,
|
|
filters: [{
|
|
name: "Audio/Video",
|
|
extensions: ["mp3", "wav", "m4a", "mp4", "flac", "ogg", "webm", "mov", "mkv"],
|
|
}],
|
|
});
|
|
if (paths) {
|
|
const pathList = Array.isArray(paths) ? paths : [paths];
|
|
await transcribeFiles(pathList);
|
|
}
|
|
} catch (err) {
|
|
error = typeof err === "string" ? err : err.message || "Failed to open file dialog";
|
|
}
|
|
}
|
|
|
|
async function transcribeFiles(paths) {
|
|
transcribing = true;
|
|
error = "";
|
|
fileTranscript = "";
|
|
segments = [];
|
|
|
|
for (let i = 0; i < paths.length; i++) {
|
|
const path = paths[i];
|
|
fileName = path.split(/[\\/]/).pop() || path;
|
|
progress = (i / paths.length) * 100;
|
|
progressText = `Transcribing ${i + 1}/${paths.length}...`;
|
|
|
|
try {
|
|
const result = await invoke("transcribe_file", {
|
|
path,
|
|
engine: settings.engine,
|
|
language: settings.language,
|
|
initialPrompt: "",
|
|
profileId: profilesStore.activeProfileId,
|
|
removeFillers: settings.removeFillers,
|
|
britishEnglish: settings.britishEnglish,
|
|
antiHallucination: settings.antiHallucination,
|
|
formatMode: settings.formatMode,
|
|
});
|
|
|
|
const text = result.segments.map((s) => s.text).join(" ").trim();
|
|
|
|
if (paths.length > 1) {
|
|
fileTranscript += `--- ${fileName} ---\n${text}\n\n`;
|
|
} else {
|
|
fileTranscript = text;
|
|
}
|
|
|
|
segments = [...segments, ...result.segments];
|
|
|
|
addToHistory({
|
|
id: crypto.randomUUID(),
|
|
date: new Date().toLocaleString("en-GB"),
|
|
source: fileName,
|
|
profileId: profilesStore.activeProfileId,
|
|
preview: text.slice(0, 120),
|
|
text,
|
|
segments: result.segments,
|
|
duration: result.duration,
|
|
language: result.language,
|
|
engine: result.engine ?? settings.engine,
|
|
modelId: result.modelId ?? null,
|
|
});
|
|
} catch (err) {
|
|
error = `Failed: ${fileName}. ${typeof err === "string" ? err : err.message}`;
|
|
}
|
|
}
|
|
|
|
progress = 100;
|
|
progressText = "Done";
|
|
transcribing = false;
|
|
|
|
setTimeout(() => {
|
|
progress = 0;
|
|
progressText = "";
|
|
fileName = "";
|
|
}, 3000);
|
|
}
|
|
|
|
function copyAll() {
|
|
if (fileTranscript) {
|
|
navigator.clipboard.writeText(fileTranscript).catch(() => {
|
|
invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleExport(format) {
|
|
if (!fileTranscript) return;
|
|
showExportMenu = false;
|
|
const content = exportTranscript(fileTranscript, segments, format);
|
|
const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
|
|
const ext = extMap[format] || "txt";
|
|
const defaultPath = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
|
|
|
if (hasTauriRuntime()) {
|
|
try {
|
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
const path = await save({
|
|
title: `Export ${format.toUpperCase()} transcript`,
|
|
defaultPath,
|
|
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
|
});
|
|
if (!path) return;
|
|
await invoke("write_text_file_cmd", { path, contents: content });
|
|
toasts.success(`Exported ${format.toUpperCase()} transcript`);
|
|
return;
|
|
} catch (err) {
|
|
console.error("file transcript export failed", err);
|
|
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const blob = new Blob([content], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = defaultPath;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
|
<!-- Title -->
|
|
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-4">File Transcription</h2>
|
|
|
|
<!-- Drop zone. The dashed border IS the affordance, so we drop the
|
|
outer Card (which doubled the surface treatment per brand spec)
|
|
and let the dashed div be the single surface. The region landmark
|
|
carries an explicit accessible name per WCAG ARIA13. -->
|
|
<div class="px-7 pb-3">
|
|
<div
|
|
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed
|
|
{isDragOver
|
|
? 'border-accent bg-accent-subtle scale-[1.01]'
|
|
: 'border-border hover:border-text-tertiary'}"
|
|
style="transition-duration: var(--duration-ui)"
|
|
role="region"
|
|
aria-label="Audio file drop zone"
|
|
>
|
|
{#if !fileTranscript && !transcribing}
|
|
<EmptyState
|
|
icon={Upload}
|
|
message="Drop an audio file or click to browse"
|
|
/>
|
|
{:else}
|
|
<div class="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center mb-3">
|
|
<Upload size={20} class="text-text-tertiary" aria-hidden="true" />
|
|
</div>
|
|
<p class="text-[13px] text-text-secondary text-center">
|
|
Drop audio or video files here
|
|
</p>
|
|
{/if}
|
|
<p class="text-[12px] text-text-secondary text-center mt-1.5">
|
|
MP3, WAV, M4A, MP4, FLAC, OGG
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Browse buttons + progress -->
|
|
<div class="flex items-center gap-2 px-7 pb-3">
|
|
<button
|
|
class="btn-lg rounded-lg font-medium text-white bg-accent hover:bg-accent-hover
|
|
shadow-[var(--shadow-accent-raised)]"
|
|
onclick={handleBrowse}
|
|
disabled={transcribing}
|
|
>
|
|
Browse Files
|
|
</button>
|
|
<div class="flex-1"></div>
|
|
{#if progressText}
|
|
<span class="text-[12px] text-text-secondary animate-fade-in">{progressText}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
{#if error}
|
|
<div class="px-7 pb-2 animate-fade-in">
|
|
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Progress bar -->
|
|
{#if progress > 0}
|
|
<div class="px-7 pb-3 animate-fade-in">
|
|
<div class="h-[3px] bg-bg-elevated rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-accent rounded-full shadow-[var(--shadow-accent-glow)]"
|
|
style="width: {progress}%; transition-duration: var(--duration-ui)"
|
|
></div>
|
|
</div>
|
|
{#if fileName}
|
|
<p class="text-[12px] text-text-secondary mt-1.5">{fileName}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- File transcript -->
|
|
<div class="flex-1 px-7 pb-3 min-h-0">
|
|
<Card classes="h-full flex flex-col">
|
|
<textarea
|
|
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
|
resize-none placeholder:text-text-tertiary"
|
|
placeholder="Transcribed text will appear here..."
|
|
bind:value={fileTranscript}
|
|
data-no-transition
|
|
aria-label="File transcript"
|
|
aria-live="polite"
|
|
></textarea>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Bottom actions -->
|
|
<div class="flex gap-0.5 px-7 pb-4">
|
|
<button class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text" onclick={copyAll}>Copy</button>
|
|
<div class="relative">
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
|
onclick={() => showExportMenu = !showExportMenu}
|
|
>Export</button>
|
|
{#if showExportMenu}
|
|
<div class="absolute left-0 bottom-full mb-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
|
|
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
|
|
<button
|
|
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
|
|
onclick={() => handleExport(fmt)}
|
|
>{label}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|