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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
autoSortRules AutoSortRule[]
|
||||||
|
|
||||||
@@unique([userId, name])
|
@@unique([userId, name])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -185,3 +186,25 @@ model EmailConfig {
|
|||||||
emailsImported Int @default(0)
|
emailsImported Int @default(0)
|
||||||
updatedAt DateTime @updatedAt
|
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 { marked } from 'marked';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
|
import { prisma, ensureDefaultNotebook, getCurrentUserId } from '@/lib/prisma';
|
||||||
import { verifyApiKeyFromRequest } from '@/lib/auth';
|
import { verifyApiKeyFromRequest } from '@/lib/auth';
|
||||||
import {
|
import {
|
||||||
clipUrlToNote,
|
clipUrlToNote,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
stripHtmlToText,
|
stripHtmlToText,
|
||||||
makeExcerpt,
|
makeExcerpt,
|
||||||
} from '@/lib/storage';
|
} from '@/lib/storage';
|
||||||
|
import { applyAutoSortToNote } from '@/lib/autoSort';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
@@ -29,6 +30,7 @@ export async function OPTIONS() {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
const isValidKey = await verifyApiKeyFromRequest(req);
|
const isValidKey = await verifyApiKeyFromRequest(req);
|
||||||
const isShareTarget = req.nextUrl.searchParams.get('source') === 'share-target';
|
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(
|
return NextResponse.json(
|
||||||
{ ok: true, noteId: note.id, title: note.title, mode: 'extension-article' },
|
{ ok: true, noteId: note.id, title: note.title, mode: 'extension-article' },
|
||||||
{ status: 201, headers: corsHeaders }
|
{ status: 201, headers: corsHeaders }
|
||||||
@@ -283,6 +287,8 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await applyAutoSortToNote(note.id, userId).catch(() => {});
|
||||||
|
|
||||||
if (isShareTarget) {
|
if (isShareTarget) {
|
||||||
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
const isValidKey = await verifyApiKeyFromRequest(req);
|
const isValidKey = await verifyApiKeyFromRequest(req);
|
||||||
if (!isValidKey) {
|
if (!isValidKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -361,6 +368,8 @@ export async function GET(req: NextRequest) {
|
|||||||
noteId = created.id;
|
noteId = created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await applyAutoSortToNote(noteId, userId).catch(() => {});
|
||||||
|
|
||||||
if (sp.get('redirect') === '1') {
|
if (sp.get('redirect') === '1') {
|
||||||
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
|
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 { buildNoteWhereClause } from '@/lib/search';
|
||||||
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
||||||
import { getOrCreateTagIds } from '@/lib/importers';
|
import { getOrCreateTagIds } from '@/lib/importers';
|
||||||
|
import { applyAutoSortToNote } from '@/lib/autoSort';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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({
|
return NextResponse.json({
|
||||||
...note,
|
...note,
|
||||||
tags: note.tags.map((nt) => nt.tag),
|
tags: note.tags.map((nt) => nt.tag),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Menu,
|
Menu,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SidebarData,
|
SidebarData,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
import RichNoteEditor from './RichNoteEditor';
|
import RichNoteEditor from './RichNoteEditor';
|
||||||
import SettingsView from './SettingsView';
|
import SettingsView from './SettingsView';
|
||||||
import ThumbnailEditorModal from './ThumbnailEditorModal';
|
import ThumbnailEditorModal from './ThumbnailEditorModal';
|
||||||
|
import TriageModal from './TriageModal';
|
||||||
import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents';
|
import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents';
|
||||||
|
|
||||||
export default function EvercloneWorkspace() {
|
export default function EvercloneWorkspace() {
|
||||||
@@ -96,6 +98,7 @@ export default function EvercloneWorkspace() {
|
|||||||
const [creatingUser, setCreatingUser] = useState(false);
|
const [creatingUser, setCreatingUser] = useState(false);
|
||||||
const [newUserName, setNewUserName] = useState('');
|
const [newUserName, setNewUserName] = useState('');
|
||||||
const [newUserEmail, setNewUserEmail] = useState('');
|
const [newUserEmail, setNewUserEmail] = useState('');
|
||||||
|
const [triageModalOpen, setTriageModalOpen] = useState(false);
|
||||||
|
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(filterNotebookId ? 1 : 0) +
|
(filterNotebookId ? 1 : 0) +
|
||||||
@@ -1536,19 +1539,30 @@ export default function EvercloneWorkspace() {
|
|||||||
<span>New</span>
|
<span>New</span>
|
||||||
</button>
|
</button>
|
||||||
{activeView === 'notebook' && selectedNotebookId && (
|
{activeView === 'notebook' && selectedNotebookId && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
window.open(
|
onClick={() => setTriageModalOpen(true)}
|
||||||
`/api/export?notebookId=${selectedNotebookId}&format=enex`,
|
title="Run Auto-Sort Triage on this notebook"
|
||||||
'_blank'
|
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" />
|
||||||
title="Export this Notebook (.enex)"
|
<span className="hidden sm:inline">Triage</span>
|
||||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
</button>
|
||||||
>
|
<button
|
||||||
<Download className="w-4 h-4" />
|
type="button"
|
||||||
</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 && (
|
{activeView === 'trash' && notes.length > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -2950,6 +2964,17 @@ export default function EvercloneWorkspace() {
|
|||||||
await handleImmediatePatch({ thumbnailUrl });
|
await handleImmediatePatch({ thumbnailUrl });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TriageModal
|
||||||
|
isOpen={triageModalOpen}
|
||||||
|
onClose={() => setTriageModalOpen(false)}
|
||||||
|
notebooks={sidebar.notebooks}
|
||||||
|
initialNotebookId={activeView === 'notebook' ? selectedNotebookId : undefined}
|
||||||
|
onApplied={() => {
|
||||||
|
fetchSidebar();
|
||||||
|
fetchNotesList();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,34 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Wand2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Edit2,
|
||||||
|
Sparkles,
|
||||||
|
Filter,
|
||||||
|
Pin,
|
||||||
|
Play,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NotebookItem } from '@/types/everclone';
|
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 {
|
interface SettingsViewProps {
|
||||||
notebooks: NotebookItem[];
|
notebooks: NotebookItem[];
|
||||||
@@ -56,8 +82,17 @@ export default function SettingsView({
|
|||||||
onDataChanged,
|
onDataChanged,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
'import' | 'clipper' | 'email' | 'android'
|
'rules' | 'import' | 'clipper' | 'email' | 'android'
|
||||||
>('import');
|
>('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 ---
|
// --- IMPORT STATE ---
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
@@ -151,10 +186,110 @@ export default function SettingsView({
|
|||||||
} catch {}
|
} 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(() => {
|
useEffect(() => {
|
||||||
loadJobs();
|
loadJobs();
|
||||||
loadKeys();
|
loadKeys();
|
||||||
loadEmailCfg();
|
loadEmailCfg();
|
||||||
|
loadRules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll recentJobs every 2s if any job is running/processing
|
// Poll recentJobs every 2s if any job is running/processing
|
||||||
@@ -633,6 +768,19 @@ export default function SettingsView({
|
|||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex border-b border-gray-200 bg-white px-8 gap-6 text-sm font-medium">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('import')}
|
onClick={() => setActiveTab('import')}
|
||||||
@@ -689,6 +837,272 @@ export default function SettingsView({
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<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 */}
|
{/* TAB 1: IMPORT & EXPORT */}
|
||||||
{activeTab === 'import' && (
|
{activeTab === 'import' && (
|
||||||
<>
|
<>
|
||||||
@@ -1438,6 +1852,222 @@ Content-Type: application/json
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { ImapFlow } from 'imapflow';
|
||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
import { prisma, ensureDefaultNotebook } from './prisma';
|
import { prisma, ensureDefaultNotebook, getCurrentUserId } from './prisma';
|
||||||
import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers';
|
import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers';
|
||||||
import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage';
|
import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage';
|
||||||
|
|
||||||
@@ -79,18 +79,19 @@ export interface IncomingEmailPayload {
|
|||||||
export async function ingestEmailToNote(
|
export async function ingestEmailToNote(
|
||||||
email: IncomingEmailPayload
|
email: IncomingEmailPayload
|
||||||
): Promise<{ noteId: string; title: string }> {
|
): Promise<{ noteId: string; title: string }> {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
const parsedSubj = parseEvernoteEmailSubject(email.subject);
|
const parsedSubj = parseEvernoteEmailSubject(email.subject);
|
||||||
|
|
||||||
const targetNbName = parsedSubj.notebookName || email.defaultNotebook || '';
|
const targetNbName = parsedSubj.notebookName || email.defaultNotebook || '';
|
||||||
const notebookId = targetNbName.trim()
|
const notebookId = targetNbName.trim()
|
||||||
? await getOrCreateNotebookByName(targetNbName.trim())
|
? await getOrCreateNotebookByName(targetNbName.trim(), userId)
|
||||||
: (await ensureDefaultNotebook()).id;
|
: (await ensureDefaultNotebook(userId)).id;
|
||||||
|
|
||||||
const combinedTags = [
|
const combinedTags = [
|
||||||
...(email.defaultTags || ['emailed']),
|
...(email.defaultTags || ['emailed']),
|
||||||
...parsedSubj.tags,
|
...parsedSubj.tags,
|
||||||
];
|
];
|
||||||
const tagIds = await getOrCreateTagIds(combinedTags);
|
const tagIds = await getOrCreateTagIds(combinedTags, userId);
|
||||||
|
|
||||||
let bodyHtml =
|
let bodyHtml =
|
||||||
email.html ||
|
email.html ||
|
||||||
@@ -111,6 +112,7 @@ export async function ingestEmailToNote(
|
|||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
|
userId,
|
||||||
title: parsedSubj.title,
|
title: parsedSubj.title,
|
||||||
contentHtml: bodyHtml,
|
contentHtml: bodyHtml,
|
||||||
contentText: stripHtmlToText(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 };
|
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;
|
return note.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user