From d1500cda8c28dfd5439e1634ff998b6b61dbcca9 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 23:49:19 +0100 Subject: [PATCH] feat(phase9): saveMarkdown utility Centralises the save-dialog plus write-file plumbing. suggestedFilename slugs the title into "-.md". saveTranscriptAsMarkdown opens the system save dialog and writes via write_text_file_cmd; on cancel returns null with no toast or fallback. exportTranscriptsToDir writes one .md per item to a chosen folder, in-batch collision suffix " (2)" etc. Documents the deliberate non-check of pre-existing files in the chosen directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/utils/saveMarkdown.ts | 119 ++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/lib/utils/saveMarkdown.ts diff --git a/src/lib/utils/saveMarkdown.ts b/src/lib/utils/saveMarkdown.ts new file mode 100644 index 0000000..4117837 --- /dev/null +++ b/src/lib/utils/saveMarkdown.ts @@ -0,0 +1,119 @@ +// Phase 9 shared helpers. Centralises the save-dialog + write-file dance +// that HistoryPage (and any future consumer that wants to export a +// transcript as Markdown) calls. Keeps three responsibilities in one +// place: filename suggestion, single-file save, and bulk-to-directory +// export. + +import { save, open } from "@tauri-apps/plugin-dialog"; +import { invoke } from "@tauri-apps/api/core"; +import { toasts } from "$lib/stores/toasts.svelte"; +import { buildMarkdown } from "$lib/utils/frontmatter"; +import { hasTauriRuntime } from "$lib/utils/runtime"; +import type { TranscriptEntry } from "$lib/types/app"; + +/** Build "-.md" from a transcript entry. Slug keeps + * a-z0-9-; everything else collapses to a hyphen. Empty titles fall + * back to "transcript". Trailing hyphens and runs collapse. */ +export function suggestedFilename(item: TranscriptEntry): string { + const raw = (item.title || "transcript").toLowerCase(); + const slug = + raw + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "transcript"; + const iso = new Date(item.createdAt || (item as { date?: string }).date || Date.now()) + .toISOString() + .slice(0, 10); + return `${slug}-${iso}.md`; +} + +/** Save a single transcript via the system Save dialog. Resolves to the + * chosen path on success, null if the user cancelled. No clipboard + * fallback. */ +export async function saveTranscriptAsMarkdown( + item: TranscriptEntry, +): Promise { + if (!hasTauriRuntime()) return null; + const md = buildMarkdown(item); + const defaultPath = suggestedFilename(item); + const path = await save({ + title: "Save transcript as Markdown", + defaultPath, + filters: [{ name: "Markdown", extensions: ["md"] }], + }); + if (!path) return null; + try { + await invoke("write_text_file_cmd", { path, contents: md }); + toasts.success(`Saved to ${basename(path)}`); + return path; + } catch (err) { + toasts.error("Couldn't save transcript", String(err)); + return null; + } +} + +/** Bulk export N transcripts to a user-chosen directory. One file per + * transcript, filename collisions inside the batch suffixed " (2)", + * " (3)" etc. Resolves to the count of files actually written. + * + * We do not check for pre-existing files in the chosen directory, so + * exporting on top of an already-populated folder can clobber. The + * user picked the folder, the OS picker shows them the contents — that + * is judged sufficient signalling for a Phase 9 polish item. Adding a + * filesystem-check loop is post-v0.1 territory if it actually bites. */ +export async function exportTranscriptsToDir( + items: TranscriptEntry[], +): Promise { + if (!hasTauriRuntime() || items.length === 0) return 0; + + const dir = await open({ + directory: true, + multiple: false, + title: "Choose export folder", + }); + if (!dir || typeof dir !== "string") return 0; + + const used = new Set(); + let written = 0; + for (const item of items) { + const base = suggestedFilename(item); + const finalName = uniquify(base, used); + used.add(finalName.toLowerCase()); + const path = `${dir}${pathSeparator(dir)}${finalName}`; + try { + await invoke("write_text_file_cmd", { + path, + contents: buildMarkdown(item), + }); + written += 1; + } catch (err) { + console.error("exportTranscriptsToDir write failed", path, err); + } + } + toasts.success( + written === items.length + ? `Exported ${written} transcript${written === 1 ? "" : "s"} to ${basename(dir)}` + : `Exported ${written} of ${items.length} transcripts to ${basename(dir)}`, + ); + return written; +} + +function uniquify(name: string, taken: Set): string { + if (!taken.has(name.toLowerCase())) return name; + const dot = name.lastIndexOf("."); + const stem = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ""; + let n = 2; + while (taken.has(`${stem} (${n})${ext}`.toLowerCase())) n += 1; + return `${stem} (${n})${ext}`; +} + +function basename(p: string): string { + const sep = p.includes("\\") ? "\\" : "/"; + const parts = p.split(sep); + return parts[parts.length - 1] || p; +} + +function pathSeparator(dir: string): string { + return dir.includes("\\") ? "\\" : "/"; +}