diff --git a/Dockerfile b/Dockerfile index 3fdd931..195921b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index 263c6ad..5444d32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b9786e..f13f805 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/scripts/backfill-memory-card.ts b/scripts/backfill-memory-card.ts new file mode 100644 index 0000000..fca19a9 --- /dev/null +++ b/scripts/backfill-memory-card.ts @@ -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()); diff --git a/src/app/api/export/logseq/route.ts b/src/app/api/export/logseq/route.ts new file mode 100644 index 0000000..ae383d7 --- /dev/null +++ b/src/app/api/export/logseq/route.ts @@ -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 = { + 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(); + + 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, + 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 }); + } +} diff --git a/src/app/api/import/logseq/route.ts b/src/app/api/import/logseq/route.ts new file mode 100644 index 0000000..b2d075b --- /dev/null +++ b/src/app/api/import/logseq/route.ts @@ -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> { + const entries = new Map(); + + // 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(); // 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; 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(); + + for (const item of importedNotes) { + const { parsed } = item; + + // Convert bodyMarkdown to HTML (simple) + const htmlContent = parsed.bodyMarkdown + .split('\n\n') + .map((p) => `

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

`) + .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 = { + note: 'NOTE', + link: 'BOOKMARK', + reference: 'CLIP', + file: 'FILE', + task: 'NOTE', + person: 'NOTE', + idea: 'NOTE', + }; + return map[cardType] || 'NOTE'; +} + +function guessMimeType(ext: string): string { + const map: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', + '.json': 'application/json', '.csv': 'text/csv', + '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', + '.webm': 'audio/webm', '.mp4': 'audio/mp4', + }; + return map[ext.toLowerCase()] || 'application/octet-stream'; +} diff --git a/src/app/api/notebooks/[id]/canvas/route.ts b/src/app/api/notebooks/[id]/canvas/route.ts index 33bcac2..7ed725e 100644 --- a/src/app/api/notebooks/[id]/canvas/route.ts +++ b/src/app/api/notebooks/[id]/canvas/route.ts @@ -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 }, diff --git a/src/app/api/notebooks/[id]/notes/route.ts b/src/app/api/notebooks/[id]/notes/route.ts index 1a08b15..627c918 100644 --- a/src/app/api/notebooks/[id]/notes/route.ts +++ b/src/app/api/notebooks/[id]/notes/route.ts @@ -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' } }, }, }); diff --git a/src/app/api/notes/[id]/attachments/route.ts b/src/app/api/notes/[id]/attachments/route.ts new file mode 100644 index 0000000..19b6e29 --- /dev/null +++ b/src/app/api/notes/[id]/attachments/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notes/[id]/children/route.ts b/src/app/api/notes/[id]/children/route.ts new file mode 100644 index 0000000..d5f1ff1 --- /dev/null +++ b/src/app/api/notes/[id]/children/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 9c9617d..b498945 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -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 = {}; 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); diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index c7f8f7c..ce35049 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -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 = {}; + const where: Record = { + 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' } }, }, }); diff --git a/src/app/api/notes/search/route.ts b/src/app/api/notes/search/route.ts index 5a97e57..6416d5e 100644 --- a/src/app/api/notes/search/route.ts +++ b/src/app/api/notes/search/route.ts @@ -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, StopSel=, 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(), diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts index 2eb101b..f13d296 100644 --- a/src/app/api/sync/route.ts +++ b/src/app/api/sync/route.ts @@ -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 = {}; + 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 }); } } diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index d5e94db..8415022 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -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); diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 9cf6de4..6875ed7 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -18,12 +18,26 @@ const TYPE_COLORS: Record = { AUDIO: 'bg-red-500/20 text-red-400', }; +const CARD_TYPE_COLORS: Record = { + 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; 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(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 = { 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 (
/ + {note.parent && ( + <> + + {note.parent.title} + + / + + )} {note.notebook ? ( <> @@ -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" > - Delete + Forget @@ -233,6 +277,16 @@ export default function NoteDetailPage() { {note.type} + {note.cardType !== 'note' && ( + + {note.cardType} + + )} + {note.visibility !== 'private' && ( + + {note.visibility} + + )} {note.tags.map((nt) => ( ))} @@ -241,6 +295,24 @@ export default function NoteDetailPage() { + {/* Summary */} + {note.summary && ( +
+ {note.summary} +
+ )} + + {/* Properties */} + {properties.length > 0 && ( +
+ {properties.map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+ )} + {/* URL */} {note.url && ( )} + {/* Attachments gallery */} + {note.attachments.length > 0 && ( + + )} + {/* Content */} {editing ? (
@@ -336,7 +436,8 @@ export default function NoteDetailPage() { />
@@ -357,6 +458,27 @@ export default function NoteDetailPage() { )} )} + + {/* Children */} + {note.children.length > 0 && ( +
+

Child Notes

+
+ {note.children.map((child) => ( + + + {child.cardType} + + {child.title} + + ))} +
+
+ )} ); diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx index 05c1c75..2c352a6 100644 --- a/src/app/notes/new/page.tsx +++ b/src/app/notes/new/page.tsx @@ -46,6 +46,7 @@ function NewNoteForm() { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); + const [bodyJson, setBodyJson] = useState(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() { diff --git a/src/components/MemoryCardView.tsx b/src/components/MemoryCardView.tsx new file mode 100644 index 0000000..324eb69 --- /dev/null +++ b/src/components/MemoryCardView.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { TagBadge } from './TagBadge'; + +const CARD_TYPE_COLORS: Record = { + 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; + 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) => 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 = {}; + 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 ( +
+ {/* Card Type & Visibility */} +
+ + {cardType} + + {editable ? ( +
+ {VISIBILITY_OPTIONS.map((v) => ( + + ))} +
+ ) : visibility !== 'private' ? ( + + {visibility} + + ) : null} +
+ + {/* Summary */} + {summary && ( +
+
Summary
+

{summary}

+
+ )} + + {/* Parent breadcrumb */} + {parent && ( +
+ Parent: + + {parent.title} + + + {parent.cardType} + +
+ )} + + {/* Properties */} +
+
+ Properties + {editable && ( + + )} +
+ {editingProps ? ( +
+ {propEntries.map(([key, value], i) => ( +
+ { + 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" + /> + :: + { + 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" + /> + +
+ ))} +
+ 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()} + /> + :: + 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()} + /> + +
+
+ ) : propEntries.length > 0 ? ( +
+ {propEntries.map(([key, value]) => ( +
+ {key}:: + {value} +
+ ))} +
+ ) : ( +

No properties set

+ )} +
+ + {/* Children */} + {children.length > 0 && ( +
+ + Child Notes ({children.length}) + +
+ {children.map((child) => ( + + + {child.cardType} + + {child.title} + + ))} +
+
+ )} + + {/* Tags */} + {tags.length > 0 && ( +
+ Tags +
+ {tags.map((nt) => ( + + ))} +
+
+ )} + + {/* Attachments */} + {attachments.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index d2e7f7c..83d7012 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -13,34 +13,70 @@ const TYPE_COLORS: Record = { AUDIO: 'bg-red-500/20 text-red-400', }; +const CARD_TYPE_STYLES: Record = { + 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; } -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 (
{type} + {cardType !== 'note' && ( + + {cardType} + + )} {isPinned && ( )} + {childCount > 0 && ( + 1 ? 's' : ''}`}> + ▽ {childCount} + + )} + {visibility && visibility !== 'private' && ( + + {visibility} + + )} {new Date(updatedAt).toLocaleDateString()} @@ -58,6 +94,20 @@ export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, t

{snippet}

)} + {/* Property badges */} + {propertyEntries.length > 0 && ( +
+ {propertyEntries.slice(0, 3).map(([key, value]) => ( + + {key}: {String(value)} + + ))} + {propertyEntries.length > 3 && ( + +{propertyEntries.length - 3} + )} +
+ )} + {tags.length > 0 && (
{tags.slice(0, 4).map((tag) => ( diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 0a736bb..7b765ce 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -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) { +function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }: Omit) { const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -61,9 +62,9 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit { - onChange(editor.getHTML()); + onChange(editor.getHTML(), editor.getJSON()); }, editorProps: { attributes: { @@ -168,7 +169,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit editor.chain().focus().toggleTaskList().run()} title="Task List" > - ☐ Tasks + ☐ Tasks
@@ -210,19 +211,19 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit editor.chain().focus().setHorizontalRule().run()} title="Horizontal Rule" > - ─ + ─ editor.chain().focus().undo().run()} title="Undo" > - ↩ + ↩ editor.chain().focus().redo().run()} title="Redo" > - ↪ + ↪
@@ -232,7 +233,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit; + return ; } diff --git a/src/lib/content-convert.ts b/src/lib/content-convert.ts new file mode 100644 index 0000000..80f3863 --- /dev/null +++ b/src/lib/content-convert.ts @@ -0,0 +1,396 @@ +import { NoteType } from '@prisma/client'; + +// ─── TipTap JSON types ────────────────────────────────────────────── + +interface TipTapNode { + type: string; + content?: TipTapNode[]; + text?: string; + attrs?: Record; + marks?: { type: string; attrs?: Record }[]; +} + +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

+ text = text.replace(/^]*>|<\/p>$/gi, ''); + + // Replace
with newline markers + text = text.replace(//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 }[] = []; + 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 = /]*>([\s\S]*?)<\/li>/gi; + let liMatch; + while ((liMatch = liRegex.exec(html)) !== null) { + // Check for task list items + const taskMatch = liMatch[1].match(/^]*type="checkbox"[^>]*(checked)?[^>]*\/?>\s*/i); + if (taskMatch) { + const content = liMatch[1].replace(/]*\/?>\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>/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(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); +} + +export function htmlToTipTapJson(html: string): TipTapDoc { + return parseSimpleHtml(html); +} + +// ─── TipTap JSON → HTML ──────────────────────────────────────────── + +function renderMarks(text: string, marks?: { type: string; attrs?: Record }[]): string { + if (!marks || marks.length === 0) return escapeHtml(text); + + let result = escapeHtml(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}`; + 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 `

${children}

`; + case 'heading': + const level = node.attrs?.level || 1; + return `${children}`; + case 'bulletList': + return `
    ${children}
`; + case 'orderedList': + return `
    ${children}
`; + case 'listItem': + return `
  • ${children}
  • `; + case 'taskList': + return `
      ${children}
    `; + case 'taskItem': { + const checked = node.attrs?.checked ? ' checked' : ''; + return `
  • ${children}
  • `; + } + case 'blockquote': + return `
    ${children}
    `; + case 'codeBlock': + return `
    ${children}
    `; + case 'horizontalRule': + return '
    '; + case 'image': + return `${escapeHtml(String(node.attrs?.alt || ''))}`; + case 'hardBreak': + return '
    '; + default: + return children; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +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 { + 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 { + const { marked } = await import('marked'); + const html = await marked.parse(md); + return htmlToTipTapJson(html); +} + +// ─── NoteType → cardType mapping ─────────────────────────────────── + +const NOTE_TYPE_TO_CARD_TYPE: Record = { + 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'; +} diff --git a/src/lib/logseq-format.ts b/src/lib/logseq-format.ts new file mode 100644 index 0000000..b73b914 --- /dev/null +++ b/src/lib/logseq-format.ts @@ -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; + 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; + 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 = {}; + 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, + }; +}