feat: Add Smart Auto-Sorting and Auto-Tagging Rules (Inbox Triage Engine)

This commit is contained in:
chrisf committed 2026-10-22 17:38:00 -06:00
1 parent 2f14f48ac1
commit 44d1dec42b
11 files changed
+1782 -20

No files matched your search

+23
View File
@@ -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])
}
+159
View File
@@ -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<string, unknown> = {};
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 }
);
}
}
+34
View File
@@ -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 }
);
}
}
+10 -1
View File
@@ -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));
}
+27
View File
@@ -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),
+38 -13
View File
@@ -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() {
<span>New</span>
</button>
{activeView === 'notebook' && selectedNotebookId && (
<button
type="button"
onClick={() =>
window.open(
`/api/export?notebookId=${selectedNotebookId}&format=enex`,
'_blank'
)
}
title="Export this Notebook (.enex)"
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
>
<Download className="w-4 h-4" />
</button>
<>
<button
type="button"
onClick={() => setTriageModalOpen(true)}
title="Run Auto-Sort Triage on this notebook"
className="flex items-center gap-1 px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-evernote-green border border-emerald-200/80 rounded-lg text-xs font-semibold shrink-0 transition"
>
<Wand2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Triage</span>
</button>
<button
type="button"
onClick={() =>
window.open(
`/api/export?notebookId=${selectedNotebookId}&format=enex`,
'_blank'
)
}
title="Export this Notebook (.enex)"
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
>
<Download className="w-4 h-4" />
</button>
</>
)}
{activeView === 'trash' && notes.length > 0 && (
<button
@@ -2950,6 +2964,17 @@ export default function EvercloneWorkspace() {
await handleImmediatePatch({ thumbnailUrl });
}}
/>
<TriageModal
isOpen={triageModalOpen}
onClose={() => setTriageModalOpen(false)}
notebooks={sidebar.notebooks}
initialNotebookId={activeView === 'notebook' ? selectedNotebookId : undefined}
onApplied={() => {
fetchSidebar();
fetchNotesList();
}}
/>
</>
)}
</div>
+632 -2
View File
@@ -21,8 +21,34 @@ import {
ArrowLeft,
Bookmark,
Terminal,
Wand2,
ArrowUp,
ArrowDown,
Edit2,
Sparkles,
Filter,
Pin,
Play,
} from 'lucide-react';
import { NotebookItem } from '@/types/everclone';
import TriageModal from './TriageModal';
interface AutoSortRuleItem {
id: string;
name: string;
enabled: boolean;
priority: number;
conditionField: string;
conditionOperator: string;
conditionValue: string;
targetNotebookId: string | null;
targetNotebook?: { id: string; name: string } | null;
addTags: string;
isPinned: boolean | null;
isShortcut: boolean | null;
stopOnMatch: boolean;
createdAt: string;
}
interface SettingsViewProps {
notebooks: NotebookItem[];
@@ -56,8 +82,17 @@ export default function SettingsView({
onDataChanged,
}: SettingsViewProps) {
const [activeTab, setActiveTab] = useState<
'import' | 'clipper' | 'email' | 'android'
>('import');
'rules' | 'import' | 'clipper' | 'email' | 'android'
>('rules');
// --- AUTO-SORT RULES STATE ---
const [rules, setRules] = useState<AutoSortRuleItem[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [triageModalOpen, setTriageModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<Partial<AutoSortRuleItem> | null>(null);
const [savingRule, setSavingRule] = useState(false);
const [ruleError, setRuleError] = useState<string | null>(null);
// --- IMPORT STATE ---
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -151,10 +186,110 @@ export default function SettingsView({
} catch {}
};
const loadRules = async () => {
setLoadingRules(true);
try {
const res = await fetch('/api/auto-sort');
if (res.ok) {
const data = await res.json();
if (data.rules) setRules(data.rules);
}
} catch (err) {
console.error('Failed to load rules:', err);
} finally {
setLoadingRules(false);
}
};
const handleToggleRule = async (rule: AutoSortRuleItem) => {
try {
const updatedEnabled = !rule.enabled;
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, enabled: updatedEnabled } : r))
);
await fetch('/api/auto-sort', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: rule.id, enabled: updatedEnabled }),
});
} catch (err) {
console.error('Failed to toggle rule:', err);
loadRules();
}
};
const handleDeleteRule = async (id: string) => {
try {
setRules((prev) => prev.filter((r) => r.id !== id));
await fetch(`/api/auto-sort?id=${id}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to delete rule:', err);
loadRules();
}
};
const handleMoveRule = async (id: string, direction: 'up' | 'down') => {
const idx = rules.findIndex((r) => r.id === id);
if (idx === -1) return;
if (direction === 'up' && idx === 0) return;
if (direction === 'down' && idx === rules.length - 1) return;
const newRules = [...rules];
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
const temp = newRules[idx];
newRules[idx] = newRules[targetIdx];
newRules[targetIdx] = temp;
setRules(newRules);
try {
await fetch('/api/auto-sort', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reorder',
ruleIds: newRules.map((r) => r.id),
}),
});
} catch (err) {
console.error('Failed to reorder rules:', err);
loadRules();
}
};
const handleSaveRule = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingRule || !editingRule.name?.trim()) {
setRuleError('Rule name is required');
return;
}
setSavingRule(true);
setRuleError(null);
try {
const isNew = !editingRule.id;
const res = await fetch('/api/auto-sort', {
method: isNew ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to save rule');
}
setRuleModalOpen(false);
setEditingRule(null);
loadRules();
} catch (err: unknown) {
setRuleError(err instanceof Error ? err.message : 'Save error');
} finally {
setSavingRule(false);
}
};
useEffect(() => {
loadJobs();
loadKeys();
loadEmailCfg();
loadRules();
}, []);
// Poll recentJobs every 2s if any job is running/processing
@@ -633,6 +768,19 @@ export default function SettingsView({
{/* Navigation Tabs */}
<div className="flex border-b border-gray-200 bg-white px-8 gap-6 text-sm font-medium">
<button
type="button"
onClick={() => setActiveTab('rules')}
className={`flex items-center gap-2 py-3 border-b-2 transition ${
activeTab === 'rules'
? 'border-evernote-green text-evernote-green'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<Wand2 className="w-4 h-4" />
<span>Auto-Sort Rules</span>
</button>
<button
type="button"
onClick={() => setActiveTab('import')}
@@ -689,6 +837,272 @@ export default function SettingsView({
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* TAB 0: AUTO-SORT RULES (INBOX TRIAGE ENGINE) */}
{activeTab === 'rules' && (
<>
{/* Header Card */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="p-1.5 rounded-lg bg-emerald-50 text-evernote-green">
<Wand2 className="w-5 h-5" />
</span>
<h2 className="text-base font-bold text-gray-900">
Inbox Triage & Auto-Sorting Rules
</h2>
</div>
<p className="text-xs text-gray-500 mt-1">
Incoming notes from Web Clipper, Email-to-Note, and Android hooks are automatically classified, moved to notebooks, and tagged based on these rules.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setTriageModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-semibold rounded-lg transition"
>
<Play className="w-3.5 h-3.5 fill-current text-gray-600" />
<span>Triage Inbox Now</span>
</button>
<button
type="button"
onClick={() => {
setEditingRule({
name: '',
enabled: true,
conditionField: 'title',
conditionOperator: 'contains',
conditionValue: '',
targetNotebookId: null,
addTags: '',
isPinned: null,
isShortcut: null,
stopOnMatch: true,
});
setRuleError(null);
setRuleModalOpen(true);
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-evernote-green hover:bg-evernote-greenHover text-white text-xs font-semibold rounded-lg shadow-sm transition"
>
<Plus className="w-3.5 h-3.5" />
<span>New Rule</span>
</button>
</div>
</div>
{/* Quick Templates */}
<div className="pt-2 border-t border-gray-100">
<div className="text-[11px] font-bold uppercase tracking-wider text-gray-400 mb-2">
💡 Quick Templates: Click to add pre-configured rule
</div>
<div className="flex flex-wrap gap-2">
{[
{
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) => (
<button
key={tpl.name}
type="button"
onClick={() => {
setEditingRule({
name: tpl.name.replace(/^[^\w\s]+\s*/, ''),
enabled: true,
conditionField: tpl.field,
conditionOperator: tpl.operator,
conditionValue: tpl.value,
targetNotebookId: tpl.targetNb,
addTags: tpl.tags,
stopOnMatch: true,
});
setRuleError(null);
setRuleModalOpen(true);
}}
className="text-xs bg-gray-50 hover:bg-emerald-50 hover:text-evernote-green border border-gray-200 hover:border-emerald-200 px-2.5 py-1 rounded-lg text-gray-700 font-medium transition"
>
{tpl.name}
</button>
))}
</div>
</div>
</div>
{/* Rules List Card */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">
Active Rules ({rules.length})
</h3>
<span className="text-xs text-gray-500">
Rules evaluate from top to bottom
</span>
</div>
{loadingRules ? (
<div className="py-12 flex justify-center text-gray-400">
<Loader2 className="w-6 h-6 animate-spin text-evernote-green" />
</div>
) : rules.length === 0 ? (
<div className="py-12 text-center text-gray-400 space-y-3">
<Wand2 className="w-10 h-10 mx-auto text-gray-300" />
<p className="text-sm font-medium text-gray-600">
No auto-sort rules created yet.
</p>
<p className="text-xs text-gray-400 max-w-sm mx-auto">
Create rules to automatically route clipped articles, receipts, and emails directly into target notebooks and tags.
</p>
</div>
) : (
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div
key={rule.id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-xl border transition ${
rule.enabled
? 'bg-white border-gray-200 hover:border-gray-300 shadow-xs'
: 'bg-gray-50/70 border-gray-200 opacity-60'
}`}
>
<div className="flex items-start sm:items-center gap-3">
{/* Priority controls */}
<div className="flex flex-col items-center shrink-0">
<button
type="button"
onClick={() => handleMoveRule(rule.id, 'up')}
disabled={idx === 0}
className="text-gray-400 hover:text-gray-700 disabled:opacity-20 p-0.5"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-[10px] font-bold text-gray-500">
#{idx + 1}
</span>
<button
type="button"
onClick={() => handleMoveRule(rule.id, 'down')}
disabled={idx === rules.length - 1}
className="text-gray-400 hover:text-gray-700 disabled:opacity-20 p-0.5"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{/* Enabled checkbox */}
<input
type="checkbox"
checked={rule.enabled}
onChange={() => 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 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-gray-900">
{rule.name}
</span>
{rule.stopOnMatch && (
<span className="text-[10px] bg-amber-50 text-amber-700 border border-amber-200 px-1.5 py-0.2 rounded font-medium">
Stop on match
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-gray-600">
<span className="bg-gray-100 text-gray-700 px-2 py-0.5 rounded-md font-mono text-[11px]">
If <strong className="font-semibold text-gray-900">{rule.conditionField}</strong> {rule.conditionOperator} &ldquo;<span className="text-gray-900 font-semibold">{rule.conditionValue}</span>&rdquo;
</span>
{rule.targetNotebook?.name && (
<span className="bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-md font-medium">
Move → {rule.targetNotebook.name}
</span>
)}
{rule.addTags && (
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200 px-2 py-0.5 rounded-md font-medium">
+Tags: {rule.addTags}
</span>
)}
{rule.isPinned && (
<span className="bg-amber-50 text-amber-700 border border-amber-200 px-2 py-0.5 rounded-md font-medium">
📌 Pin
</span>
)}
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 self-end sm:self-center shrink-0">
<button
type="button"
onClick={() => {
setEditingRule(rule);
setRuleError(null);
setRuleModalOpen(true);
}}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition"
title="Edit Rule"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
title="Delete Rule"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
{/* TAB 1: IMPORT & EXPORT */}
{activeTab === 'import' && (
<>
@@ -1438,6 +1852,222 @@ Content-Type: application/json
)}
</div>
</div>
{/* Auto-Sort Rule Editor Modal */}
{ruleModalOpen && editingRule && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200 w-full max-w-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-emerald-50 to-teal-50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-evernote-green/15 text-evernote-green flex items-center justify-center">
<Wand2 className="w-4 h-4" />
</div>
<h3 className="font-bold text-gray-900 text-base">
{editingRule.id ? 'Edit Auto-Sort Rule' : 'New Auto-Sort Rule'}
</h3>
</div>
<button
type="button"
onClick={() => setRuleModalOpen(false)}
className="text-gray-400 hover:text-gray-700"
>
✕
</button>
</div>
<form onSubmit={handleSaveRule} className="p-6 space-y-4">
{ruleError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-red-700 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{ruleError}</span>
</div>
)}
{/* Rule Name */}
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">
Rule Name *
</label>
<input
type="text"
required
value={editingRule.name || ''}
onChange={(e) => 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"
/>
</div>
{/* Condition Section */}
<div className="bg-gray-50 border border-gray-200 rounded-xl p-3.5 space-y-2.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-gray-500">
When Note Matches (IF):
</span>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-0.5">
Field
</label>
<select
value={editingRule.conditionField || 'title'}
onChange={(e) =>
setEditingRule({ ...editingRule, conditionField: e.target.value })
}
className="w-full px-2.5 py-1.5 text-xs bg-white border border-gray-300 rounded-lg"
>
<option value="title">Title</option>
<option value="content">Content / Body</option>
<option value="sourceUrl">Source URL</option>
<option value="author">Author / Email Sender</option>
<option value="attachmentType">Attachment MIME / File Ext</option>
<option value="hasTodo">Has Checklist / Tasks</option>
</select>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-0.5">
Operator
</label>
<select
value={editingRule.conditionOperator || 'contains'}
onChange={(e) =>
setEditingRule({ ...editingRule, conditionOperator: e.target.value })
}
className="w-full px-2.5 py-1.5 text-xs bg-white border border-gray-300 rounded-lg"
>
<option value="contains">contains</option>
<option value="not_contains">does not contain</option>
<option value="equals">equals exactly</option>
<option value="startsWith">starts with</option>
<option value="endsWith">ends with</option>
<option value="matchesRegex">matches regex</option>
</select>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-0.5">
Match Value (case-insensitive)
</label>
<input
type="text"
value={editingRule.conditionValue || ''}
onChange={(e) =>
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"
/>
</div>
</div>
{/* Action Section */}
<div className="bg-gray-50 border border-gray-200 rounded-xl p-3.5 space-y-2.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-gray-500">
Actions to Perform (THEN):
</span>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-0.5">
Move to Target Notebook
</label>
<select
value={editingRule.targetNotebookId || ''}
onChange={(e) =>
setEditingRule({
...editingRule,
targetNotebookId: e.target.value || null,
})
}
className="w-full px-2.5 py-1.5 text-xs bg-white border border-gray-300 rounded-lg"
>
<option value="">(Leave in place - do not move)</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.name} {nb.isDefault ? '(Default Inbox)' : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-0.5">
Attach Tags (comma-separated)
</label>
<input
type="text"
value={editingRule.addTags || ''}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-1.5 pt-1">
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={Boolean(editingRule.isPinned)}
onChange={(e) =>
setEditingRule({
...editingRule,
isPinned: e.target.checked ? true : null,
})
}
className="w-3.5 h-3.5 text-evernote-green rounded border-gray-300 focus:ring-evernote-green"
/>
<span>Pin note to top of list</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={editingRule.stopOnMatch !== false}
onChange={(e) =>
setEditingRule({
...editingRule,
stopOnMatch: e.target.checked,
})
}
className="w-3.5 h-3.5 text-evernote-green rounded border-gray-300 focus:ring-evernote-green"
/>
<span>Stop evaluating further rules if this rule matches</span>
</label>
</div>
</div>
{/* Modal Buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setRuleModalOpen(false)}
className="px-4 py-2 text-xs font-semibold text-gray-600 hover:text-gray-900 rounded-lg hover:bg-gray-100 transition"
>
Cancel
</button>
<button
type="submit"
disabled={savingRule}
className="px-5 py-2 bg-evernote-green hover:bg-evernote-greenHover text-white rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
{savingRule ? 'Saving...' : editingRule.id ? 'Update Rule' : 'Create Rule'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Triage Preview & Run Modal */}
<TriageModal
isOpen={triageModalOpen}
onClose={() => setTriageModalOpen(false)}
notebooks={notebooks}
onApplied={() => onDataChanged()}
/>
</div>
);
}
+347
View File
@@ -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<string>(
initialNotebookId || defaultNb?.id || ''
);
const [loadingPreview, setLoadingPreview] = useState(false);
const [executing, setExecuting] = useState(false);
const [result, setResult] = useState<TriageResult | null>(null);
const [appliedSuccess, setAppliedSuccess] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200 w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-emerald-50 to-teal-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-evernote-green/15 text-evernote-green flex items-center justify-center">
<Wand2 className="w-5 h-5 text-evernote-green" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">
Inbox Triage & Auto-Sort Engine
</h2>
<p className="text-xs text-gray-600">
Evaluate and organize notes using your active Auto-Sort rules
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-white rounded-lg transition"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Target Notebook Selector & Controls */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200 flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-600 font-medium">Notebook to Triage:</span>
<select
value={selectedNbId}
onChange={(e) => {
setSelectedNbId(e.target.value);
runPreview(e.target.value);
}}
disabled={loadingPreview || executing}
className="text-xs font-semibold bg-white border border-gray-300 rounded-lg px-2.5 py-1 text-gray-800 focus:outline-none focus:ring-1 focus:ring-evernote-green"
>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.name} {nb.isDefault ? '(Default Inbox)' : ''}
</option>
))}
</select>
</div>
<button
onClick={() => runPreview(selectedNbId)}
disabled={loadingPreview || executing}
className="text-xs text-gray-600 hover:text-gray-900 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-200 transition"
>
{loadingPreview ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
'Re-scan'
)}
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{errorMsg && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-red-700 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{appliedSuccess && (
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-xl text-emerald-800 text-sm flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-emerald-600 shrink-0" />
<div className="font-semibold">{appliedSuccess}</div>
</div>
)}
{loadingPreview ? (
<div className="py-16 flex flex-col items-center justify-center text-gray-500 space-y-3">
<Loader2 className="w-8 h-8 text-evernote-green animate-spin" />
<p className="text-sm font-medium">
Scanning notes in &ldquo;{currentNotebook?.name}&rdquo; against Auto-Sort rules...
</p>
</div>
) : result ? (
<>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-gray-50 border border-gray-200 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-gray-800">
{result.totalScanned}
</div>
<div className="text-xs text-gray-500 font-medium uppercase mt-0.5">
Notes Scanned
</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-evernote-green">
{result.totalMatched}
</div>
<div className="text-xs text-emerald-700 font-medium uppercase mt-0.5">
Rules Matched
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 text-center">
<div className="text-2xl font-bold text-blue-700">
{result.movedCount}
</div>
<div className="text-xs text-blue-600 font-medium uppercase mt-0.5">
To Move
</div>
</div>
</div>
{/* Diffs List */}
{result.diffs.length === 0 ? (
<div className="py-12 text-center text-gray-500 space-y-2">
<CheckCircle2 className="w-10 h-10 text-emerald-500 mx-auto" />
<p className="font-semibold text-gray-800">
All clean! No notes require triage.
</p>
<p className="text-xs text-gray-500 max-w-sm mx-auto">
All notes in &ldquo;{currentNotebook?.name}&rdquo; are already in their target notebooks or do not match any enabled rule.
</p>
</div>
) : (
<div className="space-y-3">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-500">
Proposed Actions ({result.diffs.length} notes)
</h4>
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
{result.diffs.map((diff) => (
<div
key={diff.noteId}
className="p-3 bg-white border border-gray-200 rounded-xl hover:border-gray-300 shadow-sm transition space-y-2"
>
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-sm text-gray-900 truncate">
{diff.title || 'Untitled Note'}
</span>
<span className="text-[10px] font-semibold uppercase bg-gray-100 text-gray-700 px-2 py-0.5 rounded-full shrink-0">
Rule: {diff.matchedRules.join(', ')}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{diff.targetNotebookName && (
<span className="flex items-center gap-1 text-blue-700 bg-blue-50 border border-blue-200 px-2 py-0.5 rounded-md font-medium">
<Folder className="w-3 h-3" />
<span>{currentNotebook?.name}</span>
<ArrowRight className="w-3 h-3" />
<span className="font-bold">{diff.targetNotebookName}</span>
</span>
)}
{diff.addedTags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-md font-medium"
>
<TagIcon className="w-3 h-3" />
<span>+{tag}</span>
</span>
))}
{diff.isPinned && (
<span className="flex items-center gap-1 text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md font-medium">
<Pin className="w-3 h-3" />
<span>Pin</span>
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</>
) : null}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-semibold text-gray-600 hover:text-gray-900 rounded-lg hover:bg-gray-200 transition"
>
Close
</button>
{result && result.diffs.length > 0 && !appliedSuccess && (
<button
onClick={handleExecuteTriage}
disabled={executing || loadingPreview}
className="flex items-center gap-2 px-5 py-2.5 bg-evernote-green hover:bg-emerald-600 text-white rounded-xl font-bold text-xs shadow-md transition disabled:opacity-50"
>
{executing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Applying Triage...</span>
</>
) : (
<>
<Play className="w-3.5 h-3.5 fill-current" />
<span>Apply Triage ({result.diffs.length} Notes)</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
+490
View File
@@ -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('<en-todo') ||
html.includes('type="checkbox"') ||
/\[[ xX]\]/.test(text);
return operator === 'equals' || operator === 'contains'
? normVal.toLowerCase() === 'false' || normVal === '0'
? !hasTodo
: hasTodo
: hasTodo;
}
// Special case: attachmentType
if (field === 'attachmentType') {
const attachments = note.attachments || [];
if (attachments.length === 0) {
return operator === 'not_contains';
}
const matchesAny = attachments.some((att) => {
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<string>();
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<TriageResult> {
const { notebookId, userId, dryRun = false, ruleIds } = options;
const rulesWhere: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {};
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,
};
}
+14 -4
View File
@@ -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 };
}
+8
View File
@@ -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;
}