From d060e996986040e024872f2bd17e8513b45f1972 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 21:29:40 -0800 Subject: [PATCH] feat: add plain Markdown export/import API endpoints Export notes as .md files with YAML frontmatter (single or ZIP batch). Import .md files or ZIP archives with frontmatter parsing and dual-write. Co-Authored-By: Claude Opus 4.6 --- .../tasks/task-8 - Markdown-export-import.md | 28 +- src/app/api/export/markdown/route.ts | 180 +++++++++++ src/app/api/import/markdown/route.ts | 304 ++++++++++++++++++ 3 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 src/app/api/export/markdown/route.ts create mode 100644 src/app/api/import/markdown/route.ts diff --git a/backlog/tasks/task-8 - Markdown-export-import.md b/backlog/tasks/task-8 - Markdown-export-import.md index 8d731fa..1f5edd5 100644 --- a/backlog/tasks/task-8 - Markdown-export-import.md +++ b/backlog/tasks/task-8 - Markdown-export-import.md @@ -1,9 +1,10 @@ --- id: TASK-8 title: Markdown export/import -status: To Do +status: Done assignee: [] created_date: '2026-02-13 20:39' +updated_date: '2026-02-25 05:19' labels: [] dependencies: [] priority: low @@ -14,3 +15,28 @@ priority: low Export notes as .md files, import .md files as notes. Batch export notebooks as zip of markdown files. + +## Final Summary + + +Implemented plain Markdown export/import for rNotes. + +## Export (`GET /api/export/markdown`) +- **Single note**: `?noteId=` returns a `.md` file with YAML frontmatter (type, tags, url, notebook, dates) and markdown body +- **Batch**: Returns a ZIP archive of all user notes as `notes/*.md` + `attachments/*` +- **Notebook filter**: `?notebookId=` exports only that notebook's notes +- Uses stored `bodyMarkdown` or converts from TipTap JSON via `tipTapJsonToMarkdown()` + +## Import (`POST /api/import/markdown`) +- Accepts multiple `.md` files and/or `.zip` archives via multipart form +- Parses YAML frontmatter for metadata (type, tags, url, language, pinned) +- Extracts title from first `# heading` or filename +- Dual-write: converts markdown → TipTap JSON → HTML for full format coverage +- Creates tags automatically via upsert +- ZIP imports also handle `attachments/` and `assets/` directories +- Optional `notebookId` form field to assign imported notes to a notebook + +## Files +- `src/app/api/export/markdown/route.ts` (new) +- `src/app/api/import/markdown/route.ts` (new) + diff --git a/src/app/api/export/markdown/route.ts b/src/app/api/export/markdown/route.ts new file mode 100644 index 0000000..140b4de --- /dev/null +++ b/src/app/api/export/markdown/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed } from '@/lib/auth'; +import { tipTapJsonToMarkdown } from '@/lib/content-convert'; +import archiver from 'archiver'; +import { readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; + +/** Build YAML frontmatter from note metadata */ +function buildFrontmatter(note: { + id: string; + type: string; + cardType: string; + url: string | null; + language: string | null; + isPinned: boolean; + createdAt: Date; + updatedAt: Date; + tags: { tag: { name: string } }[]; + notebook?: { title: string; slug: string } | null; +}): string { + const lines: string[] = ['---']; + + lines.push(`type: ${note.cardType}`); + if (note.url) lines.push(`url: ${note.url}`); + if (note.language) lines.push(`language: ${note.language}`); + if (note.isPinned) lines.push(`pinned: true`); + if (note.notebook) lines.push(`notebook: ${note.notebook.title}`); + + if (note.tags.length > 0) { + lines.push(`tags:`); + for (const t of note.tags) { + lines.push(` - ${t.tag.name}`); + } + } + + lines.push(`created: ${note.createdAt.toISOString()}`); + lines.push(`updated: ${note.updatedAt.toISOString()}`); + lines.push('---'); + + return lines.join('\n'); +} + +/** Extract markdown body from a note, preferring bodyMarkdown */ +function getMarkdownBody(note: { + bodyMarkdown: string | null; + bodyJson: unknown; + contentPlain: string | null; + content: string; +}): string { + // Prefer the stored markdown + if (note.bodyMarkdown) return note.bodyMarkdown; + + // Convert from TipTap JSON if available + if (note.bodyJson && typeof note.bodyJson === 'object') { + try { + return tipTapJsonToMarkdown(note.bodyJson as Parameters[0]); + } catch { + // fall through + } + } + + // Fall back to plain text + return note.contentPlain || note.content || ''; +} + +/** Sanitize title to a safe filename */ +function sanitizeFilename(title: string): string { + return title + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200) || 'untitled'; +} + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + + const { searchParams } = new URL(request.url); + const notebookId = searchParams.get('notebookId'); + const noteId = searchParams.get('noteId'); + + // --- Single note export --- + if (noteId) { + const note = await prisma.note.findFirst({ + where: { id: noteId, authorId: user.id, archivedAt: null }, + include: { + tags: { include: { tag: true } }, + notebook: { select: { title: true, slug: true } }, + }, + }); + + if (!note) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + + const frontmatter = buildFrontmatter(note); + const body = getMarkdownBody(note); + const md = `${frontmatter}\n\n# ${note.title}\n\n${body}\n`; + + const filename = sanitizeFilename(note.title) + '.md'; + + return new NextResponse(md, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } + + // --- Batch export as ZIP --- + const where: Record = { + authorId: user.id, + archivedAt: null, + }; + if (notebookId) where.notebookId = notebookId; + + const notes = await prisma.note.findMany({ + where, + include: { + tags: { include: { tag: true } }, + notebook: { select: { title: true, slug: true } }, + attachments: { include: { file: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + const archive = archiver('zip', { zlib: { level: 6 } }); + const chunks: Buffer[] = []; + archive.on('data', (chunk: Buffer) => chunks.push(chunk)); + + const usedFilenames = new Set(); + + for (const note of notes) { + const frontmatter = buildFrontmatter(note); + const body = getMarkdownBody(note); + const md = `${frontmatter}\n\n# ${note.title}\n\n${body}\n`; + + let filename = sanitizeFilename(note.title); + if (usedFilenames.has(filename)) { + filename = `${filename}_${note.id.slice(0, 6)}`; + } + usedFilenames.add(filename); + + archive.append(md, { name: `notes/${filename}.md` }); + + // Include attachments + for (const att of note.attachments) { + const filePath = path.join(UPLOAD_DIR, att.file.storageKey); + if (existsSync(filePath)) { + const fileData = await readFile(filePath); + archive.append(fileData, { name: `attachments/${att.file.storageKey}` }); + } + } + } + + await archive.finalize(); + + const buffer = Buffer.concat(chunks); + const zipName = notebookId ? 'rnotes-notebook-export.zip' : 'rnotes-export.zip'; + + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${zipName}"`, + }, + }); + } catch (error) { + console.error('Markdown export error:', error); + return NextResponse.json({ error: 'Failed to export' }, { status: 500 }); + } +} diff --git a/src/app/api/import/markdown/route.ts b/src/app/api/import/markdown/route.ts new file mode 100644 index 0000000..446aea5 --- /dev/null +++ b/src/app/api/import/markdown/route.ts @@ -0,0 +1,304 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed } from '@/lib/auth'; +import { markdownToTipTapJson, tipTapJsonToHtml } from '@/lib/content-convert'; +import { stripHtml } from '@/lib/strip-html'; +import { writeFile, mkdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { nanoid } from 'nanoid'; + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; + +// ─── ZIP extraction (reused from logseq import) ───────────────────── + +async function extractZip(buffer: Buffer): Promise> { + const entries = new Map(); + let offset = 0; + + while (offset < buffer.length - 4) { + if (buffer.readUInt32LE(offset) !== 0x04034b50) break; + + const compressionMethod = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const filenameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + + const filename = buffer.toString('utf8', offset + 30, offset + 30 + filenameLength); + const dataStart = offset + 30 + filenameLength + extraLength; + + if (compressedSize > 0 && !filename.endsWith('/')) { + const compressedData = buffer.subarray(dataStart, dataStart + compressedSize); + + if (compressionMethod === 0) { + entries.set(filename, Buffer.from(compressedData)); + } else if (compressionMethod === 8) { + const zlib = await import('zlib'); + try { + const inflated = zlib.inflateRawSync(compressedData); + entries.set(filename, inflated); + } catch { + // Skip corrupted entries + } + } + } + + offset = dataStart + compressedSize; + } + + return entries; +} + +// ─── YAML frontmatter parser ───────────────────────────────────────── + +interface Frontmatter { + type?: string; + url?: string; + language?: string; + pinned?: boolean; + notebook?: string; + tags?: string[]; + created?: string; + updated?: string; +} + +function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: string } { + const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/; + const match = content.match(fmRegex); + + if (!match) { + return { frontmatter: {}, body: content }; + } + + const yamlBlock = match[1]; + const body = content.slice(match[0].length); + const fm: Frontmatter = {}; + + // Simple YAML key: value parser (handles scalar values and tag lists) + const lines = yamlBlock.split('\n'); + let currentKey: string | null = null; + let tagList: string[] = []; + + for (const line of lines) { + const scalarMatch = line.match(/^(\w+):\s*(.+)$/); + const listKeyMatch = line.match(/^(\w+):\s*$/); + const listItemMatch = line.match(/^\s+-\s+(.+)$/); + + if (scalarMatch) { + const [, key, value] = scalarMatch; + currentKey = key; + switch (key) { + case 'type': fm.type = value; break; + case 'url': fm.url = value; break; + case 'language': fm.language = value; break; + case 'pinned': fm.pinned = value === 'true'; break; + case 'notebook': fm.notebook = value; break; + case 'created': fm.created = value; break; + case 'updated': fm.updated = value; break; + } + } else if (listKeyMatch) { + currentKey = listKeyMatch[1]; + if (currentKey === 'tags') tagList = []; + } else if (listItemMatch && currentKey === 'tags') { + tagList.push(listItemMatch[1].trim().toLowerCase()); + } + } + + if (tagList.length > 0) fm.tags = tagList; + + return { frontmatter: fm, body }; +} + +/** Extract title from first # heading or filename */ +function extractTitle(body: string, filename: string): { title: string; bodyWithoutTitle: string } { + const headingMatch = body.match(/^\s*#\s+(.+)\s*\n/); + if (headingMatch) { + return { + title: headingMatch[1].trim(), + bodyWithoutTitle: body.slice(headingMatch[0].length).trimStart(), + }; + } + // Fall back to filename without extension + return { + title: filename.replace(/\.md$/i, '').replace(/[_-]/g, ' '), + bodyWithoutTitle: body, + }; +} + +function cardTypeToNoteType(cardType: string): 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO' { + const map: Record = { + note: 'NOTE', + link: 'BOOKMARK', + reference: 'CLIP', + file: 'FILE', + task: 'NOTE', + person: 'NOTE', + idea: 'NOTE', + }; + return map[cardType] || 'NOTE'; +} + +function guessMimeType(ext: string): string { + const map: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', + '.json': 'application/json', '.csv': 'text/csv', + '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', + '.webm': 'audio/webm', '.mp4': 'audio/mp4', + }; + return map[ext.toLowerCase()] || 'application/octet-stream'; +} + +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + + const formData = await request.formData(); + const files = formData.getAll('file') as File[]; + const notebookId = formData.get('notebookId') as string | null; + + if (files.length === 0) { + return NextResponse.json({ error: 'No files provided' }, { status: 400 }); + } + + // Ensure upload directory exists + if (!existsSync(UPLOAD_DIR)) { + await mkdir(UPLOAD_DIR, { recursive: true }); + } + + const importedNotes: { title: string; id: string }[] = []; + + for (const file of files) { + if (file.name.endsWith('.zip')) { + // ── ZIP of markdown files ── + const buffer = Buffer.from(await file.arrayBuffer()); + const entries = await extractZip(buffer); + + // First pass: extract attachment files + const assetFiles = new Map(); + for (const [name, data] of Array.from(entries.entries())) { + if ((name.startsWith('attachments/') || name.startsWith('assets/')) && data.length > 0) { + const assetName = name.replace(/^(attachments|assets)\//, ''); + const ext = path.extname(assetName); + const storageKey = `${nanoid(12)}_${assetName.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const filePath = path.join(UPLOAD_DIR, storageKey); + await writeFile(filePath, data); + + await prisma.file.create({ + data: { + storageKey, + filename: assetName, + mimeType: guessMimeType(ext), + sizeBytes: data.length, + authorId: user.id, + }, + }); + + assetFiles.set(assetName, storageKey); + } + } + + // Second pass: import markdown files + for (const [name, data] of Array.from(entries.entries())) { + if (name.endsWith('.md') && data.length > 0) { + const filename = path.basename(name); + const content = data.toString('utf8'); + const note = await importMarkdownNote(content, filename, user.id, notebookId); + if (note) importedNotes.push(note); + } + } + } else if (file.name.endsWith('.md')) { + // ── Single .md file ── + const content = await file.text(); + const note = await importMarkdownNote(content, file.name, user.id, notebookId); + if (note) importedNotes.push(note); + } else { + // Skip non-markdown files + continue; + } + } + + return NextResponse.json({ + imported: importedNotes.length, + notes: importedNotes, + }); + } catch (error) { + console.error('Markdown import error:', error); + return NextResponse.json({ error: 'Failed to import' }, { status: 500 }); + } +} + +/** Import a single markdown string as a note */ +async function importMarkdownNote( + content: string, + filename: string, + authorId: string, + notebookId: string | null, +): Promise<{ title: string; id: string } | null> { + const { frontmatter, body } = parseFrontmatter(content); + const { title, bodyWithoutTitle } = extractTitle(body, filename); + + if (!title.trim()) return null; + + const bodyMarkdown = bodyWithoutTitle.trim(); + const cardType = frontmatter.type || 'note'; + const noteType = cardTypeToNoteType(cardType); + + // Convert markdown → TipTap JSON → HTML for dual-write + let bodyJson = null; + let htmlContent = ''; + try { + bodyJson = await markdownToTipTapJson(bodyMarkdown); + htmlContent = tipTapJsonToHtml(bodyJson); + } catch { + htmlContent = bodyMarkdown + .split('\n\n') + .map((p) => `

${p.replace(/\n/g, '
')}

`) + .join(''); + } + + const contentPlain = stripHtml(htmlContent); + + // Find or create tags + const tagRecords = []; + if (frontmatter.tags) { + for (const tagName of frontmatter.tags) { + const name = tagName.trim().toLowerCase(); + if (!name) continue; + const tag = await prisma.tag.upsert({ + where: { spaceId_name: { spaceId: '', name } }, + update: {}, + create: { name, spaceId: '' }, + }); + tagRecords.push(tag); + } + } + + const note = await prisma.note.create({ + data: { + title: title.trim(), + content: htmlContent, + contentPlain, + bodyMarkdown, + bodyJson: bodyJson ? JSON.parse(JSON.stringify(bodyJson)) : undefined, + bodyFormat: 'markdown', + cardType, + visibility: 'private', + properties: {}, + type: noteType, + authorId, + notebookId: notebookId || null, + url: frontmatter.url || null, + language: frontmatter.language || null, + isPinned: frontmatter.pinned || false, + tags: { + create: tagRecords.map((tag) => ({ tagId: tag.id })), + }, + }, + }); + + return { title: note.title, id: note.id }; +}