diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 422813d..054f894 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model Notebook { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt notes Note[] + autoSortRules AutoSortRule[] @@unique([userId, name]) @@index([userId]) @@ -185,3 +186,25 @@ model EmailConfig { emailsImported Int @default(0) updatedAt DateTime @updatedAt } + +model AutoSortRule { + id String @id @default(cuid()) + userId String @default("default") + name String + enabled Boolean @default(true) + priority Int @default(0) + conditionField String @default("title") // title | content | sourceUrl | author | attachmentType | hasTodo + conditionOperator String @default("contains") // contains | not_contains | equals | startsWith | endsWith | matchesRegex + conditionValue String @default("") + targetNotebookId String? + targetNotebook Notebook? @relation(fields: [targetNotebookId], references: [id], onDelete: SetNull) + addTags String @default("") // comma-separated tag names + isPinned Boolean? + isShortcut Boolean? + stopOnMatch Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, enabled, priority]) + @@index([targetNotebookId]) +} diff --git a/src/app/api/auto-sort/route.ts b/src/app/api/auto-sort/route.ts new file mode 100644 index 0000000..357ea0c --- /dev/null +++ b/src/app/api/auto-sort/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, getCurrentUserId } from '@/lib/prisma'; + +export async function GET() { + try { + const userId = await getCurrentUserId(); + const rules = await prisma.autoSortRule.findMany({ + where: { userId }, + orderBy: { priority: 'asc' }, + include: { + targetNotebook: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json({ rules }); + } catch (err: unknown) { + console.error('GET /api/auto-sort error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch rules' }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const userId = await getCurrentUserId(); + const body = await req.json(); + + const name = (body.name || '').trim(); + if (!name) { + return NextResponse.json({ error: 'Rule name is required' }, { status: 400 }); + } + + // Determine default priority if not provided + let priority = typeof body.priority === 'number' ? body.priority : 0; + if (priority === 0) { + const highest = await prisma.autoSortRule.findFirst({ + where: { userId }, + orderBy: { priority: 'desc' }, + select: { priority: true }, + }); + priority = (highest?.priority ?? -1) + 1; + } + + const rule = await prisma.autoSortRule.create({ + data: { + userId, + name, + enabled: body.enabled !== false, + priority, + conditionField: body.conditionField || 'title', + conditionOperator: body.conditionOperator || 'contains', + conditionValue: (body.conditionValue || '').trim(), + targetNotebookId: body.targetNotebookId || null, + addTags: (body.addTags || '').trim(), + isPinned: typeof body.isPinned === 'boolean' ? body.isPinned : null, + isShortcut: typeof body.isShortcut === 'boolean' ? body.isShortcut : null, + stopOnMatch: body.stopOnMatch !== false, + }, + include: { + targetNotebook: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json(rule, { status: 201 }); + } catch (err: unknown) { + console.error('POST /api/auto-sort error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to create rule' }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + try { + const userId = await getCurrentUserId(); + const body = await req.json(); + + // Support bulk reordering + if (body.action === 'reorder' && Array.isArray(body.ruleIds)) { + await Promise.all( + body.ruleIds.map((id: string, index: number) => + prisma.autoSortRule.updateMany({ + where: { id, userId }, + data: { priority: index }, + }) + ) + ); + return NextResponse.json({ success: true }); + } + + const { id } = body; + if (!id) { + return NextResponse.json({ error: 'Rule ID required' }, { status: 400 }); + } + + const updateData: Record = {}; + if (typeof body.name === 'string') updateData.name = body.name.trim(); + if (typeof body.enabled === 'boolean') updateData.enabled = body.enabled; + if (typeof body.priority === 'number') updateData.priority = body.priority; + if (typeof body.conditionField === 'string') updateData.conditionField = body.conditionField; + if (typeof body.conditionOperator === 'string') + updateData.conditionOperator = body.conditionOperator; + if (typeof body.conditionValue === 'string') updateData.conditionValue = body.conditionValue.trim(); + if (body.targetNotebookId !== undefined) { + updateData.targetNotebookId = body.targetNotebookId || null; + } + if (typeof body.addTags === 'string') updateData.addTags = body.addTags.trim(); + if (body.isPinned !== undefined) updateData.isPinned = body.isPinned; + if (body.isShortcut !== undefined) updateData.isShortcut = body.isShortcut; + if (typeof body.stopOnMatch === 'boolean') updateData.stopOnMatch = body.stopOnMatch; + + const updated = await prisma.autoSortRule.update({ + where: { id }, + data: updateData, + include: { + targetNotebook: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (err: unknown) { + console.error('PUT /api/auto-sort error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to update rule' }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const userId = await getCurrentUserId(); + const id = req.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Rule ID required' }, { status: 400 }); + } + + await prisma.autoSortRule.deleteMany({ + where: { id, userId }, + }); + + return NextResponse.json({ success: true }); + } catch (err: unknown) { + console.error('DELETE /api/auto-sort error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to delete rule' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auto-sort/run/route.ts b/src/app/api/auto-sort/run/route.ts new file mode 100644 index 0000000..53a7909 --- /dev/null +++ b/src/app/api/auto-sort/run/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma'; +import { runAutoSortOnNotebook } from '@/lib/autoSort'; + +export async function POST(req: NextRequest) { + try { + const userId = await getCurrentUserId(); + const body = await req.json().catch(() => ({})); + + let notebookId = body.notebookId; + if (!notebookId) { + const defaultNb = await ensureDefaultNotebook(userId); + notebookId = defaultNb.id; + } + + const dryRun = body.dryRun === true; + const ruleIds = Array.isArray(body.ruleIds) ? body.ruleIds : undefined; + + const result = await runAutoSortOnNotebook({ + notebookId, + userId, + dryRun, + ruleIds, + }); + + return NextResponse.json(result); + } catch (err: unknown) { + console.error('POST /api/auto-sort/run error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to run auto-sort' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/hooks/note/route.ts b/src/app/api/hooks/note/route.ts index 57ce89d..73d16e0 100644 --- a/src/app/api/hooks/note/route.ts +++ b/src/app/api/hooks/note/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { marked } from 'marked'; import { JSDOM } from 'jsdom'; import { Readability } from '@mozilla/readability'; -import { prisma, ensureDefaultNotebook } from '@/lib/prisma'; +import { prisma, ensureDefaultNotebook, getCurrentUserId } from '@/lib/prisma'; import { verifyApiKeyFromRequest } from '@/lib/auth'; import { clipUrlToNote, @@ -16,6 +16,7 @@ import { stripHtmlToText, makeExcerpt, } from '@/lib/storage'; +import { applyAutoSortToNote } from '@/lib/autoSort'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -29,6 +30,7 @@ export async function OPTIONS() { export async function POST(req: NextRequest) { try { + const userId = await getCurrentUserId(); const isValidKey = await verifyApiKeyFromRequest(req); const isShareTarget = req.nextUrl.searchParams.get('source') === 'share-target'; @@ -165,6 +167,8 @@ export async function POST(req: NextRequest) { }); } + await applyAutoSortToNote(note.id, userId).catch(() => {}); + return NextResponse.json( { ok: true, noteId: note.id, title: note.title, mode: 'extension-article' }, { status: 201, headers: corsHeaders } @@ -283,6 +287,8 @@ export async function POST(req: NextRequest) { }); } + await applyAutoSortToNote(note.id, userId).catch(() => {}); + if (isShareTarget) { return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url)); } @@ -302,6 +308,7 @@ export async function POST(req: NextRequest) { export async function GET(req: NextRequest) { try { + const userId = await getCurrentUserId(); const isValidKey = await verifyApiKeyFromRequest(req); if (!isValidKey) { return NextResponse.json( @@ -361,6 +368,8 @@ export async function GET(req: NextRequest) { noteId = created.id; } + await applyAutoSortToNote(noteId, userId).catch(() => {}); + if (sp.get('redirect') === '1') { return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url)); } diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index b9c3faf..d487024 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -3,6 +3,7 @@ import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma'; import { buildNoteWhereClause } from '@/lib/search'; import { stripHtmlToText, makeExcerpt } from '@/lib/storage'; import { getOrCreateTagIds } from '@/lib/importers'; +import { applyAutoSortToNote } from '@/lib/autoSort'; import { Prisma } from '@prisma/client'; export async function GET(req: NextRequest) { @@ -161,6 +162,32 @@ export async function POST(req: NextRequest) { }, }); + // Automatically evaluate auto-sort rules if created in default inbox + if (!body.notebookId || body.notebookId === defaultNb.id) { + try { + const sortRes = await applyAutoSortToNote(note.id, userId); + if (sortRes.matched) { + const refreshed = await prisma.note.findUnique({ + where: { id: note.id }, + include: { + notebook: true, + tags: { include: { tag: true } }, + attachments: true, + }, + }); + if (refreshed) { + return NextResponse.json({ + ...refreshed, + tags: refreshed.tags.map((nt) => nt.tag), + autoSortApplied: sortRes, + }); + } + } + } catch (sortErr) { + console.error('Auto-sort on note creation failed (non-fatal):', sortErr); + } + } + return NextResponse.json({ ...note, tags: note.tags.map((nt) => nt.tag), diff --git a/src/components/EvercloneWorkspace.tsx b/src/components/EvercloneWorkspace.tsx index 81b7801..1d1921e 100644 --- a/src/components/EvercloneWorkspace.tsx +++ b/src/components/EvercloneWorkspace.tsx @@ -41,6 +41,7 @@ import { Globe, Menu, ArrowLeft, + Wand2, } from 'lucide-react'; import { SidebarData, @@ -51,6 +52,7 @@ import { import RichNoteEditor from './RichNoteEditor'; import SettingsView from './SettingsView'; import ThumbnailEditorModal from './ThumbnailEditorModal'; +import TriageModal from './TriageModal'; import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents'; export default function EvercloneWorkspace() { @@ -96,6 +98,7 @@ export default function EvercloneWorkspace() { const [creatingUser, setCreatingUser] = useState(false); const [newUserName, setNewUserName] = useState(''); const [newUserEmail, setNewUserEmail] = useState(''); + const [triageModalOpen, setTriageModalOpen] = useState(false); const activeFilterCount = (filterNotebookId ? 1 : 0) + @@ -1536,19 +1539,30 @@ export default function EvercloneWorkspace() { New {activeView === 'notebook' && selectedNotebookId && ( - + <> + + + )} {activeView === 'trash' && notes.length > 0 && ( + + + + + + {/* Quick Templates */} +
+
+ ๐Ÿ’ก Quick Templates: Click to add pre-configured rule +
+
+ {[ + { + name: '๐Ÿงพ Receipts & Invoices', + field: 'title', + operator: 'contains', + value: 'receipt, invoice', + tags: 'receipt, finance', + targetNb: notebooks.find((n) => /finance|receipt/i.test(n.name))?.id || null, + }, + { + name: '๐Ÿ’ป GitHub & Dev Repos', + field: 'sourceUrl', + operator: 'contains', + value: 'github.com', + tags: 'github, dev', + targetNb: null, + }, + { + name: '๐Ÿ“„ PDF Documents', + field: 'attachmentType', + operator: 'contains', + value: 'application/pdf', + tags: 'pdf, documents', + targetNb: null, + }, + { + name: '๐Ÿฝ๏ธ Recipes & Cooking', + field: 'content', + operator: 'contains', + value: 'ingredients', + tags: 'recipe, cooking', + targetNb: notebooks.find((n) => /recipe|cook/i.test(n.name))?.id || null, + }, + { + name: '๐Ÿ“ฐ Medium & Substack Articles', + field: 'sourceUrl', + operator: 'contains', + value: 'medium.com, substack.com', + tags: 'reading-list', + targetNb: null, + }, + ].map((tpl) => ( + + ))} +
+
+ + + {/* Rules List Card */} +
+
+

+ Active Rules ({rules.length}) +

+ + Rules evaluate from top to bottom + +
+ + {loadingRules ? ( +
+ +
+ ) : rules.length === 0 ? ( +
+ +

+ No auto-sort rules created yet. +

+

+ Create rules to automatically route clipped articles, receipts, and emails directly into target notebooks and tags. +

+
+ ) : ( +
+ {rules.map((rule, idx) => ( +
+
+ {/* Priority controls */} +
+ + + #{idx + 1} + + +
+ + {/* Enabled checkbox */} + handleToggleRule(rule)} + className="w-4 h-4 text-evernote-green rounded focus:ring-evernote-green border-gray-300 cursor-pointer shrink-0 mt-0.5 sm:mt-0" + /> + + {/* Rule details */} +
+
+ + {rule.name} + + {rule.stopOnMatch && ( + + Stop on match + + )} +
+ +
+ + If {rule.conditionField} {rule.conditionOperator} “{rule.conditionValue}” + + + {rule.targetNotebook?.name && ( + + Move โ†’ {rule.targetNotebook.name} + + )} + + {rule.addTags && ( + + +Tags: {rule.addTags} + + )} + + {rule.isPinned && ( + + ๐Ÿ“Œ Pin + + )} +
+
+
+ + {/* Action buttons */} +
+ + +
+
+ ))} +
+ )} +
+ + )} + {/* TAB 1: IMPORT & EXPORT */} {activeTab === 'import' && ( <> @@ -1438,6 +1852,222 @@ Content-Type: application/json )} + + {/* Auto-Sort Rule Editor Modal */} + {ruleModalOpen && editingRule && ( +
+
+
+
+
+ +
+

+ {editingRule.id ? 'Edit Auto-Sort Rule' : 'New Auto-Sort Rule'} +

+
+ +
+ +
+ {ruleError && ( +
+ + {ruleError} +
+ )} + + {/* Rule Name */} +
+ + setEditingRule({ ...editingRule, name: e.target.value })} + placeholder="e.g. Tax Receipts, GitHub Repos, Recipes" + className="w-full px-3 py-2 text-xs border border-gray-300 rounded-lg focus:ring-1 focus:ring-evernote-green focus:border-evernote-green" + /> +
+ + {/* Condition Section */} +
+ + When Note Matches (IF): + + +
+
+ + +
+ +
+ + +
+
+ +
+ + + setEditingRule({ ...editingRule, conditionValue: e.target.value }) + } + placeholder="e.g. invoice, receipt, github.com, application/pdf" + className="w-full px-3 py-1.5 text-xs bg-white border border-gray-300 rounded-lg focus:ring-1 focus:ring-evernote-green" + /> +
+
+ + {/* Action Section */} +
+ + Actions to Perform (THEN): + + +
+ + +
+ +
+ + + setEditingRule({ ...editingRule, addTags: e.target.value }) + } + placeholder="e.g. receipt, finance, 2026" + className="w-full px-3 py-1.5 text-xs bg-white border border-gray-300 rounded-lg" + /> +
+ +
+ + + +
+
+ + {/* Modal Buttons */} +
+ + +
+
+
+
+ )} + + {/* Triage Preview & Run Modal */} + setTriageModalOpen(false)} + notebooks={notebooks} + onApplied={() => onDataChanged()} + /> ); } diff --git a/src/components/TriageModal.tsx b/src/components/TriageModal.tsx new file mode 100644 index 0000000..783421f --- /dev/null +++ b/src/components/TriageModal.tsx @@ -0,0 +1,347 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Wand2, + X, + Loader2, + CheckCircle2, + ArrowRight, + Tag as TagIcon, + Folder, + AlertCircle, + Play, + Pin, +} from 'lucide-react'; +import { NotebookItem } from '@/types/everclone'; + +interface TriageDiffItem { + noteId: string; + title: string; + currentNotebookId: string; + targetNotebookId?: string; + targetNotebookName?: string; + addedTags: string[]; + isPinned?: boolean; + isShortcut?: boolean; + matchedRules: string[]; +} + +interface TriageResult { + totalScanned: number; + totalMatched: number; + movedCount: number; + taggedCount: number; + diffs: TriageDiffItem[]; + dryRun: boolean; +} + +interface TriageModalProps { + isOpen: boolean; + onClose: () => void; + notebooks: NotebookItem[]; + initialNotebookId?: string; + onApplied?: () => void; +} + +export default function TriageModal({ + isOpen, + onClose, + notebooks, + initialNotebookId, + onApplied, +}: TriageModalProps) { + const defaultNb = notebooks.find((n) => n.isDefault) || notebooks[0]; + const [selectedNbId, setSelectedNbId] = useState( + initialNotebookId || defaultNb?.id || '' + ); + const [loadingPreview, setLoadingPreview] = useState(false); + const [executing, setExecuting] = useState(false); + const [result, setResult] = useState(null); + const [appliedSuccess, setAppliedSuccess] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (isOpen) { + const targetId = initialNotebookId || defaultNb?.id || ''; + setSelectedNbId(targetId); + setAppliedSuccess(null); + setErrorMsg(null); + runPreview(targetId); + } + }, [isOpen, initialNotebookId]); + + const runPreview = async (nbId: string) => { + setLoadingPreview(true); + setErrorMsg(null); + setAppliedSuccess(null); + try { + const res = await fetch('/api/auto-sort/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + notebookId: nbId || undefined, + dryRun: true, + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to preview triage'); + } + setResult(data); + } catch (err: unknown) { + setErrorMsg(err instanceof Error ? err.message : 'Preview error'); + } finally { + setLoadingPreview(false); + } + }; + + const handleExecuteTriage = async () => { + if (!result || result.diffs.length === 0) return; + setExecuting(true); + setErrorMsg(null); + try { + const res = await fetch('/api/auto-sort/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + notebookId: selectedNbId || undefined, + dryRun: false, + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to execute triage'); + } + setAppliedSuccess( + `Successfully triaged ${data.totalMatched} note${ + data.totalMatched === 1 ? '' : 's' + } (${data.movedCount} moved, ${data.taggedCount} tagged)!` + ); + setResult(data); + if (onApplied) { + onApplied(); + } + } catch (err: unknown) { + setErrorMsg(err instanceof Error ? err.message : 'Execution error'); + } finally { + setExecuting(false); + } + }; + + if (!isOpen) return null; + + const currentNotebook = notebooks.find((n) => n.id === selectedNbId); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Inbox Triage & Auto-Sort Engine +

+

+ Evaluate and organize notes using your active Auto-Sort rules +

+
+
+ +
+ + {/* Target Notebook Selector & Controls */} +
+
+ Notebook to Triage: + +
+ +
+ + {/* Content Area */} +
+ {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {appliedSuccess && ( +
+ +
{appliedSuccess}
+
+ )} + + {loadingPreview ? ( +
+ +

+ Scanning notes in “{currentNotebook?.name}” against Auto-Sort rules... +

+
+ ) : result ? ( + <> + {/* Summary Stats */} +
+
+
+ {result.totalScanned} +
+
+ Notes Scanned +
+
+
+
+ {result.totalMatched} +
+
+ Rules Matched +
+
+
+
+ {result.movedCount} +
+
+ To Move +
+
+
+ + {/* Diffs List */} + {result.diffs.length === 0 ? ( +
+ +

+ All clean! No notes require triage. +

+

+ All notes in “{currentNotebook?.name}” are already in their target notebooks or do not match any enabled rule. +

+
+ ) : ( +
+

+ Proposed Actions ({result.diffs.length} notes) +

+
+ {result.diffs.map((diff) => ( +
+
+ + {diff.title || 'Untitled Note'} + + + Rule: {diff.matchedRules.join(', ')} + +
+ +
+ {diff.targetNotebookName && ( + + + {currentNotebook?.name} + + {diff.targetNotebookName} + + )} + + {diff.addedTags.map((tag) => ( + + + +{tag} + + ))} + + {diff.isPinned && ( + + + Pin + + )} +
+
+ ))} +
+
+ )} + + ) : null} +
+ + {/* Footer */} +
+ + + {result && result.diffs.length > 0 && !appliedSuccess && ( + + )} +
+
+
+ ); +} diff --git a/src/lib/autoSort.ts b/src/lib/autoSort.ts new file mode 100644 index 0000000..3ae3436 --- /dev/null +++ b/src/lib/autoSort.ts @@ -0,0 +1,490 @@ +import { prisma } from './prisma'; +import { getOrCreateTagIds } from './importers'; +import { AutoSortRule } from '@prisma/client'; + +export type ConditionField = + | 'title' + | 'content' + | 'sourceUrl' + | 'author' + | 'attachmentType' + | 'hasTodo'; + +export type ConditionOperator = + | 'contains' + | 'not_contains' + | 'equals' + | 'startsWith' + | 'endsWith' + | 'matchesRegex'; + +export interface EvaluatedNote { + id?: string; + title: string; + contentText?: string; + contentHtml?: string; + sourceUrl?: string | null; + author?: string | null; + attachments?: Array<{ mimeType: string; filename: string }>; + tags?: Array<{ tag?: { name: string }; name?: string }>; +} + +export interface RuleMatchEvaluation { + matchedRules: AutoSortRule[]; + targetNotebookId?: string; + targetNotebookName?: string; + tagsToAdd: string[]; + isPinned?: boolean; + isShortcut?: boolean; +} + +/** + * Checks whether a single condition matches a note + */ +export function checkCondition( + note: EvaluatedNote, + field: string, + operator: string, + value: string +): boolean { + const normVal = (value || '').trim(); + + // Special case: hasTodo condition + if (field === 'hasTodo') { + const html = note.contentHtml || ''; + const text = note.contentText || ''; + const hasTodo = + html.includes('data-type="taskList"') || + html.includes(' { + const mime = (att.mimeType || '').toLowerCase(); + const fn = (att.filename || '').toLowerCase(); + const search = normVal.toLowerCase(); + + switch (operator) { + case 'contains': + return mime.includes(search) || fn.includes(search); + case 'not_contains': + return !mime.includes(search) && !fn.includes(search); + case 'equals': + return mime === search || fn === search; + case 'startsWith': + return mime.startsWith(search) || fn.startsWith(search); + case 'endsWith': + return mime.endsWith(search) || fn.endsWith(search); + case 'matchesRegex': + try { + const rx = new RegExp(normVal, 'i'); + return rx.test(mime) || rx.test(fn); + } catch { + return false; + } + default: + return mime.includes(search); + } + }); + + return operator === 'not_contains' ? !matchesAny : matchesAny; + } + + // General text field extraction + let noteFieldVal = ''; + switch (field) { + case 'title': + noteFieldVal = note.title || ''; + break; + case 'content': + noteFieldVal = (note.contentText || '') + ' ' + (note.contentHtml || ''); + break; + case 'sourceUrl': + noteFieldVal = note.sourceUrl || ''; + break; + case 'author': + noteFieldVal = note.author || ''; + break; + default: + noteFieldVal = note.title || ''; + } + + const text = noteFieldVal.toLowerCase(); + const search = normVal.toLowerCase(); + + switch (operator) { + case 'contains': + return text.includes(search); + case 'not_contains': + return !text.includes(search); + case 'equals': + return text === search; + case 'startsWith': + return text.startsWith(search); + case 'endsWith': + return text.endsWith(search); + case 'matchesRegex': + try { + const rx = new RegExp(normVal, 'i'); + return rx.test(noteFieldVal); + } catch { + return false; + } + default: + return text.includes(search); + } +} + +/** + * Evaluates ordered rules against a note and computes planned actions + */ +export function evaluateAutoSortRules( + note: EvaluatedNote, + rules: (AutoSortRule & { targetNotebook?: { name: string } | null })[] +): RuleMatchEvaluation { + const matchedRules: AutoSortRule[] = []; + const tagsSet = new Set(); + let targetNotebookId: string | undefined; + let targetNotebookName: string | undefined; + let isPinned: boolean | undefined; + let isShortcut: boolean | undefined; + + // Existing tags already on note to avoid re-adding + const existingTags = new Set( + (note.tags || []).map((t) => (t.tag?.name || t.name || '').toLowerCase()) + ); + + for (const rule of rules) { + if (!rule.enabled) continue; + + const matches = checkCondition( + note, + rule.conditionField, + rule.conditionOperator, + rule.conditionValue + ); + + if (matches) { + matchedRules.push(rule); + + // Only first matching rule sets target notebook (or overwrite if defined) + if (rule.targetNotebookId && !targetNotebookId) { + targetNotebookId = rule.targetNotebookId; + if (rule.targetNotebook?.name) { + targetNotebookName = rule.targetNotebook.name; + } + } + + // Collect tags + if (rule.addTags && rule.addTags.trim()) { + const rawTags = rule.addTags.split(/[,;\n]+/); + for (const raw of rawTags) { + const trimmed = raw.trim(); + if (trimmed && !existingTags.has(trimmed.toLowerCase())) { + tagsSet.add(trimmed); + } + } + } + + // Pin / shortcut flags + if (rule.isPinned !== null && rule.isPinned !== undefined && isPinned === undefined) { + isPinned = rule.isPinned; + } + if (rule.isShortcut !== null && rule.isShortcut !== undefined && isShortcut === undefined) { + isShortcut = rule.isShortcut; + } + + // Stop on match if configured + if (rule.stopOnMatch) { + break; + } + } + } + + return { + matchedRules, + targetNotebookId, + targetNotebookName, + tagsToAdd: Array.from(tagsSet), + isPinned, + isShortcut, + }; +} + +/** + * Applies auto-sort rules to a single note. + */ +export async function applyAutoSortToNote( + noteId: string, + userId: string +): Promise<{ + matched: boolean; + ruleNames: string[]; + targetNotebookId?: string; + addedTags: string[]; +}> { + // Fetch active rules for user + const rules = await prisma.autoSortRule.findMany({ + where: { userId, enabled: true }, + orderBy: { priority: 'asc' }, + include: { targetNotebook: true }, + }); + + if (rules.length === 0) { + return { matched: false, ruleNames: [], addedTags: [] }; + } + + const note = await prisma.note.findUnique({ + where: { id: noteId }, + include: { + tags: { include: { tag: true } }, + attachments: true, + notebook: true, + }, + }); + + if (!note || note.isTrashed) { + return { matched: false, ruleNames: [], addedTags: [] }; + } + + const evalResult = evaluateAutoSortRules(note, rules); + + if (evalResult.matchedRules.length === 0) { + return { matched: false, ruleNames: [], addedTags: [] }; + } + + // Prepare database updates + const updateData: { + notebookId?: string; + isPinned?: boolean; + isShortcut?: boolean; + } = {}; + + if (evalResult.targetNotebookId && evalResult.targetNotebookId !== note.notebookId) { + updateData.notebookId = evalResult.targetNotebookId; + } + + if (evalResult.isPinned !== undefined) { + updateData.isPinned = evalResult.isPinned; + } + + if (evalResult.isShortcut !== undefined) { + updateData.isShortcut = evalResult.isShortcut; + } + + // Create and attach new tags + if (evalResult.tagsToAdd.length > 0) { + const tagIds = await getOrCreateTagIds(evalResult.tagsToAdd, userId); + for (const tagId of tagIds) { + await prisma.noteTag.upsert({ + where: { + noteId_tagId: { + noteId: note.id, + tagId, + }, + }, + create: { + noteId: note.id, + tagId, + }, + update: {}, + }); + } + } + + if (Object.keys(updateData).length > 0) { + await prisma.note.update({ + where: { id: note.id }, + data: updateData, + }); + } + + return { + matched: true, + ruleNames: evalResult.matchedRules.map((r) => r.name), + targetNotebookId: evalResult.targetNotebookId, + addedTags: evalResult.tagsToAdd, + }; +} + +export interface TriageDiffItem { + noteId: string; + title: string; + currentNotebookId: string; + targetNotebookId?: string; + targetNotebookName?: string; + addedTags: string[]; + isPinned?: boolean; + isShortcut?: boolean; + matchedRules: string[]; +} + +export interface TriageResult { + totalScanned: number; + totalMatched: number; + movedCount: number; + taggedCount: number; + diffs: TriageDiffItem[]; + dryRun: boolean; +} + +/** + * Runs triage on a specific notebook (e.g. Inbox) or all notes. + * If dryRun is true, only returns planned actions. + * If dryRun is false, commits updates. + */ +export async function runAutoSortOnNotebook(options: { + notebookId?: string; + userId: string; + dryRun?: boolean; + ruleIds?: string[]; +}): Promise { + const { notebookId, userId, dryRun = false, ruleIds } = options; + + const rulesWhere: Record = { + userId, + enabled: true, + }; + if (ruleIds && ruleIds.length > 0) { + rulesWhere.id = { in: ruleIds }; + } + + const rules = await prisma.autoSortRule.findMany({ + where: rulesWhere, + orderBy: { priority: 'asc' }, + include: { targetNotebook: true }, + }); + + if (rules.length === 0) { + return { + totalScanned: 0, + totalMatched: 0, + movedCount: 0, + taggedCount: 0, + diffs: [], + dryRun, + }; + } + + const notesWhere: Record = { + userId, + isTrashed: false, + }; + if (notebookId) { + notesWhere.notebookId = notebookId; + } + + const notes = await prisma.note.findMany({ + where: notesWhere, + include: { + tags: { include: { tag: true } }, + attachments: true, + notebook: true, + }, + take: 1000, // Batch limit for triage operations + }); + + const diffs: TriageDiffItem[] = []; + let movedCount = 0; + let taggedCount = 0; + + for (const note of notes) { + const evalResult = evaluateAutoSortRules(note, rules); + + if (evalResult.matchedRules.length === 0) { + continue; + } + + const willMove = + evalResult.targetNotebookId && + evalResult.targetNotebookId !== note.notebookId; + const willAddTags = evalResult.tagsToAdd.length > 0; + + // Only include in diff if an actual change would occur + if ( + willMove || + willAddTags || + evalResult.isPinned !== undefined || + evalResult.isShortcut !== undefined + ) { + if (willMove) movedCount++; + if (willAddTags) taggedCount++; + + diffs.push({ + noteId: note.id, + title: note.title, + currentNotebookId: note.notebookId, + targetNotebookId: evalResult.targetNotebookId, + targetNotebookName: evalResult.targetNotebookName || undefined, + addedTags: evalResult.tagsToAdd, + isPinned: evalResult.isPinned, + isShortcut: evalResult.isShortcut, + matchedRules: evalResult.matchedRules.map((r) => r.name), + }); + + if (!dryRun) { + // Apply tag additions + if (willAddTags) { + const tagIds = await getOrCreateTagIds(evalResult.tagsToAdd, userId); + for (const tagId of tagIds) { + await prisma.noteTag.upsert({ + where: { + noteId_tagId: { + noteId: note.id, + tagId, + }, + }, + create: { + noteId: note.id, + tagId, + }, + update: {}, + }); + } + } + + // Apply notebook and flag changes + const updateData: Record = {}; + if (willMove) { + updateData.notebookId = evalResult.targetNotebookId; + } + if (evalResult.isPinned !== undefined) { + updateData.isPinned = evalResult.isPinned; + } + if (evalResult.isShortcut !== undefined) { + updateData.isShortcut = evalResult.isShortcut; + } + + if (Object.keys(updateData).length > 0) { + await prisma.note.update({ + where: { id: note.id }, + data: updateData, + }); + } + } + } + } + + return { + totalScanned: notes.length, + totalMatched: diffs.length, + movedCount, + taggedCount, + diffs, + dryRun, + }; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 7f30c17..ec38416 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,6 +1,6 @@ import { ImapFlow } from 'imapflow'; import { simpleParser } from 'mailparser'; -import { prisma, ensureDefaultNotebook } from './prisma'; +import { prisma, ensureDefaultNotebook, getCurrentUserId } from './prisma'; import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers'; import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage'; @@ -79,18 +79,19 @@ export interface IncomingEmailPayload { export async function ingestEmailToNote( email: IncomingEmailPayload ): Promise<{ noteId: string; title: string }> { + const userId = await getCurrentUserId(); const parsedSubj = parseEvernoteEmailSubject(email.subject); const targetNbName = parsedSubj.notebookName || email.defaultNotebook || ''; const notebookId = targetNbName.trim() - ? await getOrCreateNotebookByName(targetNbName.trim()) - : (await ensureDefaultNotebook()).id; + ? await getOrCreateNotebookByName(targetNbName.trim(), userId) + : (await ensureDefaultNotebook(userId)).id; const combinedTags = [ ...(email.defaultTags || ['emailed']), ...parsedSubj.tags, ]; - const tagIds = await getOrCreateTagIds(combinedTags); + const tagIds = await getOrCreateTagIds(combinedTags, userId); let bodyHtml = email.html || @@ -111,6 +112,7 @@ export async function ingestEmailToNote( const note = await prisma.note.create({ data: { + userId, title: parsedSubj.title, contentHtml: bodyHtml, contentText: stripHtmlToText(bodyHtml), @@ -169,6 +171,14 @@ export async function ingestEmailToNote( }); } + // Auto-sort triage for ingested email + try { + const { applyAutoSortToNote } = await import('./autoSort'); + await applyAutoSortToNote(note.id, userId); + } catch (err) { + console.error('Auto-sort for email failed (non-fatal):', err); + } + return { noteId: note.id, title: note.title }; } diff --git a/src/lib/importers.ts b/src/lib/importers.ts index 40f7287..f5fb296 100644 --- a/src/lib/importers.ts +++ b/src/lib/importers.ts @@ -798,6 +798,14 @@ export async function clipUrlToNote( }); } + // Auto-sort triage for newly clipped web notes + try { + const { applyAutoSortToNote } = await import('./autoSort'); + await applyAutoSortToNote(note.id, userId); + } catch (err) { + console.error('Auto-sort for web clip failed (non-fatal):', err); + } + return note.id; }