import TurndownService from "turndown"; import JSZip from "jszip"; import { saveAs } from "file-saver"; import { Page } from "./store"; import { useSettingsStore } from "./settings-store"; import { gfm } from "turndown-plugin-gfm"; // Initialize Turndown service const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", }); // Use GFM plugin for tables turndownService.use(gfm); // Configure Turndown to handle some common elements better if needed turndownService.addRule('strikethrough', { // eslint-disable-next-line @typescript-eslint/no-explicit-any filter: ['del', 's', 'strike'] as any, replacement: function (content: string) { return '~' + content + '~'; } }); // Task List Support (Simple) turndownService.addRule('taskListItems', { filter: function (node: HTMLElement) { return node.nodeName === 'LI' && node.className.includes('task-list-item'); }, replacement: function (content: string, node: HTMLElement) { const isChecked = node.getAttribute('data-checked') === 'true'; return (isChecked ? '- [x] ' : '- [ ] ') + content.replace(/^- /, '') + '\n'; } }); /** * Converts HTML content to Markdown */ export function htmlToMarkdown(html: string): string { if (!html) return ""; return turndownService.turndown(html); } // Helper to sanitize filenames function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*]/g, '_').trim(); } // 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); } }); }