feat: Add Smart Auto-Sorting and Auto-Tagging Rules (Inbox Triage Engine)
This commit is contained in:
1 parent
2f14f48ac1
commit
44d1dec42b
11 files changed
+1782
-20
No files matched your search
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} “<span className="text-gray-900 font-semibold">{rule.conditionValue}</span>”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{currentNotebook?.name}” 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 “{currentNotebook?.name}” 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user