nodepad-hypotheses / lib /export.ts
Leon4gr45's picture
Upload folder using huggingface_hub
bfe95fb verified
Raw History Blame Contribute Delete
11.2 kB
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<ContentType, { heading: string; emoji: string; description: string }> = {
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<ContentType, ExportBlock[]>()
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<boolean> {
try {
await navigator.clipboard.writeText(content)
return true
} catch {
return false
}
}