import type { ContentType } from "./content-types" // Escape characters that would break markdown table cells const mdCell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ") export interface ExportBlock { id: string text: string contentType: ContentType category?: string annotation?: string confidence?: number | null sources?: { url: string; title: string; siteName: string }[] isPinned?: boolean timestamp?: number } // ── Type ordering — research-logical flow ───────────────────────────────────── const TYPE_ORDER: ContentType[] = [ "thesis", "claim", "question", "idea", "task", "entity", "definition", "reference", "quote", "opinion", "reflection", "narrative", "comparison", "general", ] const TYPE_META: Record = { thesis: { heading: "Thesis", emoji: "🔬", description: "Synthesised conclusions" }, claim: { heading: "Claims", emoji: "⚡", description: "Assertions with confidence scores" }, question: { heading: "Questions", emoji: "❓", description: "Open threads and unknowns" }, idea: { heading: "Ideas", emoji: "💡", description: "Raw concepts and hunches" }, task: { heading: "Tasks", emoji: "✅", description: "Action items" }, entity: { heading: "Entities", emoji: "🏷️", description: "People, places, and named concepts" }, definition: { heading: "Definitions", emoji: "📖", description: "Terminology and explanations" }, reference: { heading: "References", emoji: "🔗", description: "Sources and links" }, quote: { heading: "Quotes", emoji: "💬", description: "Direct quotations" }, opinion: { heading: "Opinions", emoji: "🗣️", description: "Subjective takes" }, reflection: { heading: "Reflections", emoji: "🪞", description: "Personal observations" }, narrative: { heading: "Narratives", emoji: "📜", description: "Extended accounts" }, comparison: { heading: "Comparisons", emoji: "⚖️", description: "Contrasts and parallels" }, general: { heading: "Notes", emoji: "📝", description: "Miscellaneous notes" }, } // ── Helpers ─────────────────────────────────────────────────────────────────── function formatDate(ts?: number): string { const d = ts ? new Date(ts) : new Date() return d.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }) } function formatTime(ts?: number): string { if (!ts) return "" return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) } /** Five-block filled/empty bar + percentage */ function confidenceBar(c: number): string { const clamped = Math.min(100, Math.max(0, c)) const pct = Math.round(clamped) const filled = Math.round((clamped / 100) * 5) const bar = "█".repeat(filled) + "░".repeat(5 - filled) return `${bar} ${pct}%` } /** Anchor-safe slug for TOC links */ function slug(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .trim() .replace(/\s+/g, "-") .slice(0, 50) } // ── Main export ─────────────────────────────────────────────────────────────── /** * Export a project as a richly-formatted Markdown document. * Uses YAML front matter, a table of contents, grouped sections, * tables for claims, task lists, blockquotes, inline code, and source links * so the output renders well in GitHub, Obsidian, Notion, and any LLM context. */ export function exportToMarkdown(projectName: string, blocks: ExportBlock[]): string { if (blocks.length === 0) { return [ `# ${projectName}`, ``, `> _No nodes yet._`, ``, `---`, `*Generated by [nodepad](https://nodepad.space)*`, ].join("\n") } // Group by type const byType = new Map() for (const block of blocks) { const t = block.contentType || "general" if (!byType.has(t)) byType.set(t, []) byType.get(t)!.push(block) } const presentTypes = TYPE_ORDER.filter(t => (byType.get(t)?.length ?? 0) > 0) const date = formatDate(blocks[0]?.timestamp) const pinned = blocks.filter(b => b.isPinned) const lines: string[] = [] // ── YAML front matter ───────────────────────────────────────────────────── lines.push(`---`) lines.push(`title: "${projectName}"`) lines.push(`date: "${date}"`) lines.push(`nodes: ${blocks.length}`) lines.push(`types: [${presentTypes.map(t => `"${t}"`).join(", ")}]`) lines.push(`source: nodepad — https://nodepad.space`) lines.push(`---`) lines.push(``) // ── Title block ─────────────────────────────────────────────────────────── lines.push(`# ${projectName}`) lines.push(``) lines.push(`> **Generated by [nodepad](https://nodepad.space)** · ${date} · ${blocks.length} node${blocks.length !== 1 ? "s" : ""}`) lines.push(``) // ── Stats table ─────────────────────────────────────────────────────────── lines.push(`## Overview`) lines.push(``) lines.push(`| Type | Count |`) lines.push(`|------|-------|`) for (const type of presentTypes) { const { emoji, heading } = TYPE_META[type] lines.push(`| ${emoji} ${heading} | ${byType.get(type)!.length} |`) } lines.push(``) // ── Pinned notes callout (if any) ───────────────────────────────────────── if (pinned.length > 0) { lines.push(`> **📌 Pinned nodes**`) for (const b of pinned) { lines.push(`> - ${b.text}`) } lines.push(``) } // ── Table of contents ───────────────────────────────────────────────────── lines.push(`## Contents`) lines.push(``) for (const type of presentTypes) { const { emoji, heading } = TYPE_META[type] const count = byType.get(type)!.length lines.push(`- [${emoji} ${heading}](#${slug(heading)}) *(${count})*`) } lines.push(``) // ── Sections ────────────────────────────────────────────────────────────── for (const type of presentTypes) { const group = byType.get(type)! const { emoji, heading, description } = TYPE_META[type] lines.push(`---`) lines.push(``) lines.push(`## ${emoji} ${heading}`) lines.push(``) lines.push(`*${description}*`) lines.push(``) // Claims → table for scannability if (type === "claim") { lines.push(`| Claim | Category | Confidence | AI Annotation |`) lines.push(`|-------|----------|------------|---------------|`) for (const block of group) { const cat = block.category ? `\`${mdCell(block.category)}\`` : "—" const conf = block.confidence != null ? `\`${confidenceBar(block.confidence)}\`` : "—" const annot = block.annotation ? block.annotation.replace(/\n/g, " ").replace(/\|/g, "\\|").slice(0, 120) + (block.annotation.length > 120 ? "…" : "") : "—" lines.push(`| **${block.text.replace(/\|/g, "\\|")}** | ${cat} | ${conf} | ${annot} |`) } lines.push(``) continue } // Tasks → GFM task list if (type === "task") { for (const block of group) { lines.push(`- [ ] **${block.text}**`) if (block.category) lines.push(` - Category: \`${block.category}\``) if (block.annotation) { lines.push(` - > ${block.annotation.replace(/\n/g, " ")}`) } } lines.push(``) continue } // Quotes → blockquote with attribution styling if (type === "quote") { for (const block of group) { lines.push(`> ${block.text}`) if (block.category) lines.push(`>`) if (block.category) lines.push(`> — \`${block.category}\``) if (block.annotation) { lines.push(`>`) lines.push(`> *${block.annotation.replace(/\n/g, " ")}*`) } lines.push(``) } continue } // All other types → heading + metadata + annotation for (const block of group) { lines.push(`### ${block.text}`) lines.push(``) // Metadata row const meta: string[] = [] if (block.category) meta.push(`**Category:** \`${block.category}\``) if (block.timestamp) meta.push(`**Added:** ${formatDate(block.timestamp)} ${formatTime(block.timestamp)}`) if (block.isPinned) meta.push(`📌 *Pinned*`) if (meta.length > 0) { lines.push(meta.join(" · ")) lines.push(``) } // AI annotation as blockquote if (block.annotation) { lines.push(`> **AI Annotation**`) for (const l of block.annotation.split("\n")) { lines.push(`> ${l}`) } lines.push(``) } // Sources as a nested list if (block.sources && block.sources.length > 0) { lines.push(`**Sources**`) lines.push(``) for (const s of block.sources) { const label = s.title || s.siteName || s.url lines.push(`- [${label}](${s.url})${s.siteName ? ` *(${s.siteName})*` : ""}`) } lines.push(``) } } } // ── Footer ──────────────────────────────────────────────────────────────── lines.push(`---`) lines.push(``) lines.push(`*Generated by [nodepad](https://nodepad.space) · ${date}*`) lines.push(``) return lines.join("\n") } // ── Download / clipboard helpers ───────────────────────────────────────────── /** Trigger a browser download of a .md file. */ export function downloadMarkdown(filename: string, content: string): void { const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } /** Copy content to clipboard. Returns true on success. */ export async function copyToClipboard(content: string): Promise { try { await navigator.clipboard.writeText(content) return true } catch { return false } }