Files
NoteAI/src/lib/export.ts
T
2026-03-04 10:28:31 +08:00

143 lines
4.9 KiB
TypeScript

import JSZip from "jszip";
import { saveAs } from "file-saver";
import { Page } from "./store";
import { useSettingsStore } from "./settings-store";
import { sanitizeFilename } from "./page-utils";
import { htmlToMarkdown } from "./markdown-codec";
// Helper to escape YAML strings
function escapeYamlString(str: string): string {
return str.replace(/"/g, '\\"');
}
/**
* Converts a page to Markdown with Frontmatter
*/
export function pageToMarkdown(page: Page): string {
const rawMarkdown = htmlToMarkdown(page.content || "");
const tags = page.tags ? JSON.parse(typeof page.tags === 'string' ? page.tags : JSON.stringify(page.tags)) : [];
// Format Date to Local Time with Timezone
const dateObj = page.updatedAt ? new Date(page.updatedAt) : new Date();
// Get timezone from store
const { timezone } = useSettingsStore.getState();
// Use Intl to format date in the target timezone
// We want format: YYYY-MM-DDTHH:mm:ss
// Since Intl isn't extremely flexible with custom formats without parts, we can use "sv-SE" locale which is usually ISO-like (YYYY-MM-DD HH:mm:ss)
// Or just construct it using parts.
let localDate;
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone || "Asia/Shanghai",
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// sv-SE outputs YYYY-MM-DD HH:mm:ss
const parts = new Intl.DateTimeFormat('sv-SE', options).formatToParts(dateObj);
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "";
localDate = `${getPart('year')}-${getPart('month')}-${getPart('day')}T${getPart('hour')}:${getPart('minute')}:${getPart('second')}`;
} catch {
// Fallback if timezone is invalid
localDate = dateObj.toISOString().slice(0, 19);
}
// Construct Frontmatter
const frontmatter = [
"---",
`title: "${escapeYamlString(page.title)}"`,
`id: "${page.id}"`,
`date: "${localDate}"`,
`tags: [${tags.map((t: string) => `"${escapeYamlString(t)}"`).join(", ")}]`,
`order: ${page.order || 0}`,
"---",
"",
""
].join("\n");
return frontmatter + rawMarkdown;
}
/**
* Exports a single page as a Markdown file
*/
export function exportPageAsMarkdown(page: Page) {
const markdown = pageToMarkdown(page);
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const filename = sanitizeFilename(page.title || "Untitled");
saveAs(blob, `${filename}.md`);
}
/**
* Exports a folder and its contents as a Zip file
*/
export async function exportFolderAsZip(folderId: string, allPages: Page[], folderName: string) {
const zip = new JSZip();
const cleanFolderName = sanitizeFilename(folderName);
const folder = zip.folder(cleanFolderName);
if (!folder) {
console.error("Failed to create zip folder");
return;
}
addPagesToZipRecursive(folder, folderId, allPages);
try {
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${folderName}.zip`);
} catch (e) {
console.error("Failed to generate zip", e);
}
}
/**
* Exports ALL pages as a Zip backup
*/
export async function exportAllPagesAsZip(allPages: Page[]) {
const zip = new JSZip();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16); // YYYY-MM-DDTHH-mm
const zipName = `noteai-backup-${timestamp}`;
// Root level pages (parentId is null)
addPagesToZipRecursive(zip, null, allPages);
try {
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${zipName}.zip`);
} catch (e) {
console.error("Failed to generate backup zip", e);
throw e;
}
}
// Helper to recursively add pages to zip
function addPagesToZipRecursive(currentFolder: JSZip, parentId: string | null, allPages: Page[]) {
const children = allPages.filter(p => p.parentId === parentId);
children.forEach(page => {
const cleanTitle = sanitizeFilename(page.title);
if (page.type === 'folder') {
const subFolder = currentFolder.folder(cleanTitle);
if (subFolder) {
// Determine if we should look for children with this page's ID
addPagesToZipRecursive(subFolder, page.id, allPages);
}
} else {
const markdown = pageToMarkdown(page);
// Handle duplicate filenames by appending ID if necessary?
// For simplicity, we assume titles are unique enough or zip handles overwrite (last wins).
// Better: just append a counter if exists, but JSZip might overwrite.
currentFolder.file(`${cleanTitle}.md`, markdown);
}
});
}