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:
parent
f4b453183e
commit
d060e99698
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue