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:
119
src/lib/utils/saveMarkdown.ts
Normal file
119
src/lib/utils/saveMarkdown.ts
Normal 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("\\") ? "\\" : "/";
|
||||
}
|
||||
Reference in New Issue
Block a user