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