feat(phase9): saveMarkdown utility

Centralises the save-dialog plus write-file plumbing. suggestedFilename
slugs the title into "<slug>-<YYYY-MM-DD>.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) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 23:49:19 +01:00
parent bfec88ccc9
commit d1500cda8c

View File

@@ -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 "<slug>-<YYYY-MM-DD>.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<string | null> {
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<number> {
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<string>();
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>): 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("\\") ? "\\" : "/";
}