feat: implement Memory Card spec — hierarchy, dual-format body, Logseq interop

Evolve rNotes schema and API to support the Memory Card data model:
- Add parentId self-relation for note hierarchy (NoteTree)
- Dual-format body storage (bodyJson + bodyMarkdown) with HTML fallback
- New cardType field (note/link/file/task/person/idea/reference)
- Structured properties (Logseq-compatible key-value JSON)
- Soft-delete via archivedAt (FUN model: Forget, not Delete)
- File + CardAttachment models for structured attachments
- Tag model evolved with spaceId for scoped tags
- Content conversion library (HTML/JSON/Markdown bidirectional)
- Logseq import/export (ZIP with pages/ + assets/)
- NoteEditor emits TipTap JSON as canonical format
- NoteCard shows cardType badges, child count, properties
- Canvas sync enriched with Memory Card fields
- Backfill script for existing notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-22 22:33:48 +00:00
parent adc68d06e1
commit 7b1d120379
22 changed files with 1995 additions and 66 deletions

View File

@ -30,6 +30,8 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/src/lib/content-convert.ts ./src/lib/content-convert.ts
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma

View File

@ -14,6 +14,7 @@
"dependencies": {
"@encryptid/sdk": "file:../encryptid-sdk",
"@prisma/client": "^6.19.2",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
@ -23,6 +24,7 @@
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"archiver": "^7.0.0",
"dompurify": "^3.2.0",
"lowlight": "^3.3.0",
"marked": "^15.0.0",
@ -33,6 +35,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/archiver": "^6",
"@types/dompurify": "^3",
"@types/node": "^20",
"@types/react": "^18",

View File

@ -18,6 +18,7 @@ model User {
notebooks NotebookCollaborator[]
notes Note[]
files File[]
sharedByMe SharedAccess[] @relation("SharedBy")
}
@ -86,12 +87,31 @@ model Note {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags NoteTag[]
// ─── Memory Card fields ─────────────────────────────────────────
parentId String?
parent Note? @relation("NoteTree", fields: [parentId], references: [id], onDelete: SetNull)
children Note[] @relation("NoteTree")
bodyJson Json? // TipTap JSON (canonical format)
bodyMarkdown String? @db.Text // portable markdown for search + Logseq
bodyFormat String @default("html") // "html" | "markdown" | "blocks"
cardType String @default("note") // note|link|file|task|person|idea|reference
summary String? // auto or manual
visibility String @default("private") // private|space|public
position Float? // fractional ordering
properties Json @default("{}") // Logseq-compatible key-value
archivedAt DateTime? // soft-delete
tags NoteTag[]
attachments CardAttachment[]
@@index([notebookId])
@@index([authorId])
@@index([type])
@@index([isPinned])
@@index([parentId])
@@index([cardType])
@@index([archivedAt])
@@index([position])
}
enum NoteType {
@ -104,15 +124,52 @@ enum NoteType {
AUDIO
}
// ─── Files & Attachments ────────────────────────────────────────────
model File {
id String @id @default(cuid())
storageKey String @unique // unique filename on disk
filename String // original filename
mimeType String
sizeBytes Int
checksum String?
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
attachments CardAttachment[]
}
model CardAttachment {
id String @id @default(cuid())
noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
fileId String
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
role String @default("supporting") // "primary"|"preview"|"supporting"
caption String?
position Float @default(0)
createdAt DateTime @default(now())
@@unique([noteId, fileId])
@@index([noteId])
@@index([fileId])
}
// ─── Tags ───────────────────────────────────────────────────────────
model Tag {
id String @id @default(cuid())
name String @unique
name String
color String? @default("#6b7280")
spaceId String @default("") // "" = global, otherwise space-scoped
schema Json?
createdAt DateTime @default(now())
notes NoteTag[]
@@unique([spaceId, name])
@@index([spaceId])
}
model NoteTag {

View File

@ -0,0 +1,158 @@
/**
* Backfill script: Populate Memory Card fields for existing notes.
*
* Processes all notes where bodyJson IS NULL:
* 1. htmlToTipTapJson(content) bodyJson
* 2. tipTapJsonToMarkdown(bodyJson) bodyMarkdown
* 3. mapNoteTypeToCardType(type) cardType
* 4. sortOrder * 1.0 position
* 5. bodyFormat = 'html'
*
* Also backfills fileUrl references into File + CardAttachment records.
*
* Run: docker exec rnotes-online npx tsx scripts/backfill-memory-card.ts
*/
import { PrismaClient } from '@prisma/client';
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '../src/lib/content-convert';
const prisma = new PrismaClient();
const BATCH_SIZE = 50;
async function backfillNotes() {
console.log('=== Memory Card Backfill ===\n');
// Count notes needing backfill
const total = await prisma.note.count({
where: { bodyJson: null, content: { not: '' } },
});
console.log(`Found ${total} notes to backfill.\n`);
if (total === 0) {
console.log('Nothing to do!');
return;
}
let processed = 0;
let errors = 0;
while (true) {
const notes = await prisma.note.findMany({
where: { bodyJson: null, content: { not: '' } },
select: { id: true, content: true, type: true, sortOrder: true, fileUrl: true, mimeType: true, fileSize: true, authorId: true },
take: BATCH_SIZE,
});
if (notes.length === 0) break;
for (const note of notes) {
try {
const bodyJson = htmlToTipTapJson(note.content);
const bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
const cardType = mapNoteTypeToCardType(note.type);
await prisma.note.update({
where: { id: note.id },
data: {
bodyJson,
bodyMarkdown,
bodyFormat: 'html',
cardType,
position: note.sortOrder * 1.0,
},
});
// Backfill fileUrl → File + CardAttachment
if (note.fileUrl) {
const storageKey = note.fileUrl.replace(/^\/api\/uploads\//, '');
if (storageKey && storageKey !== note.fileUrl) {
// Check if File record already exists
const existingFile = await prisma.file.findUnique({
where: { storageKey },
});
if (!existingFile) {
const file = await prisma.file.create({
data: {
storageKey,
filename: storageKey.replace(/^[a-zA-Z0-9_-]+_/, ''), // strip nanoid prefix
mimeType: note.mimeType || 'application/octet-stream',
sizeBytes: note.fileSize || 0,
authorId: note.authorId,
},
});
await prisma.cardAttachment.create({
data: {
noteId: note.id,
fileId: file.id,
role: 'primary',
position: 0,
},
});
} else {
// File exists, just link it
const existingLink = await prisma.cardAttachment.findUnique({
where: { noteId_fileId: { noteId: note.id, fileId: existingFile.id } },
});
if (!existingLink) {
await prisma.cardAttachment.create({
data: {
noteId: note.id,
fileId: existingFile.id,
role: 'primary',
position: 0,
},
});
}
}
}
}
processed++;
} catch (err) {
errors++;
console.error(` Error on note ${note.id}:`, err);
}
}
console.log(` Processed ${processed}/${total} (${errors} errors)`);
}
// Also backfill notes with empty content (set bodyJson to empty doc)
const emptyNotes = await prisma.note.count({
where: { bodyJson: null, content: '' },
});
if (emptyNotes > 0) {
await prisma.note.updateMany({
where: { bodyJson: null, content: '' },
data: {
bodyJson: { type: 'doc', content: [] },
bodyMarkdown: '',
bodyFormat: 'html',
cardType: 'note',
},
});
console.log(` Set ${emptyNotes} empty notes to empty doc.`);
}
// Update tags: set spaceId to '' where null
const nullSpaceTags = await prisma.$executeRaw`
UPDATE "Tag" SET "spaceId" = '' WHERE "spaceId" IS NULL
`;
if (nullSpaceTags > 0) {
console.log(` Updated ${nullSpaceTags} tags with null spaceId to ''.`);
}
console.log(`\n=== Done! ${processed} notes backfilled, ${errors} errors ===`);
// Verify
const remaining = await prisma.note.count({
where: { bodyJson: null, content: { not: '' } },
});
console.log(`Remaining unprocessed notes: ${remaining}`);
}
backfillNotes()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAuth, isAuthed } from '@/lib/auth';
import { noteToLogseqPage, sanitizeLogseqFilename } from '@/lib/logseq-format';
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';
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');
// Fetch notes
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 } },
children: {
select: { id: true, title: true, cardType: true },
where: { archivedAt: null },
},
attachments: {
include: { file: true },
},
},
orderBy: { updatedAt: 'desc' },
});
// Build ZIP archive
const archive = archiver('zip', { zlib: { level: 6 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
// Create pages directory
const usedFilenames = new Set<string>();
for (const note of notes) {
// Generate Logseq page content
const pageContent = noteToLogseqPage({
title: note.title,
cardType: note.cardType,
visibility: note.visibility,
bodyMarkdown: note.bodyMarkdown,
contentPlain: note.contentPlain,
properties: note.properties as Record<string, unknown>,
tags: note.tags,
children: note.children,
attachments: note.attachments.map((a) => ({
file: { storageKey: a.file.storageKey, filename: a.file.filename },
caption: a.caption,
})),
});
// Unique filename
let filename = sanitizeLogseqFilename(note.title);
if (usedFilenames.has(filename)) {
filename = `${filename}_${note.id.slice(0, 6)}`;
}
usedFilenames.add(filename);
archive.append(pageContent, { name: `pages/${filename}.md` });
// Copy attachments into assets/
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: `assets/${att.file.storageKey}` });
}
}
}
// Add logseq config
archive.append(JSON.stringify({
"meta/version": 2,
"block/journal?": false,
}, null, 2), { name: 'logseq/config.edn' });
await archive.finalize();
const buffer = Buffer.concat(chunks);
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="rnotes-logseq-export.zip"`,
},
});
} catch (error) {
console.error('Logseq export error:', error);
return NextResponse.json({ error: 'Failed to export' }, { status: 500 });
}
}

View File

@ -0,0 +1,231 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAuth, isAuthed } from '@/lib/auth';
import { logseqPageToNote } from '@/lib/logseq-format';
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';
// Simple unzip using built-in Node.js zlib
async function extractZip(buffer: Buffer): Promise<Map<string, Buffer>> {
const entries = new Map<string, Buffer>();
// Manual ZIP parsing (PKZip format)
let offset = 0;
while (offset < buffer.length - 4) {
// Local file header signature
if (buffer.readUInt32LE(offset) !== 0x04034b50) break;
const compressionMethod = buffer.readUInt16LE(offset + 8);
const compressedSize = buffer.readUInt32LE(offset + 18);
const uncompressedSize = buffer.readUInt32LE(offset + 22);
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) {
// Stored (no compression)
entries.set(filename, Buffer.from(compressedData));
} else if (compressionMethod === 8) {
// Deflate
const zlib = await import('zlib');
try {
const inflated = zlib.inflateRawSync(compressedData);
entries.set(filename, inflated);
} catch {
// Skip corrupted entries
}
}
}
offset = dataStart + compressedSize;
}
return entries;
}
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 file = formData.get('file') as File | null;
const notebookId = formData.get('notebookId') as string | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!file.name.endsWith('.zip')) {
return NextResponse.json({ error: 'File must be a ZIP archive' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const entries = await extractZip(buffer);
// Ensure upload directory exists
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
// Phase 1: Extract assets
const assetFiles = new Map<string, string>(); // original path → storageKey
for (const [name, data] of entries) {
if (name.startsWith('assets/') && data.length > 0) {
const assetName = name.replace('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);
// Create File record
await prisma.file.create({
data: {
storageKey,
filename: assetName,
mimeType: guessMimeType(ext),
sizeBytes: data.length,
authorId: user.id,
},
});
assetFiles.set(assetName, storageKey);
}
}
// Phase 2: Parse pages
const importedNotes: { filename: string; parsed: ReturnType<typeof logseqPageToNote>; noteId?: string }[] = [];
for (const [name, data] of entries) {
if (name.startsWith('pages/') && name.endsWith('.md') && data.length > 0) {
const filename = name.replace('pages/', '');
const content = data.toString('utf8');
const parsed = logseqPageToNote(filename, content);
importedNotes.push({ filename, parsed });
}
}
// Phase 3: Create notes
const titleToId = new Map<string, string>();
for (const item of importedNotes) {
const { parsed } = item;
// Convert bodyMarkdown to HTML (simple)
const htmlContent = parsed.bodyMarkdown
.split('\n\n')
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('');
// Find or create tags
const tagRecords = [];
for (const tagName of parsed.tags) {
const tag = await prisma.tag.upsert({
where: { spaceId_name: { spaceId: '', name: tagName } },
update: {},
create: { name: tagName, spaceId: '' },
});
tagRecords.push(tag);
}
const note = await prisma.note.create({
data: {
title: parsed.title,
content: htmlContent,
contentPlain: parsed.bodyMarkdown,
bodyMarkdown: parsed.bodyMarkdown,
bodyFormat: 'markdown',
cardType: parsed.cardType,
visibility: parsed.visibility,
properties: parsed.properties,
type: cardTypeToNoteType(parsed.cardType),
authorId: user.id,
notebookId: notebookId || null,
tags: {
create: tagRecords.map((tag) => ({ tagId: tag.id })),
},
},
});
item.noteId = note.id;
titleToId.set(parsed.title, note.id);
// Link attachments
for (const assetPath of parsed.attachmentPaths) {
const storageKey = assetFiles.get(assetPath);
if (storageKey) {
const fileRecord = await prisma.file.findUnique({ where: { storageKey } });
if (fileRecord) {
await prisma.cardAttachment.create({
data: {
noteId: note.id,
fileId: fileRecord.id,
role: 'supporting',
position: 0,
},
});
}
}
}
}
// Phase 4: Link parent-child relationships
for (const item of importedNotes) {
if (!item.noteId) continue;
const { parsed } = item;
for (const childTitle of parsed.childTitles) {
const childId = titleToId.get(childTitle);
if (childId) {
await prisma.note.update({
where: { id: childId },
data: { parentId: item.noteId },
});
}
}
}
return NextResponse.json({
imported: importedNotes.length,
assets: assetFiles.size,
notes: importedNotes.map((n) => ({ title: n.parsed.title, id: n.noteId })),
});
} catch (error) {
console.error('Logseq import error:', error);
return NextResponse.json({ error: 'Failed to import' }, { status: 500 });
}
}
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';
}

View File

@ -3,12 +3,6 @@ import { prisma } from '@/lib/prisma';
import { pushShapesToCanvas } from '@/lib/canvas-sync';
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
/**
* POST /api/notebooks/[id]/canvas
*
* Creates an rSpace community for the notebook and populates it
* with initial shapes from the notebook's notes.
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
@ -26,7 +20,12 @@ export async function POST(
where: { id: params.id },
include: {
notes: {
include: { tags: { include: { tag: true } } },
where: { archivedAt: null },
include: {
tags: { include: { tag: true } },
children: { select: { id: true }, where: { archivedAt: null } },
attachments: { select: { id: true } },
},
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
},
},
@ -76,12 +75,20 @@ export async function POST(
url: note.url || '',
tags: note.tags.map((nt) => nt.tag.name),
noteId: note.id,
// Memory Card enrichments
cardType: note.cardType,
summary: note.summary || '',
visibility: note.visibility,
properties: note.properties || {},
parentId: note.parentId || '',
hasChildren: note.children.length > 0,
childCount: note.children.length,
attachmentCount: note.attachments.length,
});
});
await pushShapesToCanvas(canvasSlug, shapes);
// Store canvasSlug if not set
if (!notebook.canvasSlug) {
await prisma.notebook.update({
where: { id: notebook.id },

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
export async function GET(
_request: NextRequest,
@ -9,9 +10,12 @@ export async function GET(
) {
try {
const notes = await prisma.note.findMany({
where: { notebookId: params.id },
where: { notebookId: params.id, archivedAt: null },
include: {
tags: { include: { tag: true } },
parent: { select: { id: true, title: true } },
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
},
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
});
@ -37,7 +41,10 @@ export async function POST(
}
const body = await request.json();
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
const {
title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration,
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
} = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
@ -45,6 +52,23 @@ export async function POST(
const contentPlain = content ? stripHtml(content) : null;
// Dual-write
let bodyJson = clientBodyJson || null;
let bodyMarkdown: string | null = null;
let bodyFormat = 'html';
if (clientBodyJson) {
bodyJson = clientBodyJson;
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
bodyFormat = 'blocks';
} else if (content) {
bodyJson = htmlToTipTapJson(content);
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
}
const noteType = type || 'NOTE';
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
// Find or create tags
const tagRecords = [];
if (tags && Array.isArray(tags)) {
@ -52,9 +76,9 @@ export async function POST(
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
where: { spaceId_name: { spaceId: '', name } },
update: {},
create: { name },
create: { name, spaceId: '' },
});
tagRecords.push(tag);
}
@ -67,13 +91,22 @@ export async function POST(
title: title.trim(),
content: content || '',
contentPlain,
type: type || 'NOTE',
type: noteType,
url: url || null,
language: language || null,
fileUrl: fileUrl || null,
mimeType: mimeType || null,
fileSize: fileSize || null,
duration: duration || null,
bodyJson: bodyJson || undefined,
bodyMarkdown,
bodyFormat,
cardType: resolvedCardType,
parentId: parentId || null,
visibility: visibility || 'private',
properties: properties || {},
summary: summary || null,
position: position ?? null,
tags: {
create: tagRecords.map((tag) => ({
tagId: tag.id,
@ -82,6 +115,9 @@ export async function POST(
},
include: {
tags: { include: { tag: true } },
parent: { select: { id: true, title: true } },
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
},
});

View File

@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAuth, isAuthed } from '@/lib/auth';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const attachments = await prisma.cardAttachment.findMany({
where: { noteId: params.id },
include: { file: true },
orderBy: { position: 'asc' },
});
return NextResponse.json(attachments);
} catch (error) {
console.error('List attachments error:', error);
return NextResponse.json({ error: 'Failed to list attachments' }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const auth = await requireAuth(request);
if (!isAuthed(auth)) return auth;
const body = await request.json();
const { fileId, role, caption, position } = body;
if (!fileId) {
return NextResponse.json({ error: 'fileId is required' }, { status: 400 });
}
// Verify note exists
const note = await prisma.note.findUnique({
where: { id: params.id },
select: { id: true },
});
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
// Verify file exists
const file = await prisma.file.findUnique({
where: { id: fileId },
select: { id: true },
});
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const attachment = await prisma.cardAttachment.upsert({
where: { noteId_fileId: { noteId: params.id, fileId } },
update: {
role: role || 'supporting',
caption: caption || null,
position: position ?? 0,
},
create: {
noteId: params.id,
fileId,
role: role || 'supporting',
caption: caption || null,
position: position ?? 0,
},
include: { file: true },
});
return NextResponse.json(attachment, { status: 201 });
} catch (error) {
console.error('Create attachment error:', error);
return NextResponse.json({ error: 'Failed to create attachment' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const auth = await requireAuth(request);
if (!isAuthed(auth)) return auth;
const { searchParams } = new URL(request.url);
const fileId = searchParams.get('fileId');
if (!fileId) {
return NextResponse.json({ error: 'fileId query parameter required' }, { status: 400 });
}
await prisma.cardAttachment.delete({
where: { noteId_fileId: { noteId: params.id, fileId } },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Delete attachment error:', error);
return NextResponse.json({ error: 'Failed to delete attachment' }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const children = await prisma.note.findMany({
where: { parentId: params.id, archivedAt: null },
include: {
tags: { include: { tag: true } },
children: {
select: { id: true },
where: { archivedAt: null },
},
},
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
return NextResponse.json(
children.map((c) => ({
...c,
childCount: c.children.length,
children: undefined,
}))
);
} catch (error) {
console.error('List children error:', error);
return NextResponse.json({ error: 'Failed to list children' }, { status: 500 });
}
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
import { requireAuth, isAuthed } from '@/lib/auth';
import { htmlToTipTapJson, tipTapJsonToHtml, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
export async function GET(
_request: NextRequest,
@ -14,6 +15,16 @@ export async function GET(
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
author: { select: { id: true, username: true } },
parent: { select: { id: true, title: true, cardType: true } },
children: {
select: { id: true, title: true, cardType: true },
where: { archivedAt: null },
orderBy: { position: 'asc' },
},
attachments: {
include: { file: true },
orderBy: { position: 'asc' },
},
},
});
@ -37,7 +48,6 @@ export async function PUT(
if (!isAuthed(auth)) return auth;
const { user } = auth;
// Verify the user is the author
const existing = await prisma.note.findUnique({
where: { id: params.id },
select: { authorId: true },
@ -50,33 +60,61 @@ export async function PUT(
}
const body = await request.json();
const { title, content, type, url, language, isPinned, notebookId, tags } = body;
const {
title, content, type, url, language, isPinned, notebookId, tags,
// Memory Card fields
parentId, cardType, visibility, properties, summary, position,
bodyJson: clientBodyJson,
} = body;
const data: Record<string, unknown> = {};
if (title !== undefined) data.title = title.trim();
if (content !== undefined) {
data.content = content;
data.contentPlain = stripHtml(content);
}
if (type !== undefined) data.type = type;
if (url !== undefined) data.url = url || null;
if (language !== undefined) data.language = language || null;
if (isPinned !== undefined) data.isPinned = isPinned;
if (notebookId !== undefined) data.notebookId = notebookId || null;
// Memory Card field updates
if (parentId !== undefined) data.parentId = parentId || null;
if (cardType !== undefined) data.cardType = cardType;
if (visibility !== undefined) data.visibility = visibility;
if (properties !== undefined) data.properties = properties;
if (summary !== undefined) data.summary = summary || null;
if (position !== undefined) data.position = position;
// Dual-write: if client sends bodyJson, it's canonical
if (clientBodyJson) {
data.bodyJson = clientBodyJson;
data.content = tipTapJsonToHtml(clientBodyJson);
data.bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
data.contentPlain = stripHtml(data.content as string);
data.bodyFormat = 'blocks';
} else if (content !== undefined) {
// HTML content — compute all derived formats
data.content = content;
data.contentPlain = stripHtml(content);
const json = htmlToTipTapJson(content);
data.bodyJson = json;
data.bodyMarkdown = tipTapJsonToMarkdown(json);
}
// If type changed, update cardType too (unless explicitly set)
if (type !== undefined && cardType === undefined) {
data.cardType = mapNoteTypeToCardType(type);
}
// Handle tag updates: replace all tags
if (tags !== undefined && Array.isArray(tags)) {
// Delete existing tag links
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
// Find or create tags and link
for (const tagName of tags) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
where: { spaceId_name: { spaceId: '', name } },
update: {},
create: { name },
create: { name, spaceId: '' },
});
await prisma.noteTag.create({
data: { noteId: params.id, tagId: tag.id },
@ -90,6 +128,16 @@ export async function PUT(
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
parent: { select: { id: true, title: true, cardType: true } },
children: {
select: { id: true, title: true, cardType: true },
where: { archivedAt: null },
orderBy: { position: 'asc' },
},
attachments: {
include: { file: true },
orderBy: { position: 'asc' },
},
},
});
@ -120,7 +168,12 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.note.delete({ where: { id: params.id } });
// Soft-delete: set archivedAt instead of deleting
await prisma.note.update({
where: { id: params.id },
data: { archivedAt: new Date() },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Delete note error:', error);

View File

@ -3,18 +3,23 @@ import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
import { NoteType } from '@prisma/client';
import { requireAuth, isAuthed } from '@/lib/auth';
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const notebookId = searchParams.get('notebookId');
const type = searchParams.get('type');
const cardType = searchParams.get('cardType');
const tag = searchParams.get('tag');
const pinned = searchParams.get('pinned');
const where: Record<string, unknown> = {};
const where: Record<string, unknown> = {
archivedAt: null, // exclude soft-deleted
};
if (notebookId) where.notebookId = notebookId;
if (type) where.type = type as NoteType;
if (cardType) where.cardType = cardType;
if (pinned === 'true') where.isPinned = true;
if (tag) {
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
@ -25,6 +30,9 @@ export async function GET(request: NextRequest) {
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
parent: { select: { id: true, title: true } },
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: 100,
@ -43,7 +51,12 @@ export async function POST(request: NextRequest) {
if (!isAuthed(auth)) return auth;
const { user } = auth;
const body = await request.json();
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
const {
title, content, type, notebookId, url, language, tags,
fileUrl, mimeType, fileSize, duration,
// Memory Card fields
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
} = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
@ -51,6 +64,25 @@ export async function POST(request: NextRequest) {
const contentPlain = content ? stripHtml(content) : null;
// Dual-write: compute bodyJson + bodyMarkdown
let bodyJson = clientBodyJson || null;
let bodyMarkdown: string | null = null;
let bodyFormat = 'html';
if (clientBodyJson) {
// Client sent TipTap JSON — it's canonical
bodyJson = clientBodyJson;
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
bodyFormat = 'blocks';
} else if (content) {
// HTML content — convert to JSON + markdown
bodyJson = htmlToTipTapJson(content);
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
}
const noteType = type || 'NOTE';
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
// Find or create tags
const tagRecords = [];
if (tags && Array.isArray(tags)) {
@ -58,9 +90,9 @@ export async function POST(request: NextRequest) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
where: { spaceId_name: { spaceId: '', name } },
update: {},
create: { name },
create: { name, spaceId: '' },
});
tagRecords.push(tag);
}
@ -71,7 +103,7 @@ export async function POST(request: NextRequest) {
title: title.trim(),
content: content || '',
contentPlain,
type: type || 'NOTE',
type: noteType,
notebookId: notebookId || null,
authorId: user.id,
url: url || null,
@ -80,6 +112,16 @@ export async function POST(request: NextRequest) {
mimeType: mimeType || null,
fileSize: fileSize || null,
duration: duration || null,
// Memory Card fields
bodyJson: bodyJson || undefined,
bodyMarkdown,
bodyFormat,
cardType: resolvedCardType,
parentId: parentId || null,
visibility: visibility || 'private',
properties: properties || {},
summary: summary || null,
position: position ?? null,
tags: {
create: tagRecords.map((tag) => ({
tagId: tag.id,
@ -89,6 +131,9 @@ export async function POST(request: NextRequest) {
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
parent: { select: { id: true, title: true } },
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
},
});

View File

@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
const type = searchParams.get('type');
const cardType = searchParams.get('cardType');
const notebookId = searchParams.get('notebookId');
if (!q) {
@ -15,13 +16,17 @@ export async function GET(request: NextRequest) {
}
// Build WHERE clauses for optional filters
const filters: string[] = [];
const filters: string[] = ['n."archivedAt" IS NULL'];
const params: (string | null)[] = [q]; // $1 = search query
if (type) {
params.push(type);
filters.push(`n."type" = $${params.length}::"NoteType"`);
}
if (cardType) {
params.push(cardType);
filters.push(`n."cardType" = $${params.length}`);
}
if (notebookId) {
params.push(notebookId);
filters.push(`n."notebookId" = $${params.length}`);
@ -29,17 +34,19 @@ export async function GET(request: NextRequest) {
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
// Full-text search using PostgreSQL ts_vector + ts_query
// Falls back to ILIKE if the GIN index hasn't been created yet
// Full-text search — prefer bodyMarkdown over contentPlain
const results = await prisma.$queryRawUnsafe<Array<{
id: string;
title: string;
content: string;
contentPlain: string | null;
bodyMarkdown: string | null;
type: string;
cardType: string;
notebookId: string | null;
notebookTitle: string | null;
isPinned: boolean;
summary: string | null;
updatedAt: Date;
rank: number;
headline: string | null;
@ -49,25 +56,29 @@ export async function GET(request: NextRequest) {
n.title,
n.content,
n."contentPlain",
n."bodyMarkdown",
n.type::"text" as type,
n."cardType",
n."notebookId",
nb.title as "notebookTitle",
n."isPinned",
n.summary,
n."updatedAt",
ts_rank(
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title),
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title),
plainto_tsquery('english', $1)
) as rank,
ts_headline('english',
COALESCE(n."contentPlain", n.content, ''),
COALESCE(n."bodyMarkdown", n."contentPlain", n.content, ''),
plainto_tsquery('english', $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
) as headline
FROM "Note" n
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
WHERE (
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
OR n.title ILIKE '%' || $1 || '%'
OR n."bodyMarkdown" ILIKE '%' || $1 || '%'
OR n."contentPlain" ILIKE '%' || $1 || '%'
)
${whereClause}
@ -95,8 +106,9 @@ export async function GET(request: NextRequest) {
const response = results.map((note) => ({
id: note.id,
title: note.title,
snippet: note.headline || (note.contentPlain || note.content || '').slice(0, 150),
snippet: note.summary || note.headline || (note.bodyMarkdown || note.contentPlain || note.content || '').slice(0, 150),
type: note.type,
cardType: note.cardType,
notebookId: note.notebookId,
notebookTitle: note.notebookTitle,
updatedAt: new Date(note.updatedAt).toISOString(),

View File

@ -4,8 +4,8 @@ import { prisma } from '@/lib/prisma';
/**
* POST /api/sync
*
* Receives shape update events from the rSpace canvas (via postMessage CanvasEmbed fetch)
* and updates the corresponding DB records.
* Receives shape update events from the rSpace canvas and updates DB records.
* Handles Memory Card fields: cardType, summary, properties, visibility.
*/
export async function POST(request: NextRequest) {
try {
@ -19,7 +19,6 @@ export async function POST(request: NextRequest) {
const shapeType = data?.type as string | undefined;
if (type === 'shape-deleted') {
// Clear canvasShapeId references (don't delete the DB record)
await Promise.all([
prisma.note.updateMany({
where: { canvasShapeId: shapeId },
@ -41,12 +40,19 @@ export async function POST(request: NextRequest) {
});
if (note) {
await prisma.note.update({
where: { id: note.id },
data: {
title: (data.noteTitle as string) || note.title,
},
});
const updateData: Record<string, unknown> = {};
if (data.noteTitle) updateData.title = data.noteTitle;
if (data.cardType) updateData.cardType = data.cardType;
if (data.summary !== undefined) updateData.summary = data.summary || null;
if (data.visibility) updateData.visibility = data.visibility;
if (data.properties && typeof data.properties === 'object') updateData.properties = data.properties;
if (Object.keys(updateData).length > 0) {
await prisma.note.update({
where: { id: note.id },
data: updateData,
});
}
return NextResponse.json({ ok: true, action: 'updated', entity: 'note', id: note.id });
}
}

View File

@ -4,6 +4,7 @@ import { existsSync } from 'fs';
import path from 'path';
import { nanoid } from 'nanoid';
import { requireAuth, isAuthed } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
@ -32,6 +33,7 @@ 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 file = formData.get('file') as File | null;
@ -73,12 +75,24 @@ export async function POST(request: NextRequest) {
const fileUrl = `/api/uploads/${uniqueName}`;
// Create File record in database
const fileRecord = await prisma.file.create({
data: {
storageKey: uniqueName,
filename: file.name,
mimeType: file.type,
sizeBytes: file.size,
authorId: user.id,
},
});
return NextResponse.json({
url: fileUrl,
filename: uniqueName,
originalName: file.name,
size: file.size,
mimeType: file.type,
fileId: fileRecord.id,
}, { status: 201 });
} catch (error) {
console.error('Upload error:', error);

View File

@ -18,12 +18,26 @@ const TYPE_COLORS: Record<string, string> = {
AUDIO: 'bg-red-500/20 text-red-400',
};
const CARD_TYPE_COLORS: Record<string, string> = {
note: 'bg-amber-500/20 text-amber-400',
link: 'bg-blue-500/20 text-blue-400',
file: 'bg-slate-500/20 text-slate-400',
task: 'bg-green-500/20 text-green-400',
person: 'bg-purple-500/20 text-purple-400',
idea: 'bg-yellow-500/20 text-yellow-400',
reference: 'bg-pink-500/20 text-pink-400',
};
interface NoteData {
id: string;
title: string;
content: string;
contentPlain: string | null;
bodyJson: object | null;
bodyMarkdown: string | null;
bodyFormat: string;
type: string;
cardType: string;
url: string | null;
language: string | null;
fileUrl: string | null;
@ -32,10 +46,16 @@ interface NoteData {
duration: number | null;
isPinned: boolean;
canvasShapeId: string | null;
summary: string | null;
visibility: string;
properties: Record<string, unknown>;
createdAt: string;
updatedAt: string;
notebook: { id: string; title: string; slug: string } | null;
parent: { id: string; title: string; cardType: string } | null;
children: { id: string; title: string; cardType: string }[];
tags: { tag: { id: string; name: string; color: string | null } }[];
attachments: { id: string; role: string; caption: string | null; file: { id: string; filename: string; mimeType: string; sizeBytes: number; storageKey: string } }[];
}
export default function NoteDetailPage() {
@ -46,6 +66,7 @@ export default function NoteDetailPage() {
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
const [editBodyJson, setEditBodyJson] = useState<object | null>(null);
const [saving, setSaving] = useState(false);
const [diarizing, setDiarizing] = useState(false);
const [speakers, setSpeakers] = useState<{ speaker: string; start: number; end: number }[] | null>(null);
@ -57,19 +78,32 @@ export default function NoteDetailPage() {
setNote(data);
setEditTitle(data.title);
setEditContent(data.content);
setEditBodyJson(data.bodyJson || null);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [params.id]);
const handleEditorChange = (html: string, json?: object) => {
setEditContent(html);
if (json) setEditBodyJson(json);
};
const handleSave = async () => {
if (saving) return;
setSaving(true);
try {
const payload: Record<string, unknown> = { title: editTitle };
if (editBodyJson) {
payload.bodyJson = editBodyJson;
} else {
payload.content = editContent;
}
const res = await authFetch(`/api/notes/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: editTitle, content: editContent }),
body: JSON.stringify(payload),
});
if (res.ok) {
const updated = await res.json();
@ -97,7 +131,7 @@ export default function NoteDetailPage() {
};
const handleDelete = async () => {
if (!confirm('Delete this note?')) return;
if (!confirm('Archive this note? It can be restored later.')) return;
await authFetch(`/api/notes/${params.id}`, { method: 'DELETE' });
if (note?.notebook) {
router.push(`/notebooks/${note.notebook.id}`);
@ -110,7 +144,6 @@ export default function NoteDetailPage() {
if (!note?.fileUrl || diarizing) return;
setDiarizing(true);
try {
// Fetch the audio file from the server
const audioRes = await fetch(note.fileUrl);
const audioBlob = await audioRes.blob();
@ -154,6 +187,8 @@ export default function NoteDetailPage() {
);
}
const properties = note.properties && typeof note.properties === 'object' ? Object.entries(note.properties).filter(([, v]) => v != null && v !== '') : [];
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-4 md:px-6 py-4">
@ -165,6 +200,14 @@ export default function NoteDetailPage() {
</div>
</Link>
<span className="text-slate-600 hidden sm:inline">/</span>
{note.parent && (
<>
<Link href={`/notes/${note.parent.id}`} className="text-slate-400 hover:text-white transition-colors hidden sm:inline truncate max-w-[120px]">
{note.parent.title}
</Link>
<span className="text-slate-600 hidden sm:inline">/</span>
</>
)}
{note.notebook ? (
<>
<Link href={`/notebooks/${note.notebook.id}`} className="text-slate-400 hover:text-white transition-colors hidden sm:inline truncate max-w-[120px]">
@ -201,6 +244,7 @@ export default function NoteDetailPage() {
setEditing(false);
setEditTitle(note.title);
setEditContent(note.content);
setEditBodyJson(note.bodyJson || null);
}}
className="px-2 md:px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors hidden sm:inline-flex"
>
@ -219,7 +263,7 @@ export default function NoteDetailPage() {
onClick={handleDelete}
className="px-2 md:px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 rounded-lg transition-colors"
>
<span className="hidden sm:inline">Delete</span>
<span className="hidden sm:inline">Forget</span>
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
<UserMenu />
@ -233,6 +277,16 @@ export default function NoteDetailPage() {
<span className={`text-xs font-bold uppercase px-2 py-1 rounded ${TYPE_COLORS[note.type] || TYPE_COLORS.NOTE}`}>
{note.type}
</span>
{note.cardType !== 'note' && (
<span className={`text-xs font-medium px-2 py-1 rounded ${CARD_TYPE_COLORS[note.cardType] || CARD_TYPE_COLORS.note}`}>
{note.cardType}
</span>
)}
{note.visibility !== 'private' && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-700/50 text-slate-400">
{note.visibility}
</span>
)}
{note.tags.map((nt) => (
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
))}
@ -241,6 +295,24 @@ export default function NoteDetailPage() {
</span>
</div>
{/* Summary */}
{note.summary && (
<div className="mb-4 p-3 bg-slate-800/30 border border-slate-700/50 rounded-lg text-sm text-slate-300 italic">
{note.summary}
</div>
)}
{/* Properties */}
{properties.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{properties.map(([key, value]) => (
<span key={key} className="text-[10px] px-2 py-1 rounded bg-slate-800/50 border border-slate-700/50 text-slate-400">
<span className="text-slate-500">{key}:</span> {String(value)}
</span>
))}
</div>
)}
{/* URL */}
{note.url && (
<a
@ -325,6 +397,34 @@ export default function NoteDetailPage() {
</div>
)}
{/* Attachments gallery */}
{note.attachments.length > 0 && (
<div className="mb-6">
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">Attachments</h3>
<div className="flex flex-wrap gap-2">
{note.attachments.map((att) => (
<a
key={att.id}
href={`/api/uploads/${att.file.storageKey}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-sm text-slate-300 hover:border-slate-600 transition-colors"
>
{att.file.mimeType.startsWith('image/') ? (
<img src={`/api/uploads/${att.file.storageKey}`} alt={att.caption || att.file.filename} className="w-8 h-8 object-cover rounded" />
) : (
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
<span className="truncate max-w-[150px]">{att.caption || att.file.filename}</span>
<span className="text-[10px] text-slate-500">{att.role}</span>
</a>
))}
</div>
</div>
)}
{/* Content */}
{editing ? (
<div className="space-y-4">
@ -336,7 +436,8 @@ export default function NoteDetailPage() {
/>
<NoteEditor
value={editContent}
onChange={setEditContent}
valueJson={editBodyJson || undefined}
onChange={handleEditorChange}
type={note.type}
/>
</div>
@ -357,6 +458,27 @@ export default function NoteDetailPage() {
)}
</div>
)}
{/* Children */}
{note.children.length > 0 && (
<div className="mt-8 border-t border-slate-800 pt-6">
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">Child Notes</h3>
<div className="space-y-2">
{note.children.map((child) => (
<Link
key={child.id}
href={`/notes/${child.id}`}
className="flex items-center gap-2 px-3 py-2 bg-slate-800/30 hover:bg-slate-800/50 border border-slate-700/30 rounded-lg transition-colors"
>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${CARD_TYPE_COLORS[child.cardType] || CARD_TYPE_COLORS.note}`}>
{child.cardType}
</span>
<span className="text-sm text-slate-300 hover:text-white">{child.title}</span>
</Link>
))}
</div>
</div>
)}
</main>
</div>
);

View File

@ -46,6 +46,7 @@ function NewNoteForm() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [bodyJson, setBodyJson] = useState<object | null>(null);
const [type, setType] = useState('NOTE');
const [url, setUrl] = useState('');
const [language, setLanguage] = useState('');
@ -65,6 +66,11 @@ function NewNoteForm() {
.catch(console.error);
}, []);
const handleContentChange = (html: string, json?: object) => {
setContent(html);
if (json) setBodyJson(json);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || saving) return;
@ -77,6 +83,7 @@ function NewNoteForm() {
type,
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
};
if (bodyJson) body.bodyJson = bodyJson;
if (notebookId) body.notebookId = notebookId;
if (url) body.url = url;
if (language) body.language = language;
@ -270,7 +277,7 @@ function NewNoteForm() {
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
<NoteEditor
value={content}
onChange={setContent}
onChange={handleContentChange}
type={type}
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
/>

View File

@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { TagBadge } from './TagBadge';
const CARD_TYPE_COLORS: Record<string, string> = {
note: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
link: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
file: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
task: 'bg-green-500/20 text-green-400 border-green-500/30',
person: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
idea: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
reference: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
};
const VISIBILITY_OPTIONS = ['private', 'space', 'public'] as const;
interface MemoryCardViewProps {
noteId: string;
cardType: string;
visibility: string;
summary: string | null;
properties: Record<string, unknown>;
parent: { id: string; title: string; cardType: string } | null;
children: { id: string; title: string; cardType: string }[];
tags: { tag: { id: string; name: string; color: string | null } }[];
attachments: {
id: string;
role: string;
caption: string | null;
file: { id: string; filename: string; mimeType: string; sizeBytes: number; storageKey: string };
}[];
onUpdate?: (data: Record<string, unknown>) => void;
editable?: boolean;
}
export function MemoryCardView({
noteId,
cardType,
visibility,
summary,
properties,
parent,
children,
tags,
attachments,
onUpdate,
editable = false,
}: MemoryCardViewProps) {
const [editingProps, setEditingProps] = useState(false);
const [propEntries, setPropEntries] = useState<[string, string][]>(
Object.entries(properties || {}).map(([k, v]) => [k, String(v)])
);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const handleSaveProps = () => {
const updated: Record<string, string> = {};
for (const [k, v] of propEntries) {
if (k.trim()) updated[k.trim()] = v;
}
onUpdate?.({ properties: updated });
setEditingProps(false);
};
const handleAddProp = () => {
if (newKey.trim()) {
setPropEntries([...propEntries, [newKey.trim(), newValue]]);
setNewKey('');
setNewValue('');
}
};
const handleVisibilityChange = (v: string) => {
onUpdate?.({ visibility: v });
};
const cardColor = CARD_TYPE_COLORS[cardType] || CARD_TYPE_COLORS.note;
return (
<div className="space-y-6">
{/* Card Type & Visibility */}
<div className="flex items-center gap-3">
<span className={`text-xs font-bold uppercase px-2 py-1 rounded border ${cardColor}`}>
{cardType}
</span>
{editable ? (
<div className="flex gap-1">
{VISIBILITY_OPTIONS.map((v) => (
<button
key={v}
onClick={() => handleVisibilityChange(v)}
className={`text-[10px] px-2 py-1 rounded border transition-colors ${
visibility === v
? 'bg-slate-700 text-white border-slate-600'
: 'text-slate-500 border-slate-700/50 hover:text-slate-300'
}`}
>
{v}
</button>
))}
</div>
) : visibility !== 'private' ? (
<span className="text-[10px] px-2 py-1 rounded bg-slate-700/50 text-slate-400">
{visibility}
</span>
) : null}
</div>
{/* Summary */}
{summary && (
<div className="p-3 bg-slate-800/30 border border-slate-700/50 rounded-lg">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Summary</div>
<p className="text-sm text-slate-300 italic">{summary}</p>
</div>
)}
{/* Parent breadcrumb */}
{parent && (
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-500">Parent:</span>
<Link
href={`/notes/${parent.id}`}
className="text-amber-400 hover:text-amber-300 transition-colors"
>
{parent.title}
</Link>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${CARD_TYPE_COLORS[parent.cardType] || ''}`}>
{parent.cardType}
</span>
</div>
)}
{/* Properties */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-slate-500 uppercase tracking-wider">Properties</span>
{editable && (
<button
onClick={() => editingProps ? handleSaveProps() : setEditingProps(true)}
className="text-[10px] text-amber-400 hover:text-amber-300"
>
{editingProps ? 'Save' : 'Edit'}
</button>
)}
</div>
{editingProps ? (
<div className="space-y-2">
{propEntries.map(([key, value], i) => (
<div key={i} className="flex gap-2">
<input
value={key}
onChange={(e) => {
const next = [...propEntries];
next[i] = [e.target.value, value];
setPropEntries(next);
}}
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-slate-300"
placeholder="key"
/>
<span className="text-slate-600 text-xs self-center">::</span>
<input
value={value}
onChange={(e) => {
const next = [...propEntries];
next[i] = [key, e.target.value];
setPropEntries(next);
}}
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-slate-300"
placeholder="value"
/>
<button
onClick={() => setPropEntries(propEntries.filter((_, j) => j !== i))}
className="text-red-400 text-xs hover:text-red-300"
>
x
</button>
</div>
))}
<div className="flex gap-2">
<input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-slate-300"
placeholder="new key"
onKeyDown={(e) => e.key === 'Enter' && handleAddProp()}
/>
<span className="text-slate-600 text-xs self-center">::</span>
<input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-slate-300"
placeholder="value"
onKeyDown={(e) => e.key === 'Enter' && handleAddProp()}
/>
<button onClick={handleAddProp} className="text-amber-400 text-xs hover:text-amber-300">+</button>
</div>
</div>
) : propEntries.length > 0 ? (
<div className="space-y-1">
{propEntries.map(([key, value]) => (
<div key={key} className="flex gap-2 text-xs">
<span className="text-slate-500 font-mono">{key}::</span>
<span className="text-slate-300">{value}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-slate-600 italic">No properties set</p>
)}
</div>
{/* Children */}
{children.length > 0 && (
<div>
<span className="text-[10px] text-slate-500 uppercase tracking-wider block mb-2">
Child Notes ({children.length})
</span>
<div className="space-y-1">
{children.map((child) => (
<Link
key={child.id}
href={`/notes/${child.id}`}
className="flex items-center gap-2 px-3 py-2 bg-slate-800/30 hover:bg-slate-800/50 border border-slate-700/30 rounded-lg transition-colors"
>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${CARD_TYPE_COLORS[child.cardType] || CARD_TYPE_COLORS.note}`}>
{child.cardType}
</span>
<span className="text-sm text-slate-300 hover:text-white truncate">{child.title}</span>
</Link>
))}
</div>
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div>
<span className="text-[10px] text-slate-500 uppercase tracking-wider block mb-2">Tags</span>
<div className="flex flex-wrap gap-1.5">
{tags.map((nt) => (
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
))}
</div>
</div>
)}
{/* Attachments */}
{attachments.length > 0 && (
<div>
<span className="text-[10px] text-slate-500 uppercase tracking-wider block mb-2">
Attachments ({attachments.length})
</span>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{attachments.map((att) => (
<a
key={att.id}
href={`/api/uploads/${att.file.storageKey}`}
target="_blank"
rel="noopener noreferrer"
className="group p-2 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-slate-600 transition-colors"
>
{att.file.mimeType.startsWith('image/') ? (
<img
src={`/api/uploads/${att.file.storageKey}`}
alt={att.caption || att.file.filename}
className="w-full h-24 object-cover rounded mb-1"
/>
) : (
<div className="w-full h-24 flex items-center justify-center bg-slate-800 rounded mb-1">
<svg className="w-8 h-8 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
)}
<p className="text-[10px] text-slate-400 truncate">{att.caption || att.file.filename}</p>
<div className="flex items-center gap-1 text-[9px] text-slate-500">
<span>{att.role}</span>
<span>&middot;</span>
<span>{(att.file.sizeBytes / 1024).toFixed(0)} KB</span>
</div>
</a>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -13,34 +13,70 @@ const TYPE_COLORS: Record<string, string> = {
AUDIO: 'bg-red-500/20 text-red-400',
};
const CARD_TYPE_STYLES: Record<string, { bg: string; text: string; border: string }> = {
note: { bg: 'bg-amber-500/20', text: 'text-amber-400', border: 'border-amber-500/20' },
link: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/20' },
file: { bg: 'bg-slate-500/20', text: 'text-slate-400', border: 'border-slate-500/20' },
task: { bg: 'bg-green-500/20', text: 'text-green-400', border: 'border-green-500/20' },
person: { bg: 'bg-purple-500/20', text: 'text-purple-400', border: 'border-purple-500/20' },
idea: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', border: 'border-yellow-500/20' },
reference: { bg: 'bg-pink-500/20', text: 'text-pink-400', border: 'border-pink-500/20' },
};
interface NoteCardProps {
id: string;
title: string;
type: string;
cardType?: string;
contentPlain?: string | null;
summary?: string | null;
isPinned: boolean;
updatedAt: string;
tags: { id: string; name: string; color: string | null }[];
url?: string | null;
visibility?: string;
children?: { id: string }[];
properties?: Record<string, unknown>;
}
export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, tags, url }: NoteCardProps) {
const snippet = (contentPlain || '').slice(0, 120);
export function NoteCard({
id, title, type, cardType = 'note', contentPlain, summary,
isPinned, updatedAt, tags, url, visibility, children, properties,
}: NoteCardProps) {
const snippet = summary || (contentPlain || '').slice(0, 120);
const cardStyle = CARD_TYPE_STYLES[cardType] || CARD_TYPE_STYLES.note;
const childCount = children?.length || 0;
const propertyEntries = properties ? Object.entries(properties).filter(([, v]) => v != null && v !== '') : [];
return (
<Link
href={`/notes/${id}`}
className="block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-lg p-4 transition-all"
className={`block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-lg p-4 transition-all`}
>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${TYPE_COLORS[type] || TYPE_COLORS.NOTE}`}>
{type}
</span>
{cardType !== 'note' && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${cardStyle.bg} ${cardStyle.text}`}>
{cardType}
</span>
)}
{isPinned && (
<span className="text-amber-400 text-xs" title="Pinned to canvas">
&#9733;
</span>
)}
{childCount > 0 && (
<span className="text-[10px] text-slate-500" title={`${childCount} child note${childCount > 1 ? 's' : ''}`}>
&#9661; {childCount}
</span>
)}
{visibility && visibility !== 'private' && (
<span className="text-[10px] text-slate-500 px-1 py-0.5 rounded bg-slate-700/30">
{visibility}
</span>
)}
<span className="text-[10px] text-slate-500 ml-auto">
{new Date(updatedAt).toLocaleDateString()}
</span>
@ -58,6 +94,20 @@ export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, t
<p className="text-xs text-slate-400 line-clamp-2 mb-2">{snippet}</p>
)}
{/* Property badges */}
{propertyEntries.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{propertyEntries.slice(0, 3).map(([key, value]) => (
<span key={key} className="text-[9px] px-1.5 py-0.5 rounded bg-slate-700/40 text-slate-500">
{key}: {String(value)}
</span>
))}
{propertyEntries.length > 3 && (
<span className="text-[9px] text-slate-500">+{propertyEntries.length - 3}</span>
)}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (

View File

@ -11,7 +11,8 @@ import Image from '@tiptap/extension-image';
interface NoteEditorProps {
value: string;
onChange: (content: string) => void;
onChange: (content: string, json?: object) => void;
valueJson?: object;
type?: string;
placeholder?: string;
}
@ -43,7 +44,7 @@ function ToolbarButton({
);
}
function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
const editor = useEditor({
extensions: [
StarterKit.configure({
@ -61,9 +62,9 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
TaskItem.configure({ nested: true }),
Image.configure({ inline: true }),
],
content: value || '',
content: valueJson || value || '',
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
onChange(editor.getHTML(), editor.getJSON());
},
editorProps: {
attributes: {
@ -168,7 +169,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
onClick={() => editor.chain().focus().toggleTaskList().run()}
title="Task List"
>
Tasks
&#9744; Tasks
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
@ -210,19 +211,19 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
&#9472;
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
title="Undo"
>
&#8617;
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
&#8618;
</ToolbarButton>
</div>
@ -232,7 +233,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
);
}
export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorProps) {
export function NoteEditor({ value, onChange, valueJson, type, placeholder }: NoteEditorProps) {
const isCode = type === 'CODE';
if (isCode) {
@ -248,5 +249,5 @@ export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorPro
);
}
return <RichEditor value={value} onChange={onChange} placeholder={placeholder} />;
return <RichEditor value={value} onChange={onChange} valueJson={valueJson} placeholder={placeholder} />;
}

396
src/lib/content-convert.ts Normal file
View File

@ -0,0 +1,396 @@
import { NoteType } from '@prisma/client';
// ─── TipTap JSON types ──────────────────────────────────────────────
interface TipTapNode {
type: string;
content?: TipTapNode[];
text?: string;
attrs?: Record<string, unknown>;
marks?: { type: string; attrs?: Record<string, unknown> }[];
}
interface TipTapDoc {
type: 'doc';
content: TipTapNode[];
}
// ─── HTML → TipTap JSON ────────────────────────────────────────────
// Lightweight server-side parser — handles the HTML subset TipTap produces.
// We avoid importing the full TipTap editor server-side (it requires DOM shims).
function parseSimpleHtml(html: string): TipTapDoc {
const doc: TipTapDoc = { type: 'doc', content: [] };
if (!html || !html.trim()) return doc;
// Split into block-level elements
const blockRegex = /<(h[1-6]|p|blockquote|pre|ul|ol|hr|img)([^>]*)>([\s\S]*?)<\/\1>|<(hr|img)([^>]*?)\s*\/?>|<(ul|ol)([^>]*)>([\s\S]*?)<\/\6>/gi;
let remaining = html;
let match;
const blocks: TipTapNode[] = [];
// Simple recursive parser for inline content
function parseInline(text: string): TipTapNode[] {
const nodes: TipTapNode[] = [];
if (!text) return nodes;
// Strip outer tags if wrapped in <p>
text = text.replace(/^<p[^>]*>|<\/p>$/gi, '');
// Replace <br> with newline markers
text = text.replace(/<br\s*\/?>/gi, '\n');
// Process inline elements
const inlineRegex = /<(strong|b|em|i|s|del|code|a|mark|u)([^>]*)>([\s\S]*?)<\/\1>/gi;
let lastIndex = 0;
let inlineMatch;
while ((inlineMatch = inlineRegex.exec(text)) !== null) {
// Text before this match
if (inlineMatch.index > lastIndex) {
const before = decodeEntities(text.slice(lastIndex, inlineMatch.index));
if (before) nodes.push({ type: 'text', text: before });
}
const tag = inlineMatch[1].toLowerCase();
const attrs = inlineMatch[2];
const inner = inlineMatch[3];
const marks: { type: string; attrs?: Record<string, unknown> }[] = [];
if (tag === 'strong' || tag === 'b') marks.push({ type: 'bold' });
else if (tag === 'em' || tag === 'i') marks.push({ type: 'italic' });
else if (tag === 's' || tag === 'del') marks.push({ type: 'strike' });
else if (tag === 'code') marks.push({ type: 'code' });
else if (tag === 'a') {
const hrefMatch = attrs.match(/href="([^"]*)"/);
marks.push({ type: 'link', attrs: { href: hrefMatch?.[1] || '' } });
}
const innerNodes = parseInline(inner);
for (const node of innerNodes) {
nodes.push({
...node,
marks: [...(node.marks || []), ...marks],
});
}
lastIndex = inlineMatch.index + inlineMatch[0].length;
}
// Remaining text
if (lastIndex < text.length) {
const rest = decodeEntities(text.slice(lastIndex));
if (rest) nodes.push({ type: 'text', text: rest });
}
return nodes.length > 0 ? nodes : [{ type: 'text', text: decodeEntities(text) }];
}
function parseListItems(html: string): TipTapNode[] {
const items: TipTapNode[] = [];
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let liMatch;
while ((liMatch = liRegex.exec(html)) !== null) {
// Check for task list items
const taskMatch = liMatch[1].match(/^<input[^>]*type="checkbox"[^>]*(checked)?[^>]*\/?>\s*/i);
if (taskMatch) {
const content = liMatch[1].replace(/<input[^>]*\/?>\s*/i, '');
items.push({
type: 'taskItem',
attrs: { checked: !!taskMatch[1] },
content: [{ type: 'paragraph', content: parseInline(content) }],
});
} else {
items.push({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(liMatch[1]) }],
});
}
}
return items;
}
// Process block elements via regex
const fullBlockRegex = /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>|<(p)([^>]*)>([\s\S]*?)<\/\4>|<(blockquote)([^>]*)>([\s\S]*?)<\/\7>|<(pre)([^>]*)>([\s\S]*?)<\/\10>|<(ul|ol)([^>]*)>([\s\S]*?)<\/\12>|<(hr)[^>]*\/?>|<(img)([^>]*?)\/?>|<(li)([^>]*)>([\s\S]*?)<\/\18>/gi;
while ((match = fullBlockRegex.exec(html)) !== null) {
if (match[1]) {
// Heading
const level = parseInt(match[1].charAt(1));
const content = parseInline(match[3]);
blocks.push({ type: 'heading', attrs: { level }, content });
} else if (match[4]) {
// Paragraph
const content = parseInline(match[6]);
blocks.push({ type: 'paragraph', content });
} else if (match[7]) {
// Blockquote
const innerBlocks = parseSimpleHtml(match[9]);
blocks.push({ type: 'blockquote', content: innerBlocks.content });
} else if (match[10]) {
// Code block
const code = match[12].replace(/<code[^>]*>|<\/code>/gi, '');
blocks.push({
type: 'codeBlock',
content: [{ type: 'text', text: decodeEntities(code) }],
});
} else if (match[12]) {
// List (ul/ol)
const listType = match[12].toLowerCase();
const items = parseListItems(match[14]);
// Check if it's a task list
const hasTaskItems = items.some((i) => i.type === 'taskItem');
if (hasTaskItems) {
blocks.push({ type: 'taskList', content: items });
} else {
blocks.push({
type: listType === 'ol' ? 'orderedList' : 'bulletList',
content: items,
});
}
} else if (match[15]) {
// Horizontal rule
blocks.push({ type: 'horizontalRule' });
} else if (match[16]) {
// Image
const srcMatch = match[17]?.match(/src="([^"]*)"/);
const altMatch = match[17]?.match(/alt="([^"]*)"/);
if (srcMatch) {
blocks.push({
type: 'image',
attrs: { src: srcMatch[1], alt: altMatch?.[1] || '' },
});
}
}
}
// If no blocks were parsed, wrap the whole thing as a paragraph
if (blocks.length === 0 && html.trim()) {
blocks.push({ type: 'paragraph', content: parseInline(html) });
}
doc.content = blocks;
return doc;
}
function decodeEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
}
export function htmlToTipTapJson(html: string): TipTapDoc {
return parseSimpleHtml(html);
}
// ─── TipTap JSON → HTML ────────────────────────────────────────────
function renderMarks(text: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): string {
if (!marks || marks.length === 0) return escapeHtml(text);
let result = escapeHtml(text);
for (const mark of marks) {
switch (mark.type) {
case 'bold':
result = `<strong>${result}</strong>`;
break;
case 'italic':
result = `<em>${result}</em>`;
break;
case 'strike':
result = `<s>${result}</s>`;
break;
case 'code':
result = `<code>${result}</code>`;
break;
case 'link':
result = `<a href="${escapeHtml(String(mark.attrs?.href || ''))}">${result}</a>`;
break;
}
}
return result;
}
function renderNode(node: TipTapNode): string {
if (node.type === 'text') {
return renderMarks(node.text || '', node.marks);
}
const children = (node.content || []).map(renderNode).join('');
switch (node.type) {
case 'doc':
return children;
case 'paragraph':
return `<p>${children}</p>`;
case 'heading':
const level = node.attrs?.level || 1;
return `<h${level}>${children}</h${level}>`;
case 'bulletList':
return `<ul>${children}</ul>`;
case 'orderedList':
return `<ol>${children}</ol>`;
case 'listItem':
return `<li>${children}</li>`;
case 'taskList':
return `<ul data-type="taskList">${children}</ul>`;
case 'taskItem': {
const checked = node.attrs?.checked ? ' checked' : '';
return `<li data-type="taskItem"><input type="checkbox"${checked}>${children}</li>`;
}
case 'blockquote':
return `<blockquote>${children}</blockquote>`;
case 'codeBlock':
return `<pre><code>${children}</code></pre>`;
case 'horizontalRule':
return '<hr>';
case 'image':
return `<img src="${escapeHtml(String(node.attrs?.src || ''))}" alt="${escapeHtml(String(node.attrs?.alt || ''))}">`;
case 'hardBreak':
return '<br>';
default:
return children;
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function tipTapJsonToHtml(json: TipTapDoc): string {
return renderNode(json as unknown as TipTapNode);
}
// ─── TipTap JSON → Markdown ────────────────────────────────────────
function renderMarksMd(text: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): string {
if (!marks || marks.length === 0) return text;
let result = text;
for (const mark of marks) {
switch (mark.type) {
case 'bold':
result = `**${result}**`;
break;
case 'italic':
result = `*${result}*`;
break;
case 'strike':
result = `~~${result}~~`;
break;
case 'code':
result = `\`${result}\``;
break;
case 'link':
result = `[${result}](${mark.attrs?.href || ''})`;
break;
}
}
return result;
}
function nodeToMarkdown(node: TipTapNode, indent: string = ''): string {
if (node.type === 'text') {
return renderMarksMd(node.text || '', node.marks);
}
const childrenText = (node.content || [])
.map((child) => nodeToMarkdown(child, indent))
.join('');
switch (node.type) {
case 'doc':
return (node.content || [])
.map((child) => nodeToMarkdown(child, ''))
.join('\n\n');
case 'paragraph':
return childrenText;
case 'heading': {
const level = (node.attrs?.level as number) || 1;
return '#'.repeat(level) + ' ' + childrenText;
}
case 'bulletList':
return (node.content || [])
.map((child) => nodeToMarkdown(child, indent))
.join('\n');
case 'orderedList':
return (node.content || [])
.map((child, i) => {
const text = nodeToMarkdown(child, indent);
return text.replace(/^- /, `${i + 1}. `);
})
.join('\n');
case 'listItem': {
const inner = (node.content || [])
.map((child) => nodeToMarkdown(child, indent + ' '))
.join('\n' + indent + ' ');
return `${indent}- ${inner}`;
}
case 'taskList':
return (node.content || [])
.map((child) => nodeToMarkdown(child, indent))
.join('\n');
case 'taskItem': {
const checked = node.attrs?.checked ? 'x' : ' ';
const inner = (node.content || [])
.map((child) => nodeToMarkdown(child, indent + ' '))
.join('\n' + indent + ' ');
return `${indent}- [${checked}] ${inner}`;
}
case 'blockquote':
return (node.content || [])
.map((child) => '> ' + nodeToMarkdown(child, ''))
.join('\n');
case 'codeBlock': {
const lang = (node.attrs?.language as string) || '';
return '```' + lang + '\n' + childrenText + '\n```';
}
case 'horizontalRule':
return '---';
case 'image':
return `![${node.attrs?.alt || ''}](${node.attrs?.src || ''})`;
case 'hardBreak':
return ' \n';
default:
return childrenText;
}
}
export function tipTapJsonToMarkdown(json: TipTapDoc): string {
return nodeToMarkdown(json as unknown as TipTapNode);
}
// ─── Markdown → TipTap JSON ────────────────────────────────────────
// Uses marked to parse markdown to HTML, then HTML to TipTap JSON.
export async function markdownToTipTapJson(md: string): Promise<TipTapDoc> {
const { marked } = await import('marked');
const html = await marked.parse(md);
return htmlToTipTapJson(html);
}
// ─── NoteType → cardType mapping ───────────────────────────────────
const NOTE_TYPE_TO_CARD_TYPE: Record<string, string> = {
NOTE: 'note',
BOOKMARK: 'link',
CLIP: 'reference',
IMAGE: 'file',
FILE: 'file',
AUDIO: 'file',
CODE: 'note',
};
export function mapNoteTypeToCardType(noteType: NoteType | string): string {
return NOTE_TYPE_TO_CARD_TYPE[noteType] || 'note';
}

194
src/lib/logseq-format.ts Normal file
View File

@ -0,0 +1,194 @@
/**
* Logseq format bidirectional conversion.
*
* Export: Note Logseq page (markdown with frontmatter-style properties)
* Import: Logseq page Note fields
*/
// ─── Export ─────────────────────────────────────────────────────────
interface ExportNote {
title: string;
cardType: string;
visibility: string;
bodyMarkdown: string | null;
contentPlain: string | null;
properties: Record<string, unknown>;
tags: { tag: { name: string } }[];
children?: ExportNote[];
attachments?: { file: { storageKey: string; filename: string } ; caption: string | null }[];
}
export function noteToLogseqPage(note: ExportNote): string {
const lines: string[] = [];
// Properties block (Logseq `key:: value` format)
if (note.cardType && note.cardType !== 'note') {
lines.push(`type:: ${note.cardType}`);
}
if (note.tags.length > 0) {
lines.push(`tags:: ${note.tags.map((t) => `#${t.tag.name}`).join(', ')}`);
}
if (note.visibility && note.visibility !== 'private') {
lines.push(`visibility:: ${note.visibility}`);
}
// Custom properties
const props = note.properties || {};
for (const [key, value] of Object.entries(props)) {
if (value != null && value !== '' && !['type', 'tags', 'visibility'].includes(key)) {
lines.push(`${key}:: ${String(value)}`);
}
}
// Blank line between properties and content
if (lines.length > 0) {
lines.push('');
}
// Body content as outline blocks
const body = note.bodyMarkdown || note.contentPlain || '';
if (body.trim()) {
// Split into paragraphs and prefix with `- `
const paragraphs = body.split(/\n\n+/).filter(Boolean);
for (const para of paragraphs) {
const subLines = para.split('\n');
lines.push(`- ${subLines[0]}`);
for (let i = 1; i < subLines.length; i++) {
lines.push(` ${subLines[i]}`);
}
}
}
// Child notes as indented blocks
if (note.children && note.children.length > 0) {
for (const child of note.children) {
lines.push(` - [[${child.title}]]`);
}
}
// Attachment references
if (note.attachments && note.attachments.length > 0) {
lines.push('');
for (const att of note.attachments) {
const caption = att.caption || att.file.filename;
lines.push(`- ![${caption}](../assets/${att.file.storageKey})`);
}
}
return lines.join('\n');
}
export function sanitizeLogseqFilename(title: string): string {
return title
.replace(/[\/\\:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 200);
}
// ─── Import ─────────────────────────────────────────────────────────
interface ImportedNote {
title: string;
cardType: string;
visibility: string;
bodyMarkdown: string;
properties: Record<string, string>;
tags: string[];
childTitles: string[];
attachmentPaths: string[];
}
export function logseqPageToNote(filename: string, content: string): ImportedNote {
const title = filename
.replace(/\.md$/, '')
.replace(/_/g, ' ')
.replace(/%[0-9A-Fa-f]{2}/g, (m) => decodeURIComponent(m));
const lines = content.split('\n');
const properties: Record<string, string> = {};
const tags: string[] = [];
let cardType = 'note';
let visibility = 'private';
const bodyLines: string[] = [];
const childTitles: string[] = [];
const attachmentPaths: string[] = [];
let inProperties = true;
for (const line of lines) {
// Parse property lines (key:: value)
const propMatch = line.match(/^([a-zA-Z_-]+)::\s*(.+)$/);
if (propMatch && inProperties) {
const [, key, value] = propMatch;
if (key === 'type') {
cardType = value.trim();
} else if (key === 'tags') {
// Parse #tag1, #tag2
const tagMatches = value.matchAll(/#([a-zA-Z0-9_-]+)/g);
for (const m of tagMatches) {
tags.push(m[1].toLowerCase());
}
} else if (key === 'visibility') {
visibility = value.trim();
} else {
properties[key] = value.trim();
}
continue;
}
// Empty line after properties section
if (inProperties && line.trim() === '') {
inProperties = false;
continue;
}
inProperties = false;
// Parse outline blocks
const outlineMatch = line.match(/^(\s*)- (.+)$/);
if (outlineMatch) {
const indent = outlineMatch[1].length;
const text = outlineMatch[2];
// Check for wiki-link child references
const wikiMatch = text.match(/^\[\[(.+)\]\]$/);
if (wikiMatch && indent >= 2) {
childTitles.push(wikiMatch[1]);
continue;
}
// Check for image/attachment references
const imgMatch = text.match(/^!\[([^\]]*)\]\(\.\.\/assets\/(.+)\)$/);
if (imgMatch) {
attachmentPaths.push(imgMatch[2]);
continue;
}
// Regular content line
if (indent === 0) {
if (bodyLines.length > 0) bodyLines.push('');
bodyLines.push(text);
} else {
bodyLines.push(text);
}
} else if (line.trim()) {
// Non-outline content (continuation lines)
bodyLines.push(line.replace(/^\s{2}/, ''));
}
}
// Check title for class-based cardType hints
const classMatch = title.match(/^(Task|Idea|Person|Reference|Link|File):\s*/i);
if (classMatch) {
cardType = classMatch[1].toLowerCase();
}
return {
title,
cardType,
visibility,
bodyMarkdown: bodyLines.join('\n'),
properties,
tags,
childTitles,
attachmentPaths,
};
}