Add Image OCR search, Note Version History & Shared Notebooks

This commit is contained in:
chrisf committed 2026-10-22 22:48:36 -06:00
1 parent 247b63691a
commit 969b6ef083
21 files changed
+2166 -100

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { runOcrOnAttachment } from '@/lib/ocr';
export async function GET(
_req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const attachment = await prisma.attachment.findUnique({
where: { id },
select: { id: true, filename: true, mimeType: true, extractedText: true },
});
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
return NextResponse.json({
id: attachment.id,
filename: attachment.filename,
extractedText: attachment.extractedText,
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch OCR text' },
{ status: 500 }
);
}
}
export async function POST(
_req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const result = await runOcrOnAttachment(id);
return NextResponse.json(result);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'OCR execution failed' },
{ status: 500 }
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import { prisma } from '@/lib/prisma';
import { getFullAttachmentPath } from '@/lib/storage';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const attachment = await prisma.attachment.findUnique({
where: { id },
});
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
const fullPath = getFullAttachmentPath(attachment.storagePath);
const fileBuf = await fs.readFile(fullPath);
const download = req.nextUrl.searchParams.get('download') === '1';
const disposition = download ? 'attachment' : 'inline';
const safeFilename = encodeURIComponent(attachment.filename);
return new NextResponse(fileBuf, {
status: 200,
headers: {
'Content-Type': attachment.mimeType || 'application/octet-stream',
'Content-Length': String(fileBuf.length),
'Content-Disposition': `${disposition}; filename*=UTF-8''${safeFilename}`,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to stream attachment' },
{ status: 404 }
);
}
}
export async function DELETE(
_req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
await prisma.attachment.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete attachment' },
{ status: 500 }
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { runOcrOnAttachment } from '@/lib/ocr';
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const limit = Math.min(parseInt(String(body.limit || '10'), 10) || 10, 50);
// Find image or PDF attachments that do not have extractedText yet
const pendingAttachments = await prisma.attachment.findMany({
where: {
extractedText: null,
OR: [
{ mimeType: { startsWith: 'image/' } },
{ mimeType: 'application/pdf' },
{ filename: { endsWith: '.pdf', mode: 'insensitive' } },
{ filename: { endsWith: '.jpg', mode: 'insensitive' } },
{ filename: { endsWith: '.jpeg', mode: 'insensitive' } },
{ filename: { endsWith: '.png', mode: 'insensitive' } },
{ filename: { endsWith: '.webp', mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, filename: true },
});
const results = [];
for (const att of pendingAttachments) {
const res = await runOcrOnAttachment(att.id);
results.push({ id: att.id, filename: att.filename, success: res.success, hasText: Boolean(res.text) });
}
const remainingCount = await prisma.attachment.count({
where: {
extractedText: null,
OR: [
{ mimeType: { startsWith: 'image/' } },
{ mimeType: 'application/pdf' },
],
},
});
return NextResponse.json({
processedCount: results.length,
remainingCount,
results,
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Batch OCR failed' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { saveAttachmentBuffer } from '@/lib/storage';
import { enqueueAttachmentOcr } from '@/lib/ocr';
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const noteId = formData.get('noteId') as string | null;
if (!noteId) {
return NextResponse.json({ error: 'noteId is required' }, { status: 400 });
}
const files = formData.getAll('files') as File[];
const singleFile = formData.get('file') as File | null;
if (singleFile && files.length === 0) {
files.push(singleFile);
}
const created = [];
for (const file of files) {
const arrayBuf = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuf);
const mimeType = file.type || 'application/octet-stream';
const filename = file.name || 'attachment';
const savedBlob = await saveAttachmentBuffer(buffer, filename, mimeType);
const attachment = await prisma.attachment.create({
data: {
noteId,
filename,
mimeType,
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
extractedText: savedBlob.extractedText,
},
});
created.push(attachment);
if (!savedBlob.extractedText) {
enqueueAttachmentOcr(attachment.id);
}
}
await prisma.note.update({
where: { id: noteId },
data: { updatedAt: new Date() },
});
return NextResponse.json({ attachments: created });
} catch (err: unknown) {
console.error('Upload attachment error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to upload attachment' },
{ status: 500 }
);
}
}
+216
View File
@@ -0,0 +1,216 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId } from '@/lib/prisma';
export async function GET(
_req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserId();
const { id: notebookId } = await context.params;
// Verify notebook exists and user has access (either owner or shared)
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
include: {
shares: true,
},
});
if (!notebook) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
const isOwner = notebook.userId === userId;
const isCollaborator = notebook.shares.some((s) => s.userId === userId);
if (!isOwner && !isCollaborator) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
// Load collaborator user profiles
const userIds = notebook.shares.map((s) => s.userId);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true, avatarColor: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
const sharesWithUsers = notebook.shares.map((s) => ({
...s,
user: userMap.get(s.userId) || {
id: s.userId,
name: s.userId,
email: `${s.userId}@everclone.local`,
avatarColor: '#00a82d',
},
}));
return NextResponse.json({
isOwner,
notebook: {
id: notebook.id,
name: notebook.name,
userId: notebook.userId,
},
shares: sharesWithUsers,
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch notebook shares' },
{ status: 500 }
);
}
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserId();
const { id: notebookId } = await context.params;
const body = await req.json();
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
});
if (!notebook) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
// Only owner can share or modify shares
if (notebook.userId !== userId) {
return NextResponse.json(
{ error: 'Only the notebook owner can manage shares' },
{ status: 403 }
);
}
let targetUserId = body.userId?.trim();
const email = body.email?.trim()?.toLowerCase();
const role = body.role === 'read' ? 'read' : 'write';
if (!targetUserId && email) {
let targetUser = await prisma.user.findUnique({ where: { email } });
if (!targetUser) {
// Create user placeholder for email invitation
const username = email.split('@')[0];
targetUser = await prisma.user.create({
data: {
email,
name: username.charAt(0).toUpperCase() + username.slice(1),
},
});
}
targetUserId = targetUser.id;
}
if (!targetUserId) {
return NextResponse.json(
{ error: 'Target user ID or email is required' },
{ status: 400 }
);
}
if (targetUserId === userId) {
return NextResponse.json(
{ error: 'Cannot share a notebook with yourself' },
{ status: 400 }
);
}
const share = await prisma.notebookShare.upsert({
where: {
notebookId_userId: {
notebookId,
userId: targetUserId,
},
},
update: { role },
create: {
notebookId,
userId: targetUserId,
role,
},
});
const userProfile = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, name: true, email: true, avatarColor: true },
});
return NextResponse.json({
success: true,
share: {
...share,
user: userProfile,
},
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to save share' },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const currentUserId = await getCurrentUserId();
const { id: notebookId } = await context.params;
const targetUserId = req.nextUrl.searchParams.get('userId');
const shareId = req.nextUrl.searchParams.get('shareId');
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
});
if (!notebook) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
// Owner can delete any share; collaborator can remove themselves
const isOwner = notebook.userId === currentUserId;
if (!shareId && !targetUserId) {
return NextResponse.json(
{ error: 'shareId or userId required' },
{ status: 400 }
);
}
if (shareId) {
const share = await prisma.notebookShare.findUnique({ where: { id: shareId } });
if (!share) {
return NextResponse.json({ deleted: true });
}
if (!isOwner && share.userId !== currentUserId) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await prisma.notebookShare.delete({ where: { id: shareId } });
return NextResponse.json({ deleted: true });
}
if (targetUserId) {
if (!isOwner && targetUserId !== currentUserId) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await prisma.notebookShare.deleteMany({
where: { notebookId, userId: targetUserId },
});
return NextResponse.json({ deleted: true });
}
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to remove share' },
{ status: 500 }
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const historyId = req.nextUrl.searchParams.get('historyId');
if (historyId) {
const snapshot = await prisma.noteHistory.findUnique({
where: { id: historyId },
});
if (!snapshot) {
return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 });
}
return NextResponse.json(snapshot);
}
const history = await prisma.noteHistory.findMany({
where: { noteId: id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
noteId: true,
title: true,
excerpt: true,
wordCount: true,
createdBy: true,
createdAt: true,
},
take: 50,
});
return NextResponse.json({ history });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to load note history' },
{ status: 500 }
);
}
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await req.json();
const note = await prisma.note.findUnique({
where: { id },
});
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
// Restore action
if (body.action === 'restore') {
const historyId = body.historyId;
if (!historyId) {
return NextResponse.json({ error: 'historyId required' }, { status: 400 });
}
const snapshot = await prisma.noteHistory.findUnique({
where: { id: historyId },
});
if (!snapshot) {
return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 });
}
// 1. Take a snapshot of current state before restoring
if (note.contentHtml) {
await prisma.noteHistory.create({
data: {
noteId: id,
title: note.title,
contentHtml: note.contentHtml,
contentText: note.contentText,
excerpt: note.excerpt,
wordCount: note.contentText.trim().split(/\s+/).filter(Boolean).length,
createdBy: 'Before Restore',
},
});
}
// 2. Restore the note to the snapshot
const restored = await prisma.note.update({
where: { id },
data: {
title: snapshot.title,
contentHtml: snapshot.contentHtml,
contentText: snapshot.contentText,
excerpt: snapshot.excerpt,
updatedAt: new Date(),
},
include: {
notebook: true,
tags: { include: { tag: true } },
attachments: { orderBy: { createdAt: 'asc' } },
},
});
return NextResponse.json({
restored: true,
note: {
...restored,
tags: restored.tags.map((nt) => nt.tag),
},
});
}
// Manual snapshot action
const text = note.contentText || stripHtmlToText(note.contentHtml);
const snapshot = await prisma.noteHistory.create({
data: {
noteId: id,
title: note.title,
contentHtml: note.contentHtml,
contentText: text,
excerpt: note.excerpt || makeExcerpt(text),
wordCount: text.trim().split(/\s+/).filter(Boolean).length,
createdBy: body.createdBy || 'Manual Snapshot',
},
});
return NextResponse.json({ snapshot });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Note history operation failed' },
{ status: 500 }
);
}
}
+103 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { prisma, getCurrentUserId } from '@/lib/prisma';
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
import { getOrCreateTagIds } from '@/lib/importers';
import { Prisma } from '@prisma/client';
@@ -9,11 +9,16 @@ export async function GET(
context: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserId();
const { id } = await context.params;
const note = await prisma.note.findUnique({
where: { id },
include: {
notebook: true,
notebook: {
include: {
shares: true,
},
},
tags: { include: { tag: true } },
attachments: { orderBy: { createdAt: 'asc' } },
},
@@ -23,8 +28,24 @@ export async function GET(
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
const hasAccess =
note.userId === userId ||
note.notebook.userId === userId ||
note.notebook.shares.some((s) => s.userId === userId);
if (!hasAccess) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
const share = note.notebook.shares.find((s) => s.userId === userId);
const canEdit =
note.userId === userId ||
note.notebook.userId === userId ||
share?.role === 'write';
return NextResponse.json({
...note,
canEdit,
tags: note.tags.map((nt) => nt.tag),
});
} catch (err: unknown) {
@@ -40,9 +61,32 @@ export async function PATCH(
context: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserId();
const { id } = await context.params;
const body = await req.json();
const existingNote = await prisma.note.findUnique({
where: { id },
include: {
notebook: {
include: { shares: true },
},
},
});
if (!existingNote) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
const canEdit =
existingNote.userId === userId ||
existingNote.notebook.userId === userId ||
existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write');
if (!canEdit) {
return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 });
}
const data: Prisma.NoteUncheckedUpdateInput = {};
let shouldBumpUpdatedAt = false;
@@ -57,6 +101,40 @@ export async function PATCH(
data.excerpt = makeExcerpt(text);
shouldBumpUpdatedAt = true;
}
// Automatic version history snapshotting (at most 1 snapshot per 5 minutes of editing)
if (body.contentHtml !== undefined || body.title !== undefined) {
try {
const lastSnapshot = await prisma.noteHistory.findFirst({
where: { noteId: id },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
if (!lastSnapshot || lastSnapshot.createdAt < fiveMinutesAgo) {
const currentNote = await prisma.note.findUnique({
where: { id },
select: { title: true, contentHtml: true, contentText: true, excerpt: true },
});
if (currentNote && currentNote.contentHtml) {
const wordCount = currentNote.contentText.trim().split(/\s+/).filter(Boolean).length;
await prisma.noteHistory.create({
data: {
noteId: id,
title: currentNote.title,
contentHtml: currentNote.contentHtml,
contentText: currentNote.contentText,
excerpt: currentNote.excerpt,
wordCount,
createdBy: 'Auto Snapshot',
},
});
}
}
} catch (historyErr) {
console.warn('Note history snapshot creation failed:', historyErr);
}
}
if (body.notebookId !== undefined) {
data.notebookId = body.notebookId;
}
@@ -138,9 +216,32 @@ export async function DELETE(
context: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserId();
const { id } = await context.params;
const permanent = req.nextUrl.searchParams.get('permanent') === 'true';
const existingNote = await prisma.note.findUnique({
where: { id },
include: {
notebook: {
include: { shares: true },
},
},
});
if (!existingNote) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
const canDelete =
existingNote.userId === userId ||
existingNote.notebook.userId === userId ||
existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write');
if (!canDelete) {
return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 });
}
if (permanent) {
await prisma.note.delete({ where: { id } });
return NextResponse.json({ deleted: true, permanent: true });
+15 -1
View File
@@ -129,7 +129,21 @@ export async function POST(req: NextRequest) {
}
const defaultNb = await ensureDefaultNotebook(userId);
const notebookId = body.notebookId || defaultNb.id;
let notebookId = defaultNb.id;
if (body.notebookId) {
const allowedNb = await prisma.notebook.findFirst({
where: {
id: body.notebookId,
OR: [
{ userId },
{ shares: { some: { userId, role: 'write' } } },
],
},
});
if (allowedNb) {
notebookId = allowedNb.id;
}
}
const title = body.title?.trim() || 'Untitled Note';
const contentHtml = body.contentHtml || '<p></p>';
const contentText = stripHtmlToText(contentHtml);
+65 -10
View File
@@ -10,7 +10,8 @@ export async function GET() {
const [
users,
stacks,
notebooks,
ownedNotebooks,
sharedNotebooks,
tags,
savedSearches,
shortcutNotes,
@@ -28,8 +29,20 @@ export async function GET() {
}),
prisma.notebook.findMany({
where: { userId },
include: {
shares: { select: { id: true, userId: true, role: true } },
},
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
}),
prisma.notebook.findMany({
where: {
shares: { some: { userId } },
},
include: {
shares: { where: { userId } },
},
orderBy: [{ name: 'asc' }],
}),
prisma.tag.findMany({
where: { userId },
orderBy: { name: 'asc' },
@@ -43,12 +56,20 @@ export async function GET() {
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
}),
prisma.note.count({ where: { userId, isTrashed: false } }),
prisma.note.count({
where: {
isTrashed: false,
OR: [{ userId }, { notebook: { shares: { some: { userId } } } }],
},
}),
prisma.note.count({ where: { userId, isTrashed: true } }),
// Fast single GROUP BY for notebook note counts
// Fast single GROUP BY for notebook note counts (owned + shared)
prisma.note.groupBy({
by: ['notebookId'],
where: { userId, isTrashed: false },
where: {
isTrashed: false,
OR: [{ userId }, { notebook: { shares: { some: { userId } } } }],
},
_count: { _all: true },
}),
// Fast single raw GROUP BY query for tag note counts
@@ -71,14 +92,48 @@ export async function GET() {
tagCountMap.set(item.tagId, Number(item.count));
}
// Format owned notebooks
const formattedOwned = ownedNotebooks.map((nb) => ({
id: nb.id,
name: nb.name,
userId: nb.userId,
stackId: nb.stackId,
isDefault: nb.isDefault,
isShortcut: nb.isShortcut,
sortOrder: nb.sortOrder,
noteCount: notebookCountMap.get(nb.id) || 0,
isOwner: true,
isShared: nb.shares.length > 0,
sharesCount: nb.shares.length,
shareRole: 'owner' as const,
}));
// Format shared notebooks
const formattedShared = sharedNotebooks.map((nb) => {
const myShare = nb.shares[0];
return {
id: nb.id,
name: nb.name,
userId: nb.userId,
stackId: null, // Shared notebooks appear in their own section or unstacked
isDefault: false,
isShortcut: nb.isShortcut,
sortOrder: nb.sortOrder,
noteCount: notebookCountMap.get(nb.id) || 0,
isOwner: false,
isShared: true,
sharesCount: 1,
shareRole: (myShare?.role || 'read') as 'read' | 'write',
};
});
const combinedNotebooks = [...formattedOwned, ...formattedShared];
return NextResponse.json({
currentUser,
users,
stacks,
notebooks: notebooks.map((nb) => ({
...nb,
noteCount: notebookCountMap.get(nb.id) || 0,
})),
notebooks: combinedNotebooks,
tags: tags.map((t) => ({
...t,
noteCount: tagCountMap.get(t.id) || 0,
@@ -86,8 +141,8 @@ export async function GET() {
savedSearches,
shortcuts: {
notes: shortcutNotes,
notebooks: notebooks.filter((n) => n.isShortcut).map(nb => ({ ...nb, noteCount: notebookCountMap.get(nb.id) || 0 })),
tags: tags.filter((t) => t.isShortcut).map(t => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
notebooks: combinedNotebooks.filter((n) => n.isShortcut),
tags: tags.filter((t) => t.isShortcut).map((t) => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
savedSearches: savedSearches.filter((s) => s.isShortcut),
},
counts: {