Add Image OCR search, Note Version History & Shared Notebooks
This commit is contained in:
1 parent
247b63691a
commit
969b6ef083
21 files changed
+2166
-100
No files matched your search
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
ArrowRight,
|
||||
Link2,
|
||||
GitMerge,
|
||||
History,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SidebarData,
|
||||
@@ -57,6 +59,8 @@ import SettingsView from './SettingsView';
|
||||
import ThumbnailEditorModal from './ThumbnailEditorModal';
|
||||
import TriageModal from './TriageModal';
|
||||
import MergeNotebookModal from './MergeNotebookModal';
|
||||
import ShareNotebookModal from './ShareNotebookModal';
|
||||
import NoteHistoryModal from './NoteHistoryModal';
|
||||
import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents';
|
||||
|
||||
export default function EvercloneWorkspace() {
|
||||
@@ -247,6 +251,13 @@ export default function EvercloneWorkspace() {
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [mergeSourceNotebook, setMergeSourceNotebook] = useState<NotebookItem | null>(null);
|
||||
|
||||
// Share Notebooks modal
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [shareNotebookTarget, setShareNotebookTarget] = useState<NotebookItem | null>(null);
|
||||
|
||||
// Note Version History modal
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const listParentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -658,6 +669,23 @@ export default function EvercloneWorkspace() {
|
||||
};
|
||||
}, [selectedNoteId]);
|
||||
|
||||
const reloadActiveNote = useCallback(
|
||||
async (noteId?: string) => {
|
||||
const targetId = noteId || selectedNoteId;
|
||||
if (!targetId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${targetId}`);
|
||||
if (!res.ok) return;
|
||||
const data: NoteDetail = await res.json();
|
||||
setActiveNote(data);
|
||||
setSaveState('saved');
|
||||
} catch (e) {
|
||||
console.error('Error reloading note detail:', e);
|
||||
}
|
||||
},
|
||||
[selectedNoteId]
|
||||
);
|
||||
|
||||
// Auto-save note edits with debounce
|
||||
const scheduleNoteSave = useCallback(
|
||||
(noteId: string, patch: Record<string, unknown>) => {
|
||||
@@ -1515,6 +1543,10 @@ export default function EvercloneWorkspace() {
|
||||
setMergeSourceNotebook(item);
|
||||
setMergeModalOpen(true);
|
||||
}}
|
||||
onShare={(item) => {
|
||||
setShareNotebookTarget(item);
|
||||
setShareModalOpen(true);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
if (selectedNotebookId === nb.id) {
|
||||
setSelectedNotebookId('');
|
||||
@@ -1548,6 +1580,10 @@ export default function EvercloneWorkspace() {
|
||||
setMergeSourceNotebook(item);
|
||||
setMergeModalOpen(true);
|
||||
}}
|
||||
onShare={(item) => {
|
||||
setShareNotebookTarget(item);
|
||||
setShareModalOpen(true);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
if (selectedNotebookId === nb.id) {
|
||||
setSelectedNotebookId('');
|
||||
@@ -3093,6 +3129,15 @@ export default function EvercloneWorkspace() {
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryModalOpen(true)}
|
||||
title="Note Version History & Snapshots"
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Export Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -3200,12 +3245,20 @@ export default function EvercloneWorkspace() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-Only Notice for Shared Notes */}
|
||||
{activeNote.canEdit === false && (
|
||||
<div className="mx-6 mt-3 px-3 py-1.5 bg-amber-50 border border-amber-200 text-amber-800 rounded-md text-xs flex items-center gap-2">
|
||||
<Users className="w-4 h-4 shrink-0 text-amber-600" />
|
||||
<span>You have <strong>view-only</strong> access to this shared note.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Title Input */}
|
||||
<div className="px-6 pt-5 pb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={activeNote.title}
|
||||
readOnly={activeNote.isTrashed}
|
||||
readOnly={activeNote.isTrashed || activeNote.canEdit === false}
|
||||
onChange={(e) => {
|
||||
const newTitle = e.target.value;
|
||||
setActiveNote({ ...activeNote, title: newTitle });
|
||||
@@ -3220,7 +3273,7 @@ export default function EvercloneWorkspace() {
|
||||
<RichNoteEditor
|
||||
noteId={activeNote.id}
|
||||
contentHtml={activeNote.contentHtml}
|
||||
readOnly={activeNote.isTrashed}
|
||||
readOnly={activeNote.isTrashed || activeNote.canEdit === false}
|
||||
onChange={(newHtml) => {
|
||||
setActiveNote((prev) =>
|
||||
prev ? { ...prev, contentHtml: newHtml } : prev
|
||||
@@ -3314,6 +3367,33 @@ export default function EvercloneWorkspace() {
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{(att.sizeBytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
{att.extractedText ? (
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200 rounded cursor-help"
|
||||
title={`Extracted Text (OCR):\n${att.extractedText.slice(0, 300)}${att.extractedText.length > 300 ? '...' : ''}`}
|
||||
>
|
||||
OCR
|
||||
</span>
|
||||
) : isImg ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/attachments/${att.id}/ocr`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.extractedText) {
|
||||
reloadActiveNote(activeNote.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-0.5 text-[9px] text-gray-400 hover:text-emerald-700 hover:bg-emerald-50 border border-gray-200 rounded"
|
||||
title="Click to run OCR on this image now"
|
||||
>
|
||||
+OCR
|
||||
</button>
|
||||
) : null}
|
||||
{isImg && !activeNote.isTrashed && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3441,6 +3521,32 @@ export default function EvercloneWorkspace() {
|
||||
stacks={sidebar.stacks}
|
||||
onMerged={handleMergedNotebook}
|
||||
/>
|
||||
|
||||
{shareNotebookTarget && (
|
||||
<ShareNotebookModal
|
||||
open={shareModalOpen}
|
||||
onClose={() => {
|
||||
setShareModalOpen(false);
|
||||
setShareNotebookTarget(null);
|
||||
}}
|
||||
notebook={shareNotebookTarget}
|
||||
users={sidebar.users || []}
|
||||
currentUser={sidebar.currentUser}
|
||||
onSharesChanged={fetchSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeNote && (
|
||||
<NoteHistoryModal
|
||||
open={historyModalOpen}
|
||||
onClose={() => setHistoryModalOpen(false)}
|
||||
note={activeNote}
|
||||
onRestored={(updatedNote) => {
|
||||
setActiveNote(updatedNote);
|
||||
fetchNotesList();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Floating PWA Install Banner on Mobile */}
|
||||
@@ -3635,6 +3741,7 @@ function NotebookRow({
|
||||
onDropNote,
|
||||
onRefresh,
|
||||
onMerge,
|
||||
onShare,
|
||||
onDeleted,
|
||||
canMerge,
|
||||
}: {
|
||||
@@ -3644,6 +3751,7 @@ function NotebookRow({
|
||||
onDropNote?: (noteId: string) => void;
|
||||
onRefresh: () => void;
|
||||
onMerge?: (nb: NotebookItem) => void;
|
||||
onShare?: (nb: NotebookItem) => void;
|
||||
onDeleted?: () => void;
|
||||
canMerge?: boolean;
|
||||
}) {
|
||||
@@ -3682,6 +3790,14 @@ function NotebookRow({
|
||||
>
|
||||
<Book className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
{nb.isShared && (
|
||||
<span
|
||||
title={nb.isOwner === false ? `Shared with you (${nb.shareRole ?? 'read'})` : 'Shared notebook'}
|
||||
className="flex items-center text-blue-400 shrink-0"
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
{nb.isDefault && (
|
||||
<span className="text-[9px] uppercase px-1 py-0.2 bg-neutral-700 text-emerald-400 rounded">
|
||||
Default
|
||||
@@ -3726,6 +3842,19 @@ function NotebookRow({
|
||||
>
|
||||
Rename Notebook…
|
||||
</button>
|
||||
{onShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onShare(nb);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800 flex items-center gap-2 text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Share Notebook…</span>
|
||||
</button>
|
||||
)}
|
||||
{canMerge && onMerge && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { History, X, RotateCcw, Camera, Clock, FileText, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { NoteDetail, NoteHistoryItem } from '@/types/everclone';
|
||||
|
||||
interface NoteHistoryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
note: NoteDetail | null;
|
||||
onRestored: (updatedNote: NoteDetail) => void;
|
||||
}
|
||||
|
||||
export default function NoteHistoryModal({
|
||||
open,
|
||||
onClose,
|
||||
note,
|
||||
onRestored,
|
||||
}: NoteHistoryModalProps) {
|
||||
const [historyList, setHistoryList] = useState<NoteHistoryItem[]>([]);
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState<NoteHistoryItem | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [takingSnapshot, setTakingSnapshot] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (!note) return;
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${note.id}/history`);
|
||||
if (!res.ok) throw new Error('Failed to load history');
|
||||
const data = await res.json();
|
||||
const items: NoteHistoryItem[] = data.history || [];
|
||||
setHistoryList(items);
|
||||
if (items.length > 0) {
|
||||
loadSnapshotDetail(items[0].id);
|
||||
} else {
|
||||
setSelectedSnapshot(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load version history.');
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSnapshotDetail = async (historyId: string) => {
|
||||
if (!note) return;
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${note.id}/history?historyId=${historyId}`);
|
||||
if (!res.ok) throw new Error('Failed to load snapshot details');
|
||||
const detail: NoteHistoryItem = await res.json();
|
||||
setSelectedSnapshot(detail);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && note) {
|
||||
loadHistory();
|
||||
}
|
||||
}, [open, note?.id]);
|
||||
|
||||
if (!open || !note) return null;
|
||||
|
||||
const handleTakeSnapshot = async () => {
|
||||
setTakingSnapshot(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${note.id}/history`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'snapshot', createdBy: 'Manual' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to take snapshot');
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Failed to capture snapshot.');
|
||||
} finally {
|
||||
setTakingSnapshot(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (historyId: string) => {
|
||||
if (!window.confirm('Restore this version? A snapshot of your current note will be saved first so nothing is lost.')) {
|
||||
return;
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${note.id}/history`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'restore', historyId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to restore snapshot');
|
||||
|
||||
onRestored(data.note);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Error restoring version');
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="relative w-full max-w-4xl h-[85vh] max-h-[750px] bg-neutral-900 border border-neutral-700 rounded-2xl shadow-2xl flex flex-col overflow-hidden text-neutral-100">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/90 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
<History className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Note Version History</h2>
|
||||
<p className="text-xs text-neutral-400 truncate max-w-md">
|
||||
Snapshots and previous edits for "{note.title}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTakeSnapshot}
|
||||
disabled={takingSnapshot}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 rounded-xl text-xs font-medium border border-neutral-700 transition disabled:opacity-50"
|
||||
title="Save a snapshot of the current note state"
|
||||
>
|
||||
{takingSnapshot ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Take Snapshot Now</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-950/60 border border-red-800/80 rounded-xl text-xs text-red-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2-Pane Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Timeline Pane */}
|
||||
<div className="w-72 sm:w-80 border-r border-neutral-800 flex flex-col bg-neutral-900/50">
|
||||
<div className="p-3 border-b border-neutral-800/80 text-[11px] font-semibold text-neutral-400 uppercase tracking-wider">
|
||||
Saved Snapshots ({historyList.length})
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loadingList ? (
|
||||
<div className="p-6 text-center text-xs text-neutral-500">Loading version history…</div>
|
||||
) : historyList.length === 0 ? (
|
||||
<div className="p-6 text-center space-y-2">
|
||||
<Clock className="w-8 h-8 text-neutral-600 mx-auto" />
|
||||
<p className="text-xs text-neutral-400 font-medium">No previous snapshots yet</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Snapshots are automatically saved while editing, or tap "Take Snapshot Now" above.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
historyList.map((item) => {
|
||||
const isSelected = selectedSnapshot?.id === item.id;
|
||||
const date = new Date(item.createdAt);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => loadSnapshotDetail(item.id)}
|
||||
className={`w-full text-left p-3 rounded-xl transition flex flex-col gap-1 border ${
|
||||
isSelected
|
||||
? 'bg-purple-950/40 border-purple-500/50 text-white shadow'
|
||||
: 'bg-neutral-800/30 border-transparent text-neutral-300 hover:bg-neutral-800/80 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs font-semibold">
|
||||
<span className="truncate">{item.title || 'Untitled Note'}</span>
|
||||
<span className="text-[10px] text-neutral-400 font-normal shrink-0">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 flex items-center justify-between">
|
||||
<span>{format(date, 'MMM d, yyyy · h:mm a')}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-300">
|
||||
{item.wordCount} words
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Snapshot Preview Pane */}
|
||||
<div className="flex-1 flex flex-col bg-neutral-950 overflow-hidden">
|
||||
{loadingDetail ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-neutral-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Loading snapshot preview…
|
||||
</div>
|
||||
) : selectedSnapshot ? (
|
||||
<>
|
||||
<div className="px-6 py-3.5 border-b border-neutral-800 bg-neutral-900/60 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{selectedSnapshot.title}</h3>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
Captured on {format(new Date(selectedSnapshot.createdAt), 'MMMM d, yyyy · h:mm:ss a')} · {selectedSnapshot.wordCount} words
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(selectedSnapshot.id)}
|
||||
disabled={restoring}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-xs font-semibold shadow transition disabled:opacity-50"
|
||||
>
|
||||
{restoring ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Restore This Version</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{selectedSnapshot.contentHtml ? (
|
||||
<div
|
||||
className="prose prose-invert prose-sm max-w-none text-neutral-200"
|
||||
dangerouslySetInnerHTML={{ __html: selectedSnapshot.contentHtml }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-xs text-neutral-300 whitespace-pre-wrap font-sans">
|
||||
{selectedSnapshot.contentText || '(Empty snapshot)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-neutral-500">
|
||||
Select a version from the timeline on the left to preview it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, X, UserPlus, Trash2, Shield, Eye, Edit3, Loader2 } from 'lucide-react';
|
||||
import { NotebookItem, NotebookShareItem, UserItem } from '@/types/everclone';
|
||||
|
||||
interface ShareNotebookModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
notebook: NotebookItem | null;
|
||||
users: UserItem[];
|
||||
currentUser?: UserItem;
|
||||
onSharesChanged?: () => void;
|
||||
}
|
||||
|
||||
export default function ShareNotebookModal({
|
||||
open,
|
||||
onClose,
|
||||
notebook,
|
||||
users,
|
||||
currentUser,
|
||||
onSharesChanged,
|
||||
}: ShareNotebookModalProps) {
|
||||
const [shares, setShares] = useState<NotebookShareItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [customEmail, setCustomEmail] = useState<string>('');
|
||||
const [selectedRole, setSelectedRole] = useState<'write' | 'read'>('write');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Other users eligible to be added
|
||||
const eligibleUsers = users.filter(
|
||||
(u) => u.id !== currentUser?.id && u.id !== notebook?.userId
|
||||
);
|
||||
|
||||
const loadShares = async () => {
|
||||
if (!notebook) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebook.id}/shares`);
|
||||
if (!res.ok) throw new Error('Failed to load shares');
|
||||
const data = await res.json();
|
||||
setShares(data.shares || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Could not load notebook shares.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && notebook) {
|
||||
loadShares();
|
||||
setCustomEmail('');
|
||||
if (eligibleUsers.length > 0) {
|
||||
setSelectedUserId(eligibleUsers[0].id);
|
||||
}
|
||||
}
|
||||
}, [open, notebook?.id]);
|
||||
|
||||
if (!open || !notebook) return null;
|
||||
|
||||
const handleAddShare = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSharing(true);
|
||||
|
||||
try {
|
||||
const payload: { userId?: string; email?: string; role: 'read' | 'write' } = {
|
||||
role: selectedRole,
|
||||
};
|
||||
|
||||
if (customEmail.trim()) {
|
||||
payload.email = customEmail.trim();
|
||||
} else if (selectedUserId) {
|
||||
payload.userId = selectedUserId;
|
||||
} else {
|
||||
throw new Error('Please select a user or enter an email address.');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/notebooks/${notebook.id}/shares`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to share notebook');
|
||||
|
||||
setCustomEmail('');
|
||||
await loadShares();
|
||||
if (onSharesChanged) onSharesChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Error sharing notebook');
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShare = async (shareId: string) => {
|
||||
if (!window.confirm('Revoke access for this collaborator?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebook.id}/shares?shareId=${shareId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove share');
|
||||
await loadShares();
|
||||
if (onSharesChanged) onSharesChanged();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Failed to remove collaborator.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (shareUserId: string, newRole: 'read' | 'write') => {
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebook.id}/shares`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: shareUserId, role: newRole }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update role');
|
||||
await loadShares();
|
||||
if (onSharesChanged) onSharesChanged();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Failed to update permission.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="relative w-full max-w-lg bg-neutral-900 border border-neutral-700 rounded-2xl shadow-2xl overflow-hidden text-neutral-100">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/80">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Share Notebook</h2>
|
||||
<p className="text-xs text-neutral-400">
|
||||
Collaborate on <span className="text-white font-medium">"{notebook.name}"</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-950/60 border border-red-800/80 rounded-xl text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add collaborator form */}
|
||||
<form onSubmit={handleAddShare} className="space-y-3">
|
||||
<label className="block text-xs font-medium text-neutral-300">
|
||||
Add Collaborator
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="sm:col-span-2 space-y-1.5">
|
||||
{eligibleUsers.length > 0 ? (
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => {
|
||||
setSelectedUserId(e.target.value);
|
||||
setCustomEmail('');
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{eligibleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="email"
|
||||
value={customEmail}
|
||||
onChange={(e) => setCustomEmail(e.target.value)}
|
||||
placeholder="Collaborator email…"
|
||||
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value as 'write' | 'read')}
|
||||
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="write">Can edit</option>
|
||||
<option value="read">Can view</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sharing || (!selectedUserId && !customEmail.trim())}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-xs font-semibold shadow transition disabled:opacity-50"
|
||||
>
|
||||
{sharing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Inviting…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>Share Notebook</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Current Collaborators */}
|
||||
<div className="space-y-2 pt-2 border-t border-neutral-800">
|
||||
<h3 className="text-xs font-semibold text-neutral-300">Who has access</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">Loading collaborators…</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500 bg-neutral-800/30 rounded-xl border border-neutral-800">
|
||||
This notebook is private to you.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-52 overflow-y-auto pr-1">
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between p-2.5 bg-neutral-800/60 border border-neutral-700/60 rounded-xl text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate">
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[11px] font-bold shrink-0"
|
||||
style={{ backgroundColor: s.user?.avatarColor || '#2563eb' }}
|
||||
>
|
||||
{s.user?.name ? s.user.name.charAt(0).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-white truncate">{s.user?.name || s.userId}</div>
|
||||
<div className="text-[10px] text-neutral-400 truncate">{s.user?.email || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
value={s.role}
|
||||
onChange={(e) => handleUpdateRole(s.userId, e.target.value as 'write' | 'read')}
|
||||
className="px-2 py-1 bg-neutral-700 border border-neutral-600 rounded-lg text-[11px] text-white focus:outline-none"
|
||||
>
|
||||
<option value="write">Can edit</option>
|
||||
<option value="read">Can view</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveShare(s.id)}
|
||||
className="p-1 text-neutral-400 hover:text-red-400 rounded-lg hover:bg-neutral-700 transition"
|
||||
title="Remove collaborator"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-6 py-3 border-t border-neutral-800 bg-neutral-900/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 rounded-xl text-xs font-semibold text-neutral-300 hover:text-white hover:bg-neutral-800 transition"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { simpleParser } from 'mailparser';
|
||||
import { prisma, ensureDefaultNotebook, getCurrentUserId } from './prisma';
|
||||
import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers';
|
||||
import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage';
|
||||
import { enqueueAttachmentOcr } from './ocr';
|
||||
|
||||
/**
|
||||
* Parses Evernote's Email-to-Note Subject line syntax:
|
||||
@@ -149,6 +150,10 @@ export async function ingestEmailToNote(
|
||||
},
|
||||
});
|
||||
|
||||
if (!savedBlob.extractedText) {
|
||||
enqueueAttachmentOcr(createdAtt.id);
|
||||
}
|
||||
|
||||
const attUrl = `/api/attachments/${createdAtt.id}`;
|
||||
if (att.cid && bodyHtml.includes(`cid:${att.cid}`)) {
|
||||
bodyHtml = bodyHtml.split(`cid:${att.cid}`).join(attUrl);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
makeExcerpt,
|
||||
guessExtensionFromMime,
|
||||
} from './storage';
|
||||
import { enqueueAttachmentOcr } from './ocr';
|
||||
|
||||
export function parseEvernoteTimestamp(ts?: string | null): Date | null {
|
||||
if (!ts) return null;
|
||||
@@ -212,6 +213,10 @@ export async function saveParsedEnexNote(
|
||||
},
|
||||
});
|
||||
|
||||
if (!savedBlob.extractedText) {
|
||||
enqueueAttachmentOcr(attachment.id);
|
||||
}
|
||||
|
||||
mediaByHash.set(savedBlob.md5Hash.toLowerCase(), {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
@@ -651,6 +656,7 @@ export async function localizeClippedImages(
|
||||
storagePath: savedBlob.storagePath,
|
||||
},
|
||||
});
|
||||
enqueueAttachmentOcr(att.id);
|
||||
urlToLocalSrc.set(remoteUrl, `/api/attachments/${att.id}`);
|
||||
} catch {
|
||||
// Keep remote URL if download fails
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { prisma } from './prisma';
|
||||
import { getFullAttachmentPath } from './storage';
|
||||
|
||||
// Safe lazy import of pdf-parse
|
||||
async function extractPdfText(buffer: Buffer): Promise<string | null> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { PDFParse } = require('pdf-parse');
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const res = await parser.getText();
|
||||
const text = typeof res === 'string' ? res : res?.text || '';
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 100000) : null;
|
||||
} catch (err) {
|
||||
console.warn('PDF digital text extraction skipped/failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognizes text inside an image using Tesseract OCR.
|
||||
*/
|
||||
export async function recognizeImageOcr(imageBufferOrPath: Buffer | string): Promise<string | null> {
|
||||
let worker: Tesseract.Worker | null = null;
|
||||
try {
|
||||
worker = await createWorker('eng');
|
||||
const ret = await worker.recognize(imageBufferOrPath);
|
||||
const text = ret.data?.text?.trim() || '';
|
||||
return text.length > 0 ? text.slice(0, 50000) : null;
|
||||
} catch (err) {
|
||||
console.error('Tesseract OCR error:', err);
|
||||
return null;
|
||||
} finally {
|
||||
if (worker) {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts searchable text from any supported attachment buffer (Text, Markdown, PDF, Image OCR).
|
||||
*/
|
||||
export async function extractTextFromAttachment(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
filename: string
|
||||
): Promise<string | null> {
|
||||
const lowerMime = mimeType.toLowerCase();
|
||||
const lowerName = filename.toLowerCase();
|
||||
|
||||
// 1. Plain text / Markdown / CSV / JSON
|
||||
if (
|
||||
lowerMime.startsWith('text/') ||
|
||||
lowerMime === 'application/json' ||
|
||||
lowerMime === 'application/xml' ||
|
||||
lowerName.endsWith('.md') ||
|
||||
lowerName.endsWith('.txt') ||
|
||||
lowerName.endsWith('.csv') ||
|
||||
lowerName.endsWith('.json')
|
||||
) {
|
||||
return buffer.toString('utf-8').slice(0, 50000).trim() || null;
|
||||
}
|
||||
|
||||
// 2. Digital PDF extraction
|
||||
if (lowerMime === 'application/pdf' || lowerName.endsWith('.pdf')) {
|
||||
const pdfText = await extractPdfText(buffer);
|
||||
if (pdfText) return pdfText;
|
||||
}
|
||||
|
||||
// 3. Image OCR (JPEG, PNG, WebP, BMP, TIFF)
|
||||
if (
|
||||
lowerMime.startsWith('image/') ||
|
||||
lowerName.endsWith('.jpg') ||
|
||||
lowerName.endsWith('.jpeg') ||
|
||||
lowerName.endsWith('.png') ||
|
||||
lowerName.endsWith('.webp') ||
|
||||
lowerName.endsWith('.bmp') ||
|
||||
lowerName.endsWith('.tiff')
|
||||
) {
|
||||
return await recognizeImageOcr(buffer);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs OCR / text extraction on an existing database Attachment record and updates its extractedText field.
|
||||
*/
|
||||
export async function runOcrOnAttachment(
|
||||
attachmentId: string
|
||||
): Promise<{ text: string | null; success: boolean }> {
|
||||
try {
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return { text: null, success: false };
|
||||
}
|
||||
|
||||
const fullPath = getFullAttachmentPath(attachment.storagePath);
|
||||
const buffer = await fs.readFile(fullPath);
|
||||
|
||||
const extracted = await extractTextFromAttachment(
|
||||
buffer,
|
||||
attachment.mimeType,
|
||||
attachment.filename
|
||||
);
|
||||
|
||||
if (extracted && extracted.trim()) {
|
||||
await prisma.attachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { extractedText: extracted.trim() },
|
||||
});
|
||||
return { text: extracted.trim(), success: true };
|
||||
}
|
||||
|
||||
return { text: null, success: true };
|
||||
} catch (err) {
|
||||
console.error(`runOcrOnAttachment failed for ${attachmentId}:`, err);
|
||||
return { text: null, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously enqueues an attachment for background OCR without blocking callers.
|
||||
*/
|
||||
export function enqueueAttachmentOcr(attachmentId: string): void {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await runOcrOnAttachment(attachmentId);
|
||||
} catch (e) {
|
||||
console.warn(`Background OCR for attachment ${attachmentId} failed:`, e);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
+19
-5
@@ -111,7 +111,25 @@ export function buildNoteWhereClause(params: {
|
||||
const andConditions: Prisma.NoteWhereInput[] = [];
|
||||
|
||||
if (params.userId) {
|
||||
andConditions.push({ userId: params.userId });
|
||||
if (params.notebookId) {
|
||||
andConditions.push({
|
||||
notebookId: params.notebookId,
|
||||
OR: [
|
||||
{ userId: params.userId },
|
||||
{ notebook: { userId: params.userId } },
|
||||
{ notebook: { shares: { some: { userId: params.userId } } } },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{ userId: params.userId },
|
||||
{ notebook: { shares: { some: { userId: params.userId } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (params.notebookId) {
|
||||
andConditions.push({ notebookId: params.notebookId });
|
||||
}
|
||||
|
||||
andConditions.push({
|
||||
@@ -126,10 +144,6 @@ export function buildNoteWhereClause(params: {
|
||||
andConditions.push({ isPinned: true });
|
||||
}
|
||||
|
||||
if (params.notebookId) {
|
||||
andConditions.push({ notebookId: params.notebookId });
|
||||
}
|
||||
|
||||
if (params.stackId) {
|
||||
andConditions.push({
|
||||
notebook: { stackId: params.stackId },
|
||||
|
||||
@@ -16,11 +16,42 @@ export interface NotebookStackItem {
|
||||
export interface NotebookItem {
|
||||
id: string;
|
||||
name: string;
|
||||
userId?: string;
|
||||
stackId: string | null;
|
||||
isDefault: boolean;
|
||||
isShortcut: boolean;
|
||||
sortOrder: number;
|
||||
noteCount: number;
|
||||
isShared?: boolean;
|
||||
isOwner?: boolean;
|
||||
shareRole?: 'owner' | 'write' | 'read';
|
||||
sharesCount?: number;
|
||||
}
|
||||
|
||||
export interface NotebookShareItem {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
userId: string;
|
||||
role: 'read' | 'write';
|
||||
createdAt: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NoteHistoryItem {
|
||||
id: string;
|
||||
noteId: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
wordCount: number;
|
||||
createdBy?: string | null;
|
||||
createdAt: string;
|
||||
contentHtml?: string;
|
||||
contentText?: string;
|
||||
}
|
||||
|
||||
export interface SavedSearchItem {
|
||||
@@ -39,6 +70,7 @@ export interface AttachmentItem {
|
||||
md5Hash: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
extractedText?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -81,6 +113,7 @@ export interface NoteDetail extends NoteSummary {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
attachments: AttachmentItem[];
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
|
||||
Reference in new issue
Block a user