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/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/src/lib/content-convert.ts ./src/lib/content-convert.ts
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"dependencies": {
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
"@tiptap/extension-image": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"archiver": "^7.0.0",
|
||||
"dompurify": "^3.2.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"marked": "^15.0.0",
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6",
|
||||
"@types/dompurify": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ model User {
|
|||
|
||||
notebooks NotebookCollaborator[]
|
||||
notes Note[]
|
||||
files File[]
|
||||
sharedByMe SharedAccess[] @relation("SharedBy")
|
||||
}
|
||||
|
||||
|
|
@ -86,12 +87,31 @@ model Note {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tags NoteTag[]
|
||||
// ─── Memory Card fields ─────────────────────────────────────────
|
||||
parentId String?
|
||||
parent Note? @relation("NoteTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Note[] @relation("NoteTree")
|
||||
bodyJson Json? // TipTap JSON (canonical format)
|
||||
bodyMarkdown String? @db.Text // portable markdown for search + Logseq
|
||||
bodyFormat String @default("html") // "html" | "markdown" | "blocks"
|
||||
cardType String @default("note") // note|link|file|task|person|idea|reference
|
||||
summary String? // auto or manual
|
||||
visibility String @default("private") // private|space|public
|
||||
position Float? // fractional ordering
|
||||
properties Json @default("{}") // Logseq-compatible key-value
|
||||
archivedAt DateTime? // soft-delete
|
||||
|
||||
tags NoteTag[]
|
||||
attachments CardAttachment[]
|
||||
|
||||
@@index([notebookId])
|
||||
@@index([authorId])
|
||||
@@index([type])
|
||||
@@index([isPinned])
|
||||
@@index([parentId])
|
||||
@@index([cardType])
|
||||
@@index([archivedAt])
|
||||
@@index([position])
|
||||
}
|
||||
|
||||
enum NoteType {
|
||||
|
|
@ -104,15 +124,52 @@ enum NoteType {
|
|||
AUDIO
|
||||
}
|
||||
|
||||
// ─── Files & Attachments ────────────────────────────────────────────
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
storageKey String @unique // unique filename on disk
|
||||
filename String // original filename
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksum String?
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
attachments CardAttachment[]
|
||||
}
|
||||
|
||||
model CardAttachment {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
fileId String
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
role String @default("supporting") // "primary"|"preview"|"supporting"
|
||||
caption String?
|
||||
position Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([noteId, fileId])
|
||||
@@index([noteId])
|
||||
@@index([fileId])
|
||||
}
|
||||
|
||||
// ─── Tags ───────────────────────────────────────────────────────────
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
name String
|
||||
color String? @default("#6b7280")
|
||||
spaceId String @default("") // "" = global, otherwise space-scoped
|
||||
schema Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
notes NoteTag[]
|
||||
|
||||
@@unique([spaceId, name])
|
||||
@@index([spaceId])
|
||||
}
|
||||
|
||||
model NoteTag {
|
||||
|
|
|
|||
|
|
@ -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 { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* POST /api/notebooks/[id]/canvas
|
||||
*
|
||||
* Creates an rSpace community for the notebook and populates it
|
||||
* with initial shapes from the notebook's notes.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
|
|
@ -26,7 +20,12 @@ export async function POST(
|
|||
where: { id: params.id },
|
||||
include: {
|
||||
notes: {
|
||||
include: { tags: { include: { tag: true } } },
|
||||
where: { archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
children: { select: { id: true }, where: { archivedAt: null } },
|
||||
attachments: { select: { id: true } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
},
|
||||
},
|
||||
|
|
@ -76,12 +75,20 @@ export async function POST(
|
|||
url: note.url || '',
|
||||
tags: note.tags.map((nt) => nt.tag.name),
|
||||
noteId: note.id,
|
||||
// Memory Card enrichments
|
||||
cardType: note.cardType,
|
||||
summary: note.summary || '',
|
||||
visibility: note.visibility,
|
||||
properties: note.properties || {},
|
||||
parentId: note.parentId || '',
|
||||
hasChildren: note.children.length > 0,
|
||||
childCount: note.children.length,
|
||||
attachmentCount: note.attachments.length,
|
||||
});
|
||||
});
|
||||
|
||||
await pushShapesToCanvas(canvasSlug, shapes);
|
||||
|
||||
// Store canvasSlug if not set
|
||||
if (!notebook.canvasSlug) {
|
||||
await prisma.notebook.update({
|
||||
where: { id: notebook.id },
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -9,9 +10,12 @@ export async function GET(
|
|||
) {
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { notebookId: params.id },
|
||||
where: { notebookId: params.id, archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
|
@ -37,7 +41,10 @@ export async function POST(
|
|||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
|
||||
const {
|
||||
title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration,
|
||||
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -45,6 +52,23 @@ export async function POST(
|
|||
|
||||
const contentPlain = content ? stripHtml(content) : null;
|
||||
|
||||
// Dual-write
|
||||
let bodyJson = clientBodyJson || null;
|
||||
let bodyMarkdown: string | null = null;
|
||||
let bodyFormat = 'html';
|
||||
|
||||
if (clientBodyJson) {
|
||||
bodyJson = clientBodyJson;
|
||||
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
bodyFormat = 'blocks';
|
||||
} else if (content) {
|
||||
bodyJson = htmlToTipTapJson(content);
|
||||
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
|
||||
}
|
||||
|
||||
const noteType = type || 'NOTE';
|
||||
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
if (tags && Array.isArray(tags)) {
|
||||
|
|
@ -52,9 +76,9 @@ export async function POST(
|
|||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name },
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name },
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
|
|
@ -67,13 +91,22 @@ export async function POST(
|
|||
title: title.trim(),
|
||||
content: content || '',
|
||||
contentPlain,
|
||||
type: type || 'NOTE',
|
||||
type: noteType,
|
||||
url: url || null,
|
||||
language: language || null,
|
||||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
bodyJson: bodyJson || undefined,
|
||||
bodyMarkdown,
|
||||
bodyFormat,
|
||||
cardType: resolvedCardType,
|
||||
parentId: parentId || null,
|
||||
visibility: visibility || 'private',
|
||||
properties: properties || {},
|
||||
summary: summary || null,
|
||||
position: position ?? null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
@ -82,6 +115,9 @@ export async function POST(
|
|||
},
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { stripHtml } from '@/lib/strip-html';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToHtml, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -14,6 +15,16 @@ export async function GET(
|
|||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
author: { select: { id: true, username: true } },
|
||||
parent: { select: { id: true, title: true, cardType: true } },
|
||||
children: {
|
||||
select: { id: true, title: true, cardType: true },
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
attachments: {
|
||||
include: { file: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -37,7 +48,6 @@ export async function PUT(
|
|||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
// Verify the user is the author
|
||||
const existing = await prisma.note.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { authorId: true },
|
||||
|
|
@ -50,33 +60,61 @@ export async function PUT(
|
|||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, content, type, url, language, isPinned, notebookId, tags } = body;
|
||||
const {
|
||||
title, content, type, url, language, isPinned, notebookId, tags,
|
||||
// Memory Card fields
|
||||
parentId, cardType, visibility, properties, summary, position,
|
||||
bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (title !== undefined) data.title = title.trim();
|
||||
if (content !== undefined) {
|
||||
data.content = content;
|
||||
data.contentPlain = stripHtml(content);
|
||||
}
|
||||
if (type !== undefined) data.type = type;
|
||||
if (url !== undefined) data.url = url || null;
|
||||
if (language !== undefined) data.language = language || null;
|
||||
if (isPinned !== undefined) data.isPinned = isPinned;
|
||||
if (notebookId !== undefined) data.notebookId = notebookId || null;
|
||||
|
||||
// Memory Card field updates
|
||||
if (parentId !== undefined) data.parentId = parentId || null;
|
||||
if (cardType !== undefined) data.cardType = cardType;
|
||||
if (visibility !== undefined) data.visibility = visibility;
|
||||
if (properties !== undefined) data.properties = properties;
|
||||
if (summary !== undefined) data.summary = summary || null;
|
||||
if (position !== undefined) data.position = position;
|
||||
|
||||
// Dual-write: if client sends bodyJson, it's canonical
|
||||
if (clientBodyJson) {
|
||||
data.bodyJson = clientBodyJson;
|
||||
data.content = tipTapJsonToHtml(clientBodyJson);
|
||||
data.bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
data.contentPlain = stripHtml(data.content as string);
|
||||
data.bodyFormat = 'blocks';
|
||||
} else if (content !== undefined) {
|
||||
// HTML content — compute all derived formats
|
||||
data.content = content;
|
||||
data.contentPlain = stripHtml(content);
|
||||
const json = htmlToTipTapJson(content);
|
||||
data.bodyJson = json;
|
||||
data.bodyMarkdown = tipTapJsonToMarkdown(json);
|
||||
}
|
||||
|
||||
// If type changed, update cardType too (unless explicitly set)
|
||||
if (type !== undefined && cardType === undefined) {
|
||||
data.cardType = mapNoteTypeToCardType(type);
|
||||
}
|
||||
|
||||
// Handle tag updates: replace all tags
|
||||
if (tags !== undefined && Array.isArray(tags)) {
|
||||
// Delete existing tag links
|
||||
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
|
||||
|
||||
// Find or create tags and link
|
||||
for (const tagName of tags) {
|
||||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name },
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name },
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
await prisma.noteTag.create({
|
||||
data: { noteId: params.id, tagId: tag.id },
|
||||
|
|
@ -90,6 +128,16 @@ export async function PUT(
|
|||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
parent: { select: { id: true, title: true, cardType: true } },
|
||||
children: {
|
||||
select: { id: true, title: true, cardType: true },
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
attachments: {
|
||||
include: { file: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -120,7 +168,12 @@ export async function DELETE(
|
|||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.note.delete({ where: { id: params.id } });
|
||||
// Soft-delete: set archivedAt instead of deleting
|
||||
await prisma.note.update({
|
||||
where: { id: params.id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Delete note error:', error);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,23 @@ import { prisma } from '@/lib/prisma';
|
|||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { NoteType } from '@prisma/client';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
const type = searchParams.get('type');
|
||||
const cardType = searchParams.get('cardType');
|
||||
const tag = searchParams.get('tag');
|
||||
const pinned = searchParams.get('pinned');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = {
|
||||
archivedAt: null, // exclude soft-deleted
|
||||
};
|
||||
if (notebookId) where.notebookId = notebookId;
|
||||
if (type) where.type = type as NoteType;
|
||||
if (cardType) where.cardType = cardType;
|
||||
if (pinned === 'true') where.isPinned = true;
|
||||
if (tag) {
|
||||
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
|
||||
|
|
@ -25,6 +30,9 @@ export async function GET(request: NextRequest) {
|
|||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
take: 100,
|
||||
|
|
@ -43,7 +51,12 @@ export async function POST(request: NextRequest) {
|
|||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const body = await request.json();
|
||||
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
|
||||
const {
|
||||
title, content, type, notebookId, url, language, tags,
|
||||
fileUrl, mimeType, fileSize, duration,
|
||||
// Memory Card fields
|
||||
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -51,6 +64,25 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const contentPlain = content ? stripHtml(content) : null;
|
||||
|
||||
// Dual-write: compute bodyJson + bodyMarkdown
|
||||
let bodyJson = clientBodyJson || null;
|
||||
let bodyMarkdown: string | null = null;
|
||||
let bodyFormat = 'html';
|
||||
|
||||
if (clientBodyJson) {
|
||||
// Client sent TipTap JSON — it's canonical
|
||||
bodyJson = clientBodyJson;
|
||||
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
bodyFormat = 'blocks';
|
||||
} else if (content) {
|
||||
// HTML content — convert to JSON + markdown
|
||||
bodyJson = htmlToTipTapJson(content);
|
||||
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
|
||||
}
|
||||
|
||||
const noteType = type || 'NOTE';
|
||||
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
if (tags && Array.isArray(tags)) {
|
||||
|
|
@ -58,9 +90,9 @@ export async function POST(request: NextRequest) {
|
|||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name },
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name },
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
|
|
@ -71,7 +103,7 @@ export async function POST(request: NextRequest) {
|
|||
title: title.trim(),
|
||||
content: content || '',
|
||||
contentPlain,
|
||||
type: type || 'NOTE',
|
||||
type: noteType,
|
||||
notebookId: notebookId || null,
|
||||
authorId: user.id,
|
||||
url: url || null,
|
||||
|
|
@ -80,6 +112,16 @@ export async function POST(request: NextRequest) {
|
|||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
// Memory Card fields
|
||||
bodyJson: bodyJson || undefined,
|
||||
bodyMarkdown,
|
||||
bodyFormat,
|
||||
cardType: resolvedCardType,
|
||||
parentId: parentId || null,
|
||||
visibility: visibility || 'private',
|
||||
properties: properties || {},
|
||||
summary: summary || null,
|
||||
position: position ?? null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
@ -89,6 +131,9 @@ export async function POST(request: NextRequest) {
|
|||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get('q')?.trim();
|
||||
const type = searchParams.get('type');
|
||||
const cardType = searchParams.get('cardType');
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
|
||||
if (!q) {
|
||||
|
|
@ -15,13 +16,17 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Build WHERE clauses for optional filters
|
||||
const filters: string[] = [];
|
||||
const filters: string[] = ['n."archivedAt" IS NULL'];
|
||||
const params: (string | null)[] = [q]; // $1 = search query
|
||||
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`n."type" = $${params.length}::"NoteType"`);
|
||||
}
|
||||
if (cardType) {
|
||||
params.push(cardType);
|
||||
filters.push(`n."cardType" = $${params.length}`);
|
||||
}
|
||||
if (notebookId) {
|
||||
params.push(notebookId);
|
||||
filters.push(`n."notebookId" = $${params.length}`);
|
||||
|
|
@ -29,17 +34,19 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
||||
|
||||
// Full-text search using PostgreSQL ts_vector + ts_query
|
||||
// Falls back to ILIKE if the GIN index hasn't been created yet
|
||||
// Full-text search — prefer bodyMarkdown over contentPlain
|
||||
const results = await prisma.$queryRawUnsafe<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentPlain: string | null;
|
||||
bodyMarkdown: string | null;
|
||||
type: string;
|
||||
cardType: string;
|
||||
notebookId: string | null;
|
||||
notebookTitle: string | null;
|
||||
isPinned: boolean;
|
||||
summary: string | null;
|
||||
updatedAt: Date;
|
||||
rank: number;
|
||||
headline: string | null;
|
||||
|
|
@ -49,25 +56,29 @@ export async function GET(request: NextRequest) {
|
|||
n.title,
|
||||
n.content,
|
||||
n."contentPlain",
|
||||
n."bodyMarkdown",
|
||||
n.type::"text" as type,
|
||||
n."cardType",
|
||||
n."notebookId",
|
||||
nb.title as "notebookTitle",
|
||||
n."isPinned",
|
||||
n.summary,
|
||||
n."updatedAt",
|
||||
ts_rank(
|
||||
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title),
|
||||
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title),
|
||||
plainto_tsquery('english', $1)
|
||||
) as rank,
|
||||
ts_headline('english',
|
||||
COALESCE(n."contentPlain", n.content, ''),
|
||||
COALESCE(n."bodyMarkdown", n."contentPlain", n.content, ''),
|
||||
plainto_tsquery('english', $1),
|
||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
|
||||
) as headline
|
||||
FROM "Note" n
|
||||
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
||||
WHERE (
|
||||
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
|
||||
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
|
||||
OR n.title ILIKE '%' || $1 || '%'
|
||||
OR n."bodyMarkdown" ILIKE '%' || $1 || '%'
|
||||
OR n."contentPlain" ILIKE '%' || $1 || '%'
|
||||
)
|
||||
${whereClause}
|
||||
|
|
@ -95,8 +106,9 @@ export async function GET(request: NextRequest) {
|
|||
const response = results.map((note) => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
snippet: note.headline || (note.contentPlain || note.content || '').slice(0, 150),
|
||||
snippet: note.summary || note.headline || (note.bodyMarkdown || note.contentPlain || note.content || '').slice(0, 150),
|
||||
type: note.type,
|
||||
cardType: note.cardType,
|
||||
notebookId: note.notebookId,
|
||||
notebookTitle: note.notebookTitle,
|
||||
updatedAt: new Date(note.updatedAt).toISOString(),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { prisma } from '@/lib/prisma';
|
|||
/**
|
||||
* POST /api/sync
|
||||
*
|
||||
* Receives shape update events from the rSpace canvas (via postMessage → CanvasEmbed → fetch)
|
||||
* and updates the corresponding DB records.
|
||||
* Receives shape update events from the rSpace canvas and updates DB records.
|
||||
* Handles Memory Card fields: cardType, summary, properties, visibility.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -19,7 +19,6 @@ export async function POST(request: NextRequest) {
|
|||
const shapeType = data?.type as string | undefined;
|
||||
|
||||
if (type === 'shape-deleted') {
|
||||
// Clear canvasShapeId references (don't delete the DB record)
|
||||
await Promise.all([
|
||||
prisma.note.updateMany({
|
||||
where: { canvasShapeId: shapeId },
|
||||
|
|
@ -41,12 +40,19 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
|
||||
if (note) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
title: (data.noteTitle as string) || note.title,
|
||||
},
|
||||
});
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.noteTitle) updateData.title = data.noteTitle;
|
||||
if (data.cardType) updateData.cardType = data.cardType;
|
||||
if (data.summary !== undefined) updateData.summary = data.summary || null;
|
||||
if (data.visibility) updateData.visibility = data.visibility;
|
||||
if (data.properties && typeof data.properties === 'object') updateData.properties = data.properties;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true, action: 'updated', entity: 'note', id: note.id });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { existsSync } from 'fs';
|
|||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
|
@ -32,6 +33,7 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
|
|
@ -73,12 +75,24 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const fileUrl = `/api/uploads/${uniqueName}`;
|
||||
|
||||
// Create File record in database
|
||||
const fileRecord = await prisma.file.create({
|
||||
data: {
|
||||
storageKey: uniqueName,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
sizeBytes: file.size,
|
||||
authorId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
url: fileUrl,
|
||||
filename: uniqueName,
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
fileId: fileRecord.id,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
|
|
|||
|
|
@ -18,12 +18,26 @@ const TYPE_COLORS: Record<string, string> = {
|
|||
AUDIO: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
const CARD_TYPE_COLORS: Record<string, string> = {
|
||||
note: 'bg-amber-500/20 text-amber-400',
|
||||
link: 'bg-blue-500/20 text-blue-400',
|
||||
file: 'bg-slate-500/20 text-slate-400',
|
||||
task: 'bg-green-500/20 text-green-400',
|
||||
person: 'bg-purple-500/20 text-purple-400',
|
||||
idea: 'bg-yellow-500/20 text-yellow-400',
|
||||
reference: 'bg-pink-500/20 text-pink-400',
|
||||
};
|
||||
|
||||
interface NoteData {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentPlain: string | null;
|
||||
bodyJson: object | null;
|
||||
bodyMarkdown: string | null;
|
||||
bodyFormat: string;
|
||||
type: string;
|
||||
cardType: string;
|
||||
url: string | null;
|
||||
language: string | null;
|
||||
fileUrl: string | null;
|
||||
|
|
@ -32,10 +46,16 @@ interface NoteData {
|
|||
duration: number | null;
|
||||
isPinned: boolean;
|
||||
canvasShapeId: string | null;
|
||||
summary: string | null;
|
||||
visibility: string;
|
||||
properties: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
notebook: { id: string; title: string; slug: string } | null;
|
||||
parent: { id: string; title: string; cardType: string } | null;
|
||||
children: { id: string; title: string; cardType: string }[];
|
||||
tags: { tag: { id: string; name: string; color: string | null } }[];
|
||||
attachments: { id: string; role: string; caption: string | null; file: { id: string; filename: string; mimeType: string; sizeBytes: number; storageKey: string } }[];
|
||||
}
|
||||
|
||||
export default function NoteDetailPage() {
|
||||
|
|
@ -46,6 +66,7 @@ export default function NoteDetailPage() {
|
|||
const [editing, setEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editBodyJson, setEditBodyJson] = useState<object | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [diarizing, setDiarizing] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<{ speaker: string; start: number; end: number }[] | null>(null);
|
||||
|
|
@ -57,19 +78,32 @@ export default function NoteDetailPage() {
|
|||
setNote(data);
|
||||
setEditTitle(data.title);
|
||||
setEditContent(data.content);
|
||||
setEditBodyJson(data.bodyJson || null);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
const handleEditorChange = (html: string, json?: object) => {
|
||||
setEditContent(html);
|
||||
if (json) setEditBodyJson(json);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = { title: editTitle };
|
||||
if (editBodyJson) {
|
||||
payload.bodyJson = editBodyJson;
|
||||
} else {
|
||||
payload.content = editContent;
|
||||
}
|
||||
|
||||
const res = await authFetch(`/api/notes/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: editTitle, content: editContent }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
|
|
@ -97,7 +131,7 @@ export default function NoteDetailPage() {
|
|||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this note?')) return;
|
||||
if (!confirm('Archive this note? It can be restored later.')) return;
|
||||
await authFetch(`/api/notes/${params.id}`, { method: 'DELETE' });
|
||||
if (note?.notebook) {
|
||||
router.push(`/notebooks/${note.notebook.id}`);
|
||||
|
|
@ -110,7 +144,6 @@ export default function NoteDetailPage() {
|
|||
if (!note?.fileUrl || diarizing) return;
|
||||
setDiarizing(true);
|
||||
try {
|
||||
// Fetch the audio file from the server
|
||||
const audioRes = await fetch(note.fileUrl);
|
||||
const audioBlob = await audioRes.blob();
|
||||
|
||||
|
|
@ -154,6 +187,8 @@ export default function NoteDetailPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const properties = note.properties && typeof note.properties === 'object' ? Object.entries(note.properties).filter(([, v]) => v != null && v !== '') : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<nav className="border-b border-slate-800 px-4 md:px-6 py-4">
|
||||
|
|
@ -165,6 +200,14 @@ export default function NoteDetailPage() {
|
|||
</div>
|
||||
</Link>
|
||||
<span className="text-slate-600 hidden sm:inline">/</span>
|
||||
{note.parent && (
|
||||
<>
|
||||
<Link href={`/notes/${note.parent.id}`} className="text-slate-400 hover:text-white transition-colors hidden sm:inline truncate max-w-[120px]">
|
||||
{note.parent.title}
|
||||
</Link>
|
||||
<span className="text-slate-600 hidden sm:inline">/</span>
|
||||
</>
|
||||
)}
|
||||
{note.notebook ? (
|
||||
<>
|
||||
<Link href={`/notebooks/${note.notebook.id}`} className="text-slate-400 hover:text-white transition-colors hidden sm:inline truncate max-w-[120px]">
|
||||
|
|
@ -201,6 +244,7 @@ export default function NoteDetailPage() {
|
|||
setEditing(false);
|
||||
setEditTitle(note.title);
|
||||
setEditContent(note.content);
|
||||
setEditBodyJson(note.bodyJson || null);
|
||||
}}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors hidden sm:inline-flex"
|
||||
>
|
||||
|
|
@ -219,7 +263,7 @@ export default function NoteDetailPage() {
|
|||
onClick={handleDelete}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
<span className="hidden sm:inline">Forget</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
<UserMenu />
|
||||
|
|
@ -233,6 +277,16 @@ export default function NoteDetailPage() {
|
|||
<span className={`text-xs font-bold uppercase px-2 py-1 rounded ${TYPE_COLORS[note.type] || TYPE_COLORS.NOTE}`}>
|
||||
{note.type}
|
||||
</span>
|
||||
{note.cardType !== 'note' && (
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded ${CARD_TYPE_COLORS[note.cardType] || CARD_TYPE_COLORS.note}`}>
|
||||
{note.cardType}
|
||||
</span>
|
||||
)}
|
||||
{note.visibility !== 'private' && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-700/50 text-slate-400">
|
||||
{note.visibility}
|
||||
</span>
|
||||
)}
|
||||
{note.tags.map((nt) => (
|
||||
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
|
||||
))}
|
||||
|
|
@ -241,6 +295,24 @@ export default function NoteDetailPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{note.summary && (
|
||||
<div className="mb-4 p-3 bg-slate-800/30 border border-slate-700/50 rounded-lg text-sm text-slate-300 italic">
|
||||
{note.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
{properties.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{properties.map(([key, value]) => (
|
||||
<span key={key} className="text-[10px] px-2 py-1 rounded bg-slate-800/50 border border-slate-700/50 text-slate-400">
|
||||
<span className="text-slate-500">{key}:</span> {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL */}
|
||||
{note.url && (
|
||||
<a
|
||||
|
|
@ -325,6 +397,34 @@ export default function NoteDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments gallery */}
|
||||
{note.attachments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">Attachments</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{note.attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={`/api/uploads/${att.file.storageKey}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-sm text-slate-300 hover:border-slate-600 transition-colors"
|
||||
>
|
||||
{att.file.mimeType.startsWith('image/') ? (
|
||||
<img src={`/api/uploads/${att.file.storageKey}`} alt={att.caption || att.file.filename} className="w-8 h-8 object-cover rounded" />
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate max-w-[150px]">{att.caption || att.file.filename}</span>
|
||||
<span className="text-[10px] text-slate-500">{att.role}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -336,7 +436,8 @@ export default function NoteDetailPage() {
|
|||
/>
|
||||
<NoteEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
valueJson={editBodyJson || undefined}
|
||||
onChange={handleEditorChange}
|
||||
type={note.type}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -357,6 +458,27 @@ export default function NoteDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Children */}
|
||||
{note.children.length > 0 && (
|
||||
<div className="mt-8 border-t border-slate-800 pt-6">
|
||||
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">Child Notes</h3>
|
||||
<div className="space-y-2">
|
||||
{note.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/notes/${child.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-800/30 hover:bg-slate-800/50 border border-slate-700/30 rounded-lg transition-colors"
|
||||
>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${CARD_TYPE_COLORS[child.cardType] || CARD_TYPE_COLORS.note}`}>
|
||||
{child.cardType}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300 hover:text-white">{child.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ function NewNoteForm() {
|
|||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [bodyJson, setBodyJson] = useState<object | null>(null);
|
||||
const [type, setType] = useState('NOTE');
|
||||
const [url, setUrl] = useState('');
|
||||
const [language, setLanguage] = useState('');
|
||||
|
|
@ -65,6 +66,11 @@ function NewNoteForm() {
|
|||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleContentChange = (html: string, json?: object) => {
|
||||
setContent(html);
|
||||
if (json) setBodyJson(json);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || saving) return;
|
||||
|
|
@ -77,6 +83,7 @@ function NewNoteForm() {
|
|||
type,
|
||||
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
};
|
||||
if (bodyJson) body.bodyJson = bodyJson;
|
||||
if (notebookId) body.notebookId = notebookId;
|
||||
if (url) body.url = url;
|
||||
if (language) body.language = language;
|
||||
|
|
@ -270,7 +277,7 @@ function NewNoteForm() {
|
|||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||
<NoteEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onChange={handleContentChange}
|
||||
type={type}
|
||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
const CARD_TYPE_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||
note: { bg: 'bg-amber-500/20', text: 'text-amber-400', border: 'border-amber-500/20' },
|
||||
link: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/20' },
|
||||
file: { bg: 'bg-slate-500/20', text: 'text-slate-400', border: 'border-slate-500/20' },
|
||||
task: { bg: 'bg-green-500/20', text: 'text-green-400', border: 'border-green-500/20' },
|
||||
person: { bg: 'bg-purple-500/20', text: 'text-purple-400', border: 'border-purple-500/20' },
|
||||
idea: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', border: 'border-yellow-500/20' },
|
||||
reference: { bg: 'bg-pink-500/20', text: 'text-pink-400', border: 'border-pink-500/20' },
|
||||
};
|
||||
|
||||
interface NoteCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
cardType?: string;
|
||||
contentPlain?: string | null;
|
||||
summary?: string | null;
|
||||
isPinned: boolean;
|
||||
updatedAt: string;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
url?: string | null;
|
||||
visibility?: string;
|
||||
children?: { id: string }[];
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, tags, url }: NoteCardProps) {
|
||||
const snippet = (contentPlain || '').slice(0, 120);
|
||||
export function NoteCard({
|
||||
id, title, type, cardType = 'note', contentPlain, summary,
|
||||
isPinned, updatedAt, tags, url, visibility, children, properties,
|
||||
}: NoteCardProps) {
|
||||
const snippet = summary || (contentPlain || '').slice(0, 120);
|
||||
const cardStyle = CARD_TYPE_STYLES[cardType] || CARD_TYPE_STYLES.note;
|
||||
const childCount = children?.length || 0;
|
||||
const propertyEntries = properties ? Object.entries(properties).filter(([, v]) => v != null && v !== '') : [];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/notes/${id}`}
|
||||
className="block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-lg p-4 transition-all"
|
||||
className={`block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-lg p-4 transition-all`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${TYPE_COLORS[type] || TYPE_COLORS.NOTE}`}>
|
||||
{type}
|
||||
</span>
|
||||
{cardType !== 'note' && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${cardStyle.bg} ${cardStyle.text}`}>
|
||||
{cardType}
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<span className="text-amber-400 text-xs" title="Pinned to canvas">
|
||||
★
|
||||
</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">
|
||||
{new Date(updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
|
|
@ -58,6 +94,20 @@ export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, t
|
|||
<p className="text-xs text-slate-400 line-clamp-2 mb-2">{snippet}</p>
|
||||
)}
|
||||
|
||||
{/* Property badges */}
|
||||
{propertyEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{propertyEntries.slice(0, 3).map(([key, value]) => (
|
||||
<span key={key} className="text-[9px] px-1.5 py-0.5 rounded bg-slate-700/40 text-slate-500">
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
))}
|
||||
{propertyEntries.length > 3 && (
|
||||
<span className="text-[9px] text-slate-500">+{propertyEntries.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import Image from '@tiptap/extension-image';
|
|||
|
||||
interface NoteEditorProps {
|
||||
value: string;
|
||||
onChange: (content: string) => void;
|
||||
onChange: (content: string, json?: object) => void;
|
||||
valueJson?: object;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
|
@ -43,7 +44,7 @@ function ToolbarButton({
|
|||
);
|
||||
}
|
||||
|
||||
function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
|
||||
function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
|
|
@ -61,9 +62,9 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
|||
TaskItem.configure({ nested: true }),
|
||||
Image.configure({ inline: true }),
|
||||
],
|
||||
content: value || '',
|
||||
content: valueJson || value || '',
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
onChange(editor.getHTML(), editor.getJSON());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
|
|
@ -168,7 +169,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
|||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
title="Task List"
|
||||
>
|
||||
☐ Tasks
|
||||
☐ Tasks
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-slate-700 mx-1" />
|
||||
|
|
@ -210,19 +211,19 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
|||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
─
|
||||
─
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
↩
|
||||
↩
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
↪
|
||||
↪
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
|
|
@ -232,7 +233,7 @@ function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<Note
|
|||
);
|
||||
}
|
||||
|
||||
export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorProps) {
|
||||
export function NoteEditor({ value, onChange, valueJson, type, placeholder }: NoteEditorProps) {
|
||||
const isCode = type === 'CODE';
|
||||
|
||||
if (isCode) {
|
||||
|
|
@ -248,5 +249,5 @@ export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorPro
|
|||
);
|
||||
}
|
||||
|
||||
return <RichEditor value={value} onChange={onChange} placeholder={placeholder} />;
|
||||
return <RichEditor value={value} onChange={onChange} valueJson={valueJson} placeholder={placeholder} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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