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:
parent
adc68d06e1
commit
7b1d120379
|
|
@ -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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/prisma ./prisma
|
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
|
||||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||||
"@tiptap/extension-image": "^3.19.0",
|
"@tiptap/extension-image": "^3.19.0",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"archiver": "^7.0.0",
|
||||||
"dompurify": "^3.2.0",
|
"dompurify": "^3.2.0",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
|
|
@ -33,6 +35,7 @@
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/archiver": "^6",
|
||||||
"@types/dompurify": "^3",
|
"@types/dompurify": "^3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ model User {
|
||||||
|
|
||||||
notebooks NotebookCollaborator[]
|
notebooks NotebookCollaborator[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
files File[]
|
||||||
sharedByMe SharedAccess[] @relation("SharedBy")
|
sharedByMe SharedAccess[] @relation("SharedBy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,12 +87,31 @@ model Note {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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([notebookId])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@index([isPinned])
|
@@index([isPinned])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([cardType])
|
||||||
|
@@index([archivedAt])
|
||||||
|
@@index([position])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NoteType {
|
enum NoteType {
|
||||||
|
|
@ -104,15 +124,52 @@ enum NoteType {
|
||||||
AUDIO
|
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 ───────────────────────────────────────────────────────────
|
// ─── Tags ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String
|
||||||
color String? @default("#6b7280")
|
color String? @default("#6b7280")
|
||||||
|
spaceId String @default("") // "" = global, otherwise space-scoped
|
||||||
|
schema Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
notes NoteTag[]
|
notes NoteTag[]
|
||||||
|
|
||||||
|
@@unique([spaceId, name])
|
||||||
|
@@index([spaceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NoteTag {
|
model NoteTag {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,6 @@ import { prisma } from '@/lib/prisma';
|
||||||
import { pushShapesToCanvas } from '@/lib/canvas-sync';
|
import { pushShapesToCanvas } from '@/lib/canvas-sync';
|
||||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
|
|
@ -26,7 +20,12 @@ export async function POST(
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
include: {
|
include: {
|
||||||
notes: {
|
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' }],
|
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -76,12 +75,20 @@ export async function POST(
|
||||||
url: note.url || '',
|
url: note.url || '',
|
||||||
tags: note.tags.map((nt) => nt.tag.name),
|
tags: note.tags.map((nt) => nt.tag.name),
|
||||||
noteId: note.id,
|
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);
|
await pushShapesToCanvas(canvasSlug, shapes);
|
||||||
|
|
||||||
// Store canvasSlug if not set
|
|
||||||
if (!notebook.canvasSlug) {
|
if (!notebook.canvasSlug) {
|
||||||
await prisma.notebook.update({
|
await prisma.notebook.update({
|
||||||
where: { id: notebook.id },
|
where: { id: notebook.id },
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { stripHtml } from '@/lib/strip-html';
|
import { stripHtml } from '@/lib/strip-html';
|
||||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||||
|
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -9,9 +10,12 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: { notebookId: params.id },
|
where: { notebookId: params.id, archivedAt: null },
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
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' }],
|
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||||
});
|
});
|
||||||
|
|
@ -37,7 +41,10 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
|
|
@ -45,6 +52,23 @@ export async function POST(
|
||||||
|
|
||||||
const contentPlain = content ? stripHtml(content) : null;
|
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
|
// Find or create tags
|
||||||
const tagRecords = [];
|
const tagRecords = [];
|
||||||
if (tags && Array.isArray(tags)) {
|
if (tags && Array.isArray(tags)) {
|
||||||
|
|
@ -52,9 +76,9 @@ export async function POST(
|
||||||
const name = tagName.trim().toLowerCase();
|
const name = tagName.trim().toLowerCase();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const tag = await prisma.tag.upsert({
|
const tag = await prisma.tag.upsert({
|
||||||
where: { name },
|
where: { spaceId_name: { spaceId: '', name } },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name },
|
create: { name, spaceId: '' },
|
||||||
});
|
});
|
||||||
tagRecords.push(tag);
|
tagRecords.push(tag);
|
||||||
}
|
}
|
||||||
|
|
@ -67,13 +91,22 @@ export async function POST(
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content || '',
|
content: content || '',
|
||||||
contentPlain,
|
contentPlain,
|
||||||
type: type || 'NOTE',
|
type: noteType,
|
||||||
url: url || null,
|
url: url || null,
|
||||||
language: language || null,
|
language: language || null,
|
||||||
fileUrl: fileUrl || null,
|
fileUrl: fileUrl || null,
|
||||||
mimeType: mimeType || null,
|
mimeType: mimeType || null,
|
||||||
fileSize: fileSize || null,
|
fileSize: fileSize || null,
|
||||||
duration: duration || 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: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
@ -82,6 +115,9 @@ export async function POST(
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
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' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { stripHtml } from '@/lib/strip-html';
|
import { stripHtml } from '@/lib/strip-html';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
|
import { htmlToTipTapJson, tipTapJsonToHtml, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -14,6 +15,16 @@ export async function GET(
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
notebook: { select: { id: true, title: true, slug: true } },
|
notebook: { select: { id: true, title: true, slug: true } },
|
||||||
author: { select: { id: true, username: 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;
|
if (!isAuthed(auth)) return auth;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
|
|
||||||
// Verify the user is the author
|
|
||||||
const existing = await prisma.note.findUnique({
|
const existing = await prisma.note.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
select: { authorId: true },
|
select: { authorId: true },
|
||||||
|
|
@ -50,33 +60,61 @@ export async function PUT(
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (title !== undefined) data.title = title.trim();
|
if (title !== undefined) data.title = title.trim();
|
||||||
if (content !== undefined) {
|
|
||||||
data.content = content;
|
|
||||||
data.contentPlain = stripHtml(content);
|
|
||||||
}
|
|
||||||
if (type !== undefined) data.type = type;
|
if (type !== undefined) data.type = type;
|
||||||
if (url !== undefined) data.url = url || null;
|
if (url !== undefined) data.url = url || null;
|
||||||
if (language !== undefined) data.language = language || null;
|
if (language !== undefined) data.language = language || null;
|
||||||
if (isPinned !== undefined) data.isPinned = isPinned;
|
if (isPinned !== undefined) data.isPinned = isPinned;
|
||||||
if (notebookId !== undefined) data.notebookId = notebookId || null;
|
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
|
// Handle tag updates: replace all tags
|
||||||
if (tags !== undefined && Array.isArray(tags)) {
|
if (tags !== undefined && Array.isArray(tags)) {
|
||||||
// Delete existing tag links
|
|
||||||
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
|
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
|
||||||
|
|
||||||
// Find or create tags and link
|
|
||||||
for (const tagName of tags) {
|
for (const tagName of tags) {
|
||||||
const name = tagName.trim().toLowerCase();
|
const name = tagName.trim().toLowerCase();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const tag = await prisma.tag.upsert({
|
const tag = await prisma.tag.upsert({
|
||||||
where: { name },
|
where: { spaceId_name: { spaceId: '', name } },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name },
|
create: { name, spaceId: '' },
|
||||||
});
|
});
|
||||||
await prisma.noteTag.create({
|
await prisma.noteTag.create({
|
||||||
data: { noteId: params.id, tagId: tag.id },
|
data: { noteId: params.id, tagId: tag.id },
|
||||||
|
|
@ -90,6 +128,16 @@ export async function PUT(
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
notebook: { select: { id: true, title: true, slug: 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 });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete note error:', error);
|
console.error('Delete note error:', error);
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,23 @@ import { prisma } from '@/lib/prisma';
|
||||||
import { stripHtml } from '@/lib/strip-html';
|
import { stripHtml } from '@/lib/strip-html';
|
||||||
import { NoteType } from '@prisma/client';
|
import { NoteType } from '@prisma/client';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
|
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const notebookId = searchParams.get('notebookId');
|
const notebookId = searchParams.get('notebookId');
|
||||||
const type = searchParams.get('type');
|
const type = searchParams.get('type');
|
||||||
|
const cardType = searchParams.get('cardType');
|
||||||
const tag = searchParams.get('tag');
|
const tag = searchParams.get('tag');
|
||||||
const pinned = searchParams.get('pinned');
|
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 (notebookId) where.notebookId = notebookId;
|
||||||
if (type) where.type = type as NoteType;
|
if (type) where.type = type as NoteType;
|
||||||
|
if (cardType) where.cardType = cardType;
|
||||||
if (pinned === 'true') where.isPinned = true;
|
if (pinned === 'true') where.isPinned = true;
|
||||||
if (tag) {
|
if (tag) {
|
||||||
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
|
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
|
||||||
|
|
@ -25,6 +30,9 @@ export async function GET(request: NextRequest) {
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
notebook: { select: { id: true, title: true, slug: 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' }],
|
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||||
take: 100,
|
take: 100,
|
||||||
|
|
@ -43,7 +51,12 @@ export async function POST(request: NextRequest) {
|
||||||
if (!isAuthed(auth)) return auth;
|
if (!isAuthed(auth)) return auth;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
const body = await request.json();
|
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()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
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;
|
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
|
// Find or create tags
|
||||||
const tagRecords = [];
|
const tagRecords = [];
|
||||||
if (tags && Array.isArray(tags)) {
|
if (tags && Array.isArray(tags)) {
|
||||||
|
|
@ -58,9 +90,9 @@ export async function POST(request: NextRequest) {
|
||||||
const name = tagName.trim().toLowerCase();
|
const name = tagName.trim().toLowerCase();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const tag = await prisma.tag.upsert({
|
const tag = await prisma.tag.upsert({
|
||||||
where: { name },
|
where: { spaceId_name: { spaceId: '', name } },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name },
|
create: { name, spaceId: '' },
|
||||||
});
|
});
|
||||||
tagRecords.push(tag);
|
tagRecords.push(tag);
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +103,7 @@ export async function POST(request: NextRequest) {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content || '',
|
content: content || '',
|
||||||
contentPlain,
|
contentPlain,
|
||||||
type: type || 'NOTE',
|
type: noteType,
|
||||||
notebookId: notebookId || null,
|
notebookId: notebookId || null,
|
||||||
authorId: user.id,
|
authorId: user.id,
|
||||||
url: url || null,
|
url: url || null,
|
||||||
|
|
@ -80,6 +112,16 @@ export async function POST(request: NextRequest) {
|
||||||
mimeType: mimeType || null,
|
mimeType: mimeType || null,
|
||||||
fileSize: fileSize || null,
|
fileSize: fileSize || null,
|
||||||
duration: duration || 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: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
@ -89,6 +131,9 @@ export async function POST(request: NextRequest) {
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
notebook: { select: { id: true, title: true, slug: 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' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const q = searchParams.get('q')?.trim();
|
const q = searchParams.get('q')?.trim();
|
||||||
const type = searchParams.get('type');
|
const type = searchParams.get('type');
|
||||||
|
const cardType = searchParams.get('cardType');
|
||||||
const notebookId = searchParams.get('notebookId');
|
const notebookId = searchParams.get('notebookId');
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
|
|
@ -15,13 +16,17 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build WHERE clauses for optional filters
|
// Build WHERE clauses for optional filters
|
||||||
const filters: string[] = [];
|
const filters: string[] = ['n."archivedAt" IS NULL'];
|
||||||
const params: (string | null)[] = [q]; // $1 = search query
|
const params: (string | null)[] = [q]; // $1 = search query
|
||||||
|
|
||||||
if (type) {
|
if (type) {
|
||||||
params.push(type);
|
params.push(type);
|
||||||
filters.push(`n."type" = $${params.length}::"NoteType"`);
|
filters.push(`n."type" = $${params.length}::"NoteType"`);
|
||||||
}
|
}
|
||||||
|
if (cardType) {
|
||||||
|
params.push(cardType);
|
||||||
|
filters.push(`n."cardType" = $${params.length}`);
|
||||||
|
}
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
params.push(notebookId);
|
params.push(notebookId);
|
||||||
filters.push(`n."notebookId" = $${params.length}`);
|
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 ')}` : '';
|
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
||||||
|
|
||||||
// Full-text search using PostgreSQL ts_vector + ts_query
|
// Full-text search — prefer bodyMarkdown over contentPlain
|
||||||
// Falls back to ILIKE if the GIN index hasn't been created yet
|
|
||||||
const results = await prisma.$queryRawUnsafe<Array<{
|
const results = await prisma.$queryRawUnsafe<Array<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentPlain: string | null;
|
contentPlain: string | null;
|
||||||
|
bodyMarkdown: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
|
cardType: string;
|
||||||
notebookId: string | null;
|
notebookId: string | null;
|
||||||
notebookTitle: string | null;
|
notebookTitle: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
summary: string | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
rank: number;
|
rank: number;
|
||||||
headline: string | null;
|
headline: string | null;
|
||||||
|
|
@ -49,25 +56,29 @@ export async function GET(request: NextRequest) {
|
||||||
n.title,
|
n.title,
|
||||||
n.content,
|
n.content,
|
||||||
n."contentPlain",
|
n."contentPlain",
|
||||||
|
n."bodyMarkdown",
|
||||||
n.type::"text" as type,
|
n.type::"text" as type,
|
||||||
|
n."cardType",
|
||||||
n."notebookId",
|
n."notebookId",
|
||||||
nb.title as "notebookTitle",
|
nb.title as "notebookTitle",
|
||||||
n."isPinned",
|
n."isPinned",
|
||||||
|
n.summary,
|
||||||
n."updatedAt",
|
n."updatedAt",
|
||||||
ts_rank(
|
ts_rank(
|
||||||
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title),
|
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title),
|
||||||
plainto_tsquery('english', $1)
|
plainto_tsquery('english', $1)
|
||||||
) as rank,
|
) as rank,
|
||||||
ts_headline('english',
|
ts_headline('english',
|
||||||
COALESCE(n."contentPlain", n.content, ''),
|
COALESCE(n."bodyMarkdown", n."contentPlain", n.content, ''),
|
||||||
plainto_tsquery('english', $1),
|
plainto_tsquery('english', $1),
|
||||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
|
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
|
||||||
) as headline
|
) as headline
|
||||||
FROM "Note" n
|
FROM "Note" n
|
||||||
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
||||||
WHERE (
|
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.title ILIKE '%' || $1 || '%'
|
||||||
|
OR n."bodyMarkdown" ILIKE '%' || $1 || '%'
|
||||||
OR n."contentPlain" ILIKE '%' || $1 || '%'
|
OR n."contentPlain" ILIKE '%' || $1 || '%'
|
||||||
)
|
)
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -95,8 +106,9 @@ export async function GET(request: NextRequest) {
|
||||||
const response = results.map((note) => ({
|
const response = results.map((note) => ({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
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,
|
type: note.type,
|
||||||
|
cardType: note.cardType,
|
||||||
notebookId: note.notebookId,
|
notebookId: note.notebookId,
|
||||||
notebookTitle: note.notebookTitle,
|
notebookTitle: note.notebookTitle,
|
||||||
updatedAt: new Date(note.updatedAt).toISOString(),
|
updatedAt: new Date(note.updatedAt).toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { prisma } from '@/lib/prisma';
|
||||||
/**
|
/**
|
||||||
* POST /api/sync
|
* POST /api/sync
|
||||||
*
|
*
|
||||||
* Receives shape update events from the rSpace canvas (via postMessage → CanvasEmbed → fetch)
|
* Receives shape update events from the rSpace canvas and updates DB records.
|
||||||
* and updates the corresponding DB records.
|
* Handles Memory Card fields: cardType, summary, properties, visibility.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -19,7 +19,6 @@ export async function POST(request: NextRequest) {
|
||||||
const shapeType = data?.type as string | undefined;
|
const shapeType = data?.type as string | undefined;
|
||||||
|
|
||||||
if (type === 'shape-deleted') {
|
if (type === 'shape-deleted') {
|
||||||
// Clear canvasShapeId references (don't delete the DB record)
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.note.updateMany({
|
prisma.note.updateMany({
|
||||||
where: { canvasShapeId: shapeId },
|
where: { canvasShapeId: shapeId },
|
||||||
|
|
@ -41,12 +40,19 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note) {
|
if (note) {
|
||||||
await prisma.note.update({
|
const updateData: Record<string, unknown> = {};
|
||||||
where: { id: note.id },
|
if (data.noteTitle) updateData.title = data.noteTitle;
|
||||||
data: {
|
if (data.cardType) updateData.cardType = data.cardType;
|
||||||
title: (data.noteTitle as string) || note.title,
|
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 });
|
return NextResponse.json({ ok: true, action: 'updated', entity: 'note', id: note.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
@ -32,6 +33,7 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAuth(request);
|
const auth = await requireAuth(request);
|
||||||
if (!isAuthed(auth)) return auth;
|
if (!isAuthed(auth)) return auth;
|
||||||
|
const { user } = auth;
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
|
@ -73,12 +75,24 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const fileUrl = `/api/uploads/${uniqueName}`;
|
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({
|
return NextResponse.json({
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
filename: uniqueName,
|
filename: uniqueName,
|
||||||
originalName: file.name,
|
originalName: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
|
fileId: fileRecord.id,
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,26 @@ const TYPE_COLORS: Record<string, string> = {
|
||||||
AUDIO: 'bg-red-500/20 text-red-400',
|
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 {
|
interface NoteData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentPlain: string | null;
|
contentPlain: string | null;
|
||||||
|
bodyJson: object | null;
|
||||||
|
bodyMarkdown: string | null;
|
||||||
|
bodyFormat: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
cardType: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
fileUrl: string | null;
|
fileUrl: string | null;
|
||||||
|
|
@ -32,10 +46,16 @@ interface NoteData {
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
canvasShapeId: string | null;
|
canvasShapeId: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
visibility: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
notebook: { id: string; title: string; slug: string } | null;
|
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 } }[];
|
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() {
|
export default function NoteDetailPage() {
|
||||||
|
|
@ -46,6 +66,7 @@ export default function NoteDetailPage() {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState('');
|
const [editTitle, setEditTitle] = useState('');
|
||||||
const [editContent, setEditContent] = useState('');
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [editBodyJson, setEditBodyJson] = useState<object | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [diarizing, setDiarizing] = useState(false);
|
const [diarizing, setDiarizing] = useState(false);
|
||||||
const [speakers, setSpeakers] = useState<{ speaker: string; start: number; end: number }[] | null>(null);
|
const [speakers, setSpeakers] = useState<{ speaker: string; start: number; end: number }[] | null>(null);
|
||||||
|
|
@ -57,19 +78,32 @@ export default function NoteDetailPage() {
|
||||||
setNote(data);
|
setNote(data);
|
||||||
setEditTitle(data.title);
|
setEditTitle(data.title);
|
||||||
setEditContent(data.content);
|
setEditContent(data.content);
|
||||||
|
setEditBodyJson(data.bodyJson || null);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
|
const handleEditorChange = (html: string, json?: object) => {
|
||||||
|
setEditContent(html);
|
||||||
|
if (json) setEditBodyJson(json);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (saving) return;
|
if (saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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}`, {
|
const res = await authFetch(`/api/notes/${params.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: editTitle, content: editContent }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
|
|
@ -97,7 +131,7 @@ export default function NoteDetailPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
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' });
|
await authFetch(`/api/notes/${params.id}`, { method: 'DELETE' });
|
||||||
if (note?.notebook) {
|
if (note?.notebook) {
|
||||||
router.push(`/notebooks/${note.notebook.id}`);
|
router.push(`/notebooks/${note.notebook.id}`);
|
||||||
|
|
@ -110,7 +144,6 @@ export default function NoteDetailPage() {
|
||||||
if (!note?.fileUrl || diarizing) return;
|
if (!note?.fileUrl || diarizing) return;
|
||||||
setDiarizing(true);
|
setDiarizing(true);
|
||||||
try {
|
try {
|
||||||
// Fetch the audio file from the server
|
|
||||||
const audioRes = await fetch(note.fileUrl);
|
const audioRes = await fetch(note.fileUrl);
|
||||||
const audioBlob = await audioRes.blob();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a]">
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
<nav className="border-b border-slate-800 px-4 md:px-6 py-4">
|
<nav className="border-b border-slate-800 px-4 md:px-6 py-4">
|
||||||
|
|
@ -165,6 +200,14 @@ export default function NoteDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-slate-600 hidden sm:inline">/</span>
|
<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 ? (
|
{note.notebook ? (
|
||||||
<>
|
<>
|
||||||
<Link href={`/notebooks/${note.notebook.id}`} className="text-slate-400 hover:text-white transition-colors hidden sm:inline truncate max-w-[120px]">
|
<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);
|
setEditing(false);
|
||||||
setEditTitle(note.title);
|
setEditTitle(note.title);
|
||||||
setEditContent(note.content);
|
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"
|
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}
|
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"
|
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>
|
<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>
|
</button>
|
||||||
<UserMenu />
|
<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}`}>
|
<span className={`text-xs font-bold uppercase px-2 py-1 rounded ${TYPE_COLORS[note.type] || TYPE_COLORS.NOTE}`}>
|
||||||
{note.type}
|
{note.type}
|
||||||
</span>
|
</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) => (
|
{note.tags.map((nt) => (
|
||||||
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
|
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -241,6 +295,24 @@ export default function NoteDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* URL */}
|
||||||
{note.url && (
|
{note.url && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -325,6 +397,34 @@ export default function NoteDetailPage() {
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -336,7 +436,8 @@ export default function NoteDetailPage() {
|
||||||
/>
|
/>
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={setEditContent}
|
valueJson={editBodyJson || undefined}
|
||||||
|
onChange={handleEditorChange}
|
||||||
type={note.type}
|
type={note.type}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,6 +458,27 @@ export default function NoteDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ function NewNoteForm() {
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [bodyJson, setBodyJson] = useState<object | null>(null);
|
||||||
const [type, setType] = useState('NOTE');
|
const [type, setType] = useState('NOTE');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [language, setLanguage] = useState('');
|
const [language, setLanguage] = useState('');
|
||||||
|
|
@ -65,6 +66,11 @@ function NewNoteForm() {
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleContentChange = (html: string, json?: object) => {
|
||||||
|
setContent(html);
|
||||||
|
if (json) setBodyJson(json);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim() || saving) return;
|
if (!title.trim() || saving) return;
|
||||||
|
|
@ -77,6 +83,7 @@ function NewNoteForm() {
|
||||||
type,
|
type,
|
||||||
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
};
|
};
|
||||||
|
if (bodyJson) body.bodyJson = bodyJson;
|
||||||
if (notebookId) body.notebookId = notebookId;
|
if (notebookId) body.notebookId = notebookId;
|
||||||
if (url) body.url = url;
|
if (url) body.url = url;
|
||||||
if (language) body.language = language;
|
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>
|
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
value={content}
|
value={content}
|
||||||
onChange={setContent}
|
onChange={handleContentChange}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>·</span>
|
||||||
|
<span>{(att.file.sizeBytes / 1024).toFixed(0)} KB</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,34 +13,70 @@ const TYPE_COLORS: Record<string, string> = {
|
||||||
AUDIO: 'bg-red-500/20 text-red-400',
|
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 {
|
interface NoteCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
cardType?: string;
|
||||||
contentPlain?: string | null;
|
contentPlain?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
tags: { id: string; name: string; color: string | null }[];
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
url?: 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) {
|
export function NoteCard({
|
||||||
const snippet = (contentPlain || '').slice(0, 120);
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/notes/${id}`}
|
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">
|
<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}`}>
|
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${TYPE_COLORS[type] || TYPE_COLORS.NOTE}`}>
|
||||||
{type}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
|
{cardType !== 'note' && (
|
||||||
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${cardStyle.bg} ${cardStyle.text}`}>
|
||||||
|
{cardType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
<span className="text-amber-400 text-xs" title="Pinned to canvas">
|
<span className="text-amber-400 text-xs" title="Pinned to canvas">
|
||||||
★
|
★
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{childCount > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-500" title={`${childCount} child note${childCount > 1 ? 's' : ''}`}>
|
||||||
|
▽ {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">
|
<span className="text-[10px] text-slate-500 ml-auto">
|
||||||
{new Date(updatedAt).toLocaleDateString()}
|
{new Date(updatedAt).toLocaleDateString()}
|
||||||
</span>
|
</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>
|
<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 && (
|
{tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{tags.slice(0, 4).map((tag) => (
|
{tags.slice(0, 4).map((tag) => (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import Image from '@tiptap/extension-image';
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (content: string) => void;
|
onChange: (content: string, json?: object) => void;
|
||||||
|
valueJson?: object;
|
||||||
type?: string;
|
type?: string;
|
||||||
placeholder?: 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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
|
@ -61,9 +62,9 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
||||||
TaskItem.configure({ nested: true }),
|
TaskItem.configure({ nested: true }),
|
||||||
Image.configure({ inline: true }),
|
Image.configure({ inline: true }),
|
||||||
],
|
],
|
||||||
content: value || '',
|
content: valueJson || value || '',
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onChange(editor.getHTML());
|
onChange(editor.getHTML(), editor.getJSON());
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
|
@ -168,7 +169,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
||||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
title="Task List"
|
title="Task List"
|
||||||
>
|
>
|
||||||
☐ Tasks
|
☐ Tasks
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-slate-700 mx-1" />
|
<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()}
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
title="Horizontal Rule"
|
title="Horizontal Rule"
|
||||||
>
|
>
|
||||||
─
|
─
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
title="Undo"
|
title="Undo"
|
||||||
>
|
>
|
||||||
↩
|
↩
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
title="Redo"
|
title="Redo"
|
||||||
>
|
>
|
||||||
↪
|
↪
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</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';
|
const isCode = type === 'CODE';
|
||||||
|
|
||||||
if (isCode) {
|
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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(/&/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, 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, '&')
|
||||||
|
.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, 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 ``;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
@ -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(`- `);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue