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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 21:29:40 -08:00
parent f4b453183e
commit d060e99698
3 changed files with 511 additions and 1 deletions

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Export notes as .md files, import .md files as notes. Batch export notebooks as zip of markdown files.
<!-- SECTION:DESCRIPTION:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented plain Markdown export/import for rNotes.
## Export (`GET /api/export/markdown`)
- **Single note**: `?noteId=<id>` 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=<id>` 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)
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -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<typeof tipTapJsonToMarkdown>[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<string, unknown> = {
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<string>();
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 });
}
}

View File

@ -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<Map<string, Buffer>> {
const entries = new Map<string, Buffer>();
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<string, 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO'> = {
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<string, string> = {
'.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<string, string>();
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>${p.replace(/\n/g, '<br>')}</p>`)
.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 };
}