From 0683aec0f8dc5c15f2f40988deaa99654fdad464 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Tue, 7 Jul 2026 17:55:08 +0530 Subject: [PATCH 1/4] feat: implement smart notebook & tag apply, undo, and cleanup features --- src/commands/applyChanges.ts | 434 ++++++++++++++++++++++++ src/index.ts | 109 +++++- src/types/panel.ts | 37 +- src/webview/context/AppStateContext.tsx | 258 +++++++++++++- src/webview/pages/DashboardPage.tsx | 58 +++- src/webview/pages/HistoryPage.tsx | 101 +++++- src/webview/panel.css | 186 ++++++++++ 7 files changed, 1169 insertions(+), 14 deletions(-) create mode 100644 src/commands/applyChanges.ts diff --git a/src/commands/applyChanges.ts b/src/commands/applyChanges.ts new file mode 100644 index 0000000..f0d5539 --- /dev/null +++ b/src/commands/applyChanges.ts @@ -0,0 +1,434 @@ +import joplin from 'api'; +import { ApplyOptions, PanelNote, PanelMessage } from '../types/panel'; +import { log } from '../utils/logger'; + +export interface ChangeLogEntry { + timestamp: number; + method: 'tags' | 'notebooks' | 'both'; + notes: { + noteId: string; + originalParentId?: string; + addedTagId?: string; + addedTagIds?: string[]; + }[]; + createdFolderIds?: string[]; + createdTagIds?: string[]; +} + +function matchesKeyword(title: string, body: string, keyword: string): boolean { + const lowerKeyword = keyword.toLowerCase(); + const lowerTitle = title.toLowerCase(); + const lowerBody = body.toLowerCase(); + + try { + const escaped = lowerKeyword.replace(/[-\x2f\\^$*+?.()|[\]{}]/g, '\\$&'); + // Unicode-aware word boundary matching + const regex = new RegExp(`(?:^|[^\\p{L}\\p{N}])` + escaped + `(?:$|[^\\p{L}\\p{N}])`, 'u'); + return regex.test(lowerTitle) || regex.test(lowerBody); + } catch (e) { + return lowerTitle.includes(lowerKeyword) || lowerBody.includes(lowerKeyword); + } +} + +export async function applyCategorizationChanges( + options: ApplyOptions, + notes: PanelNote[], + assignments: number[], + clusterNames: { [clusterId: number]: string }, + clusterTags: { [clusterId: number]: string[] }, + setPanelState: (state: PanelMessage) => void, +) { + try { + setPanelState({ type: 'apply_status', text: 'Fetching existing folders and tags...' }); + + const MAX_PAGES = 500; + + // Fetch all existing tags once to map titles to IDs + const allTags: any[] = []; + let tagPage = 1; + while (tagPage <= MAX_PAGES) { + const res = await joplin.data.get(['tags'], { page: tagPage, limit: 100, fields: ['id', 'title'] }); + allTags.push(...res.items); + if (!res.has_more) break; + tagPage++; + } + const existingTagsMap = new Map(allTags.map((t: any) => [t.title.toLowerCase(), t.id])); + + // Fetch all folders once to map titles and parent_ids to IDs + const allFoldersList: any[] = []; + let folderPage = 1; + while (folderPage <= MAX_PAGES) { + const res = await joplin.data.get(['folders'], { page: folderPage, limit: 100, fields: ['id', 'title', 'parent_id'] }); + allFoldersList.push(...res.items); + if (!res.has_more) break; + folderPage++; + } + const existingFoldersMap = new Map( + allFoldersList.map((f: any) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]), + ); + + // Optimized helper to get or create tag + const getOrCreateTagOptimized = async (title: string): Promise<{ id: string; created: boolean }> => { + const lowerTitle = title.toLowerCase(); + const cachedId = existingTagsMap.get(lowerTitle); + if (cachedId) { + return { id: cachedId, created: false }; + } + const created = await joplin.data.post(['tags'], null, { title }); + existingTagsMap.set(lowerTitle, created.id); + return { id: created.id, created: true }; + }; + + // Optimized helper to get or create folder + const getOrCreateFolderOptimized = async ( + title: string, + parentId = '', + ): Promise<{ id: string; created: boolean }> => { + const key = `${title.toLowerCase()}\x1F${parentId}`; + const cachedId = existingFoldersMap.get(key); + if (cachedId) { + return { id: cachedId, created: false }; + } + const created = await joplin.data.post(['folders'], null, { + title, + parent_id: parentId || undefined, + }); + existingFoldersMap.set(key, created.id); + return { id: created.id, created: true }; + }; + + const uniqueClusterIds = Array.from(new Set(assignments.filter((id) => id >= 0))); + + // 1. Create/retrieve tags if needed + const tagMap: { [clusterId: number]: string } = {}; + const createdTagIds: string[] = []; + if (options.method === 'tags' || options.method === 'both') { + for (const clusterId of uniqueClusterIds) { + const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`; + const tagName = clusterName; + const { id: tagId, created } = await getOrCreateTagOptimized(tagName); + tagMap[clusterId] = tagId; + if (created) { + createdTagIds.push(tagId); + } + } + } + + // 2. Create folders if needed + const folderMap: { [clusterId: number]: string } = {}; + const createdFolderIds: string[] = []; + let uncategorizedFolderId = ''; + if (options.method === 'notebooks' || options.method === 'both') { + for (const clusterId of uniqueClusterIds) { + const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`; + const { id: childFolderId, created } = await getOrCreateFolderOptimized(clusterName); + folderMap[clusterId] = childFolderId; + if (created) { + createdFolderIds.push(childFolderId); + } + } + + if (assignments.includes(-1)) { + const { id: noiseFolderId, created } = await getOrCreateFolderOptimized('Uncategorized'); + uncategorizedFolderId = noiseFolderId; + if (created) { + createdFolderIds.push(noiseFolderId); + } + } + } + + // 3. Process notes & build change log + const total = notes.length; + const changeLogNotes: { + noteId: string; + originalParentId?: string; + addedTagId?: string; + addedTagIds?: string[]; + }[] = []; + + for (let i = 0; i < total; i++) { + const note = notes[i]; + const clusterId = assignments[i]; + + const changeEntry: { + noteId: string; + originalParentId?: string; + addedTagId?: string; + addedTagIds?: string[]; + } = { + noteId: note.noteId, + }; + + let modified = false; + + // Fetch note metadata and content once for both parent_id check and keyword matching + let noteTitle = ''; + let noteBody = ''; + let originalParentId = ''; + const needsBody = options.method === 'tags' || options.method === 'both'; + try { + const noteFields = needsBody ? ['parent_id', 'title', 'body'] : ['parent_id', 'title']; + const noteObj = await joplin.data.get(['notes', note.noteId], { + fields: noteFields, + }); + originalParentId = noteObj.parent_id || ''; + noteTitle = noteObj.title || ''; + noteBody = needsBody ? (noteObj.body || '') : ''; + } catch (fetchErr) { + log(`Error fetching note data for ${note.noteId}: ${fetchErr}`); + continue; + } + + // Handle tags + if ((options.method === 'tags' || options.method === 'both') && clusterId >= 0) { + const addedTagIds: string[] = []; + + // Apply the main cluster name tag (as grouping tag) + const mainTagId = tagMap[clusterId]; + if (mainTagId) { + try { + await joplin.data.post(['tags', mainTagId, 'notes'], null, { id: note.noteId }); + } catch (tagErr) { + log(`Tag ${mainTagId} may already be on note ${note.noteId}: ${tagErr}`); + } + addedTagIds.push(mainTagId); + } + + // Get all extracted specific tags for this cluster + const specificTags = clusterTags[clusterId] || []; + for (const tagText of specificTags) { + // Don't duplicate the main cluster tag if it's already applied + const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`; + if (tagText.toLowerCase() === clusterName.toLowerCase()) { + continue; + } + + if (matchesKeyword(noteTitle, noteBody, tagText)) { + const { id: tagId, created } = await getOrCreateTagOptimized(tagText); + try { + await joplin.data.post(['tags', tagId, 'notes'], null, { id: note.noteId }); + } catch (tagErr) { + log(`Tag ${tagId} may already be on note ${note.noteId}: ${tagErr}`); + } + addedTagIds.push(tagId); + if (created) { + createdTagIds.push(tagId); + } + } + } + + if (addedTagIds.length > 0) { + changeEntry.addedTagIds = addedTagIds; + modified = true; + } + } + + // Handle folders + if (options.method === 'notebooks' || options.method === 'both') { + let targetFolderId = ''; + if (clusterId >= 0) { + targetFolderId = folderMap[clusterId]; + } else if (clusterId === -1 && uncategorizedFolderId) { + targetFolderId = uncategorizedFolderId; + } + + if (targetFolderId && targetFolderId !== originalParentId) { + await joplin.data.put(['notes', note.noteId], null, { parent_id: targetFolderId }); + changeEntry.originalParentId = originalParentId; + modified = true; + } + } + + if (modified) { + changeLogNotes.push(changeEntry); + } + + setPanelState({ + type: 'apply_progress', + current: i + 1, + total, + }); + } + + // Save change log + const changeLogEntry: ChangeLogEntry = { + timestamp: Date.now(), + method: options.method, + notes: changeLogNotes, + createdFolderIds, + createdTagIds, + }; + await joplin.settings.setValue('categorization.changeLog', JSON.stringify(changeLogEntry)); + + setPanelState({ type: 'apply_complete' }); + } catch (err: any) { + log('Error in applyCategorizationChanges: ' + err); + setPanelState({ + type: 'apply_error', + message: err.message || String(err), + }); + } +} + +export async function undoCategorizationChanges(setPanelState: (state: PanelMessage) => void) { + try { + setPanelState({ type: 'undo_status', text: 'Initializing undo operation...' }); + + const changeLogStr = await joplin.settings.value('categorization.changeLog'); + if (!changeLogStr) { + throw new Error('No change log found to undo.'); + } + + const changeLog: ChangeLogEntry = JSON.parse(changeLogStr); + const total = changeLog.notes.length; + + // 1. Restore parent notebooks and remove tag associations from notes + for (let i = 0; i < total; i++) { + const entry = changeLog.notes[i]; + + // Remove added tag from note (old format) + if (entry.addedTagId) { + try { + await joplin.data.delete(['tags', entry.addedTagId, 'notes', entry.noteId]); + } catch (tagErr) { + log(`Undo: tag ${entry.addedTagId} removal failed for note ${entry.noteId}: ${tagErr}`); + } + } + + // Remove added tags from note (new format) + if (entry.addedTagIds) { + for (const tagId of entry.addedTagIds) { + try { + await joplin.data.delete(['tags', tagId, 'notes', entry.noteId]); + } catch (tagErr) { + log(`Undo: tag ${tagId} removal failed for note ${entry.noteId}: ${tagErr}`); + } + } + } + + // Restore parent notebook + if (entry.originalParentId) { + try { + await joplin.data.put(['notes', entry.noteId], null, { parent_id: entry.originalParentId }); + } catch (folderErr) { + log( + `Undo: restoring folder ${entry.originalParentId} failed for note ${entry.noteId}: ${folderErr}`, + ); + } + } + + setPanelState({ + type: 'undo_progress', + current: i + 1, + total, + }); + } + + // 2. Delete created folders if they exist + if (changeLog.createdFolderIds && changeLog.createdFolderIds.length > 0) { + setPanelState({ type: 'undo_status', text: 'Deleting created notebooks...' }); + for (const folderId of changeLog.createdFolderIds) { + try { + const notesInFolder = await joplin.data.get(['folders', folderId, 'notes'], { limit: 1 }); + if (notesInFolder.items.length > 0) { + log(`Undo: skipping non-empty folder ${folderId}`); + continue; + } + await joplin.data.delete(['folders', folderId]); + } catch (folderErr) { + log(`Undo: failed to delete created folder ${folderId}: ${folderErr}`); + } + } + } + + // 3. Delete created tags if they exist + if (changeLog.createdTagIds && changeLog.createdTagIds.length > 0) { + setPanelState({ type: 'undo_status', text: 'Deleting created tags...' }); + for (const tagId of changeLog.createdTagIds) { + try { + await joplin.data.delete(['tags', tagId]); + } catch (tagErr) { + log(`Undo: failed to delete created tag ${tagId}: ${tagErr}`); + } + } + } + + // Clear change log + await joplin.settings.setValue('categorization.changeLog', ''); + + setPanelState({ type: 'undo_complete' }); + } catch (err: any) { + log('Error in undoCategorizationChanges: ' + err); + setPanelState({ + type: 'undo_error', + message: err.message || String(err), + }); + } +} + +export async function cleanUpEmptyNotebooks(setPanelState: (state: PanelMessage) => void) { + try { + setPanelState({ type: 'cleanup_status', text: 'Checking empty notebooks...' }); + + const changeLogStr = await joplin.settings.value('categorization.changeLog'); + if (!changeLogStr) { + throw new Error('No active categorization history found.'); + } + const changeLog: ChangeLogEntry = JSON.parse(changeLogStr); + + // Get all unique original parent IDs + const originalParentIds = new Set(); + for (const note of changeLog.notes) { + if (note.originalParentId) { + originalParentIds.add(note.originalParentId); + } + } + + if (originalParentIds.size === 0) { + setPanelState({ type: 'cleanup_complete', message: 'No original notebooks to clean up.' }); + return; + } + + let deletedCount = 0; + + // Fetch all folders once to map parent-child relationships in memory + const allFolders: any[] = []; + let page = 1; + while (page <= 500) { + const res = await joplin.data.get(['folders'], { page, limit: 100, fields: ['id', 'parent_id'] }); + allFolders.push(...res.items); + if (!res.has_more) break; + page++; + } + const parentFolderIds = new Set(allFolders.map((f: any) => f.parent_id).filter((pid) => !!pid)); + + for (const folderId of originalParentIds) { + try { + // 1. Check if it has subfolders in memory + if (parentFolderIds.has(folderId)) { + continue; + } + + // 2. Check notes count in this folder + const notesInFolder = await joplin.data.get(['folders', folderId, 'notes'], { limit: 1 }); + if (notesInFolder.items.length === 0) { + await joplin.data.delete(['folders', folderId]); + deletedCount++; + } + } catch (err) { + log(`Cleanup: failed to check or delete folder ${folderId}: ${err}`); + } + } + + setPanelState({ + type: 'cleanup_complete', + message: `Cleaned up ${deletedCount} empty original notebook(s) successfully!`, + }); + } catch (err: any) { + log('Error in cleanUpEmptyNotebooks: ' + err); + setPanelState({ + type: 'cleanup_error', + message: err.message || String(err), + }); + } +} diff --git a/src/index.ts b/src/index.ts index 6fc366d..7f54065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,54 @@ import joplin from 'api'; -import { MenuItemLocation, ToolbarButtonLocation } from 'api/types'; +import { MenuItemLocation, ToolbarButtonLocation, SettingItemType as SettingType } from 'api/types'; import { runTestEmbed } from './commands/testEmbed'; import { runPipeline } from './pipeline/runPipeline'; import { PanelMessage, WebviewMessage } from './types/panel'; import { log } from './utils/logger'; +import { applyCategorizationChanges, undoCategorizationChanges, cleanUpEmptyNotebooks } from './commands/applyChanges'; joplin.plugins.register({ onStart: async function () { log('Plugin started'); + // Register setting section + await joplin.settings.registerSection('aiCategorization', { + label: 'AI Categorization', + iconName: 'fas fa-brain', + }); + + // Register setting items + await joplin.settings.registerSettings({ + 'categorization.metric': { + value: 'cosine', + type: SettingType.String, + section: 'aiCategorization', + public: true, + isEnum: true, + options: { + cosine: 'Cosine Similarity', + euclidean: 'Euclidean Distance', + }, + label: 'Distance Metric', + description: 'The metric used to compute distances between note embeddings.', + }, + 'categorization.parentNotebook': { + value: 'AI Categorized Notes', + type: SettingType.String, + section: 'aiCategorization', + public: true, + label: 'Parent Notebook', + description: 'The parent notebook name where categorized folders will be placed.', + }, + 'categorization.changeLog': { + value: '', + type: SettingType.String, + section: 'aiCategorization', + public: false, + label: 'Change Log', + description: 'Stores previous states of moved and tagged notes for undo operations.', + }, + }); + const installDir = await joplin.plugins.installationDir(); await joplin.commands.register({ @@ -33,6 +73,7 @@ joplin.plugins.register({ // Pipeline state shared between the onMessage handler and pipeline callbacks. // The webview polls this state via { type: 'poll' } messages. let panelState: PanelMessage | { type: 'idle' } = { type: 'idle' }; + let operationInProgress = false; await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => { switch (msg.type) { @@ -66,6 +107,72 @@ joplin.plugins.register({ await joplin.commands.execute('openNote', msg.noteId); } return; + + case 'getSettings': + return { + 'categorization.metric': await joplin.settings.value('categorization.metric'), + 'categorization.parentNotebook': await joplin.settings.value('categorization.parentNotebook'), + 'categorization.changeLog': await joplin.settings.value('categorization.changeLog'), + }; + + case 'updateSetting': + await joplin.settings.setValue(msg.key, msg.value); + return { success: true }; + + case 'apply': + if (operationInProgress) { + return { type: 'apply_error', message: 'Another operation is already in progress.' }; + } + operationInProgress = true; + panelState = { type: 'apply_status', text: 'Initializing application of categorization...' }; + applyCategorizationChanges( + msg.options, + msg.notes, + msg.assignments, + msg.clusterNames, + msg.clusterTags, + (state) => { + panelState = state; + }, + ).catch((err) => { + log('Error in apply background task: ' + err); + panelState = { type: 'apply_error', message: err.message || String(err) }; + }).finally(() => { + operationInProgress = false; + }); + return panelState; + + case 'undo': + if (operationInProgress) { + return { type: 'undo_error', message: 'Another operation is already in progress.' }; + } + operationInProgress = true; + panelState = { type: 'undo_status', text: 'Initializing undo...' }; + undoCategorizationChanges((state) => { + panelState = state; + }).catch((err) => { + log('Error in undo background task: ' + err); + panelState = { type: 'undo_error', message: err.message || String(err) }; + }).finally(() => { + operationInProgress = false; + }); + return panelState; + + case 'cleanUpEmptyNotebooks': + if (operationInProgress) { + return { type: 'cleanup_error', message: 'Another operation is already in progress.' }; + } + operationInProgress = true; + panelState = { type: 'cleanup_status', text: 'Checking empty notebooks...' }; + cleanUpEmptyNotebooks((state) => { + panelState = state; + }).catch((err) => { + log('Error in cleanup background task: ' + err); + panelState = { type: 'cleanup_error', message: err.message || String(err) }; + }).finally(() => { + operationInProgress = false; + }); + return panelState; } }); diff --git a/src/types/panel.ts b/src/types/panel.ts index 4dbf646..24f079d 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -14,12 +14,45 @@ export interface ProgressState { skipped: number; } +export interface ApplyOptions { + method: 'tags' | 'notebooks' | 'both'; + parentNotebookName: string; +} + +export interface ApplyMessage { + type: 'apply'; + options: ApplyOptions; + notes: PanelNote[]; + assignments: number[]; + clusterNames: { [clusterId: number]: string }; + clusterTags: { [clusterId: number]: string[] }; +} + // Plugin → Webview export type PanelMessage = | { type: 'status'; text: string } | { type: 'progress'; current: number; total: number; cached: number; skipped: number } | { type: 'results'; strategies: BenchmarkResult[]; notes: PanelNote[] } - | { type: 'error'; message: string }; + | { type: 'error'; message: string } + | { type: 'apply_status'; text: string } + | { type: 'apply_progress'; current: number; total: number } + | { type: 'apply_complete' } + | { type: 'apply_error'; message: string } + | { type: 'undo_status'; text: string } + | { type: 'undo_progress'; current: number; total: number } + | { type: 'undo_complete' } + | { type: 'undo_error'; message: string } + | { type: 'cleanup_status'; text: string } + | { type: 'cleanup_complete'; message: string } + | { type: 'cleanup_error'; message: string }; // Webview → Plugin -export type WebviewMessage = { type: 'run' } | { type: 'poll' } | { type: 'openNote'; noteId: string }; +export type WebviewMessage = + | { type: 'run' } + | { type: 'poll' } + | { type: 'openNote'; noteId: string } + | { type: 'getSettings' } + | { type: 'updateSetting'; key: string; value: any } + | ApplyMessage + | { type: 'undo' } + | { type: 'cleanUpEmptyNotebooks' }; diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx index 696bbdf..78d2be0 100644 --- a/src/webview/context/AppStateContext.tsx +++ b/src/webview/context/AppStateContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { PanelNote, BenchmarkResult, ProgressState } from '../../types/panel'; +import { PanelNote, BenchmarkResult, ProgressState, ApplyOptions } from '../../types/panel'; const POLL_INTERVAL_MS = 500; @@ -18,6 +18,36 @@ interface AppStateContextType { changeStrategy: (index: number) => void; setView: (view: ViewType) => void; updateClusterName: (clusterId: number, newName: string) => void; + + // settings states + settings: { + metric: string; + parentNotebook: string; + changeLog: string; + }; + updateSetting: (key: string, value: any) => Promise; + fetchSettings: () => Promise; + + // apply states + isApplying: boolean; + applyProgress: { current: number; total: number }; + applyError: string | null; + applySuccess: boolean; + applyChanges: (options: ApplyOptions) => Promise; + + // undo states + isUndoing: boolean; + undoProgress: { current: number; total: number }; + undoError: string | null; + undoSuccess: boolean; + undoChanges: () => Promise; + hasChangeLog: boolean; + + // cleanup states + isCleaningUp: boolean; + cleanupError: string | null; + cleanupSuccess: string | null; + cleanUpNotebooks: () => Promise; } const AppStateContext = React.createContext(undefined); @@ -37,6 +67,28 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil const [selectedStrategyIndex, setSelectedStrategyIndex] = React.useState(0); const [activeView, setActiveView] = React.useState('idle'); + const [isApplying, setIsApplying] = React.useState(false); + const [applyProgress, setApplyProgress] = React.useState({ current: 0, total: 0 }); + const [applyError, setApplyError] = React.useState(null); + const [applySuccess, setApplySuccess] = React.useState(false); + + const [isUndoing, setIsUndoing] = React.useState(false); + const [undoProgress, setUndoProgress] = React.useState({ current: 0, total: 0 }); + const [undoError, setUndoError] = React.useState(null); + const [undoSuccess, setUndoSuccess] = React.useState(false); + + const [isCleaningUp, setIsCleaningUp] = React.useState(false); + const [cleanupError, setCleanupError] = React.useState(null); + const [cleanupSuccess, setCleanupSuccess] = React.useState(null); + + const [settings, setSettings] = React.useState({ + metric: 'cosine', + parentNotebook: 'AI Categorized Notes', + changeLog: '', + }); + + const hasChangeLog = !!settings.changeLog; + const pollIntervalRef = React.useRef(null); const stopPolling = React.useCallback(() => { @@ -46,6 +98,21 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil } }, []); + const fetchSettings = React.useCallback(async () => { + try { + const res = await webviewApi.postMessage({ type: 'getSettings' }); + if (res) { + setSettings({ + metric: (res as any)['categorization.metric'] || 'cosine', + parentNotebook: (res as any)['categorization.parentNotebook'] || 'AI Categorized Notes', + changeLog: (res as any)['categorization.changeLog'] || '', + }); + } + } catch (err) { + console.error('Failed to fetch settings:', err); + } + }, []); + const handlePollResponse = React.useCallback( (msg: any) => { if (!msg || !msg.type) return; @@ -79,9 +146,82 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setIsRunning(false); setError(msg.message || 'An unknown error occurred.'); break; + + case 'apply_status': + setIsApplying(true); + setApplyError(null); + setApplySuccess(false); + break; + + case 'apply_progress': + setIsApplying(true); + setApplyProgress({ + current: msg.current || 0, + total: msg.total || 0, + }); + break; + + case 'apply_complete': + stopPolling(); + setIsApplying(false); + setApplySuccess(true); + fetchSettings(); + break; + + case 'apply_error': + stopPolling(); + setIsApplying(false); + setApplyError(msg.message || 'An unknown error occurred.'); + break; + + case 'undo_status': + setIsUndoing(true); + setUndoError(null); + setUndoSuccess(false); + break; + + case 'undo_progress': + setIsUndoing(true); + setUndoProgress({ + current: msg.current || 0, + total: msg.total || 0, + }); + break; + + case 'undo_complete': + stopPolling(); + setIsUndoing(false); + setUndoSuccess(true); + fetchSettings(); + break; + + case 'undo_error': + stopPolling(); + setIsUndoing(false); + setUndoError(msg.message || 'An unknown error occurred.'); + break; + + case 'cleanup_status': + setIsCleaningUp(true); + setCleanupError(null); + setCleanupSuccess(null); + break; + + case 'cleanup_complete': + stopPolling(); + setIsCleaningUp(false); + setCleanupSuccess(msg.message || 'Cleaned up empty notebooks.'); + fetchSettings(); + break; + + case 'cleanup_error': + stopPolling(); + setIsCleaningUp(false); + setCleanupError(msg.message || 'Failed to clean up folders.'); + break; } }, - [stopPolling], + [stopPolling, fetchSettings], ); const startPolling = React.useCallback(() => { @@ -94,6 +234,10 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil }, POLL_INTERVAL_MS); }, [stopPolling, handlePollResponse]); + React.useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + React.useEffect(() => { return () => { stopPolling(); @@ -108,6 +252,12 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setNotes([]); setError(null); setActiveView('idle'); + setApplySuccess(false); + setApplyError(null); + setUndoSuccess(false); + setUndoError(null); + setCleanupSuccess(null); + setCleanupError(null); try { await webviewApi.postMessage({ type: 'run' }); } catch (err) { @@ -140,6 +290,92 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil }); }; + const updateSetting = async (key: string, value: any) => { + try { + await webviewApi.postMessage({ + type: 'updateSetting', + key, + value, + }); + const localKey = key.replace('categorization.', ''); + setSettings((prev) => ({ + ...prev, + [localKey]: value, + })); + } catch (err) { + console.error('Failed to update setting:', err); + } + }; + + const applyChanges = async (options: ApplyOptions) => { + const currentStrategy = strategies[selectedStrategyIndex]; + if (!currentStrategy) { + setApplyError('No active strategy selected.'); + return; + } + + setIsApplying(true); + setApplyProgress({ current: 0, total: notes.length }); + setApplyError(null); + setApplySuccess(false); + setUndoSuccess(false); + setUndoError(null); + setCleanupSuccess(null); + setCleanupError(null); + + try { + await webviewApi.postMessage({ + type: 'apply', + options, + notes, + assignments: currentStrategy.assignments, + clusterNames: currentStrategy.clusterNames || {}, + clusterTags: currentStrategy.tags || {}, + }); + startPolling(); + } catch (err) { + setApplyError('Failed to apply changes: ' + String(err)); + setIsApplying(false); + } + }; + + const undoChanges = async () => { + setIsUndoing(true); + setUndoProgress({ current: 0, total: 0 }); + setUndoError(null); + setUndoSuccess(false); + setApplySuccess(false); + setApplyError(null); + setCleanupSuccess(null); + setCleanupError(null); + + try { + await webviewApi.postMessage({ type: 'undo' }); + startPolling(); + } catch (err) { + setUndoError('Failed to start undo operation: ' + String(err)); + setIsUndoing(false); + } + }; + + const cleanUpNotebooks = async () => { + setIsCleaningUp(true); + setCleanupError(null); + setCleanupSuccess(null); + setApplySuccess(false); + setApplyError(null); + setUndoSuccess(false); + setUndoError(null); + + try { + await webviewApi.postMessage({ type: 'cleanUpEmptyNotebooks' }); + startPolling(); + } catch (err) { + setCleanupError('Failed to start cleanup: ' + String(err)); + setIsCleaningUp(false); + } + }; + return ( = ({ chil changeStrategy, setView, updateClusterName, + settings, + updateSetting, + fetchSettings, + isApplying, + applyProgress, + applyError, + applySuccess, + applyChanges, + isUndoing, + undoProgress, + undoError, + undoSuccess, + undoChanges, + hasChangeLog, + isCleaningUp, + cleanupError, + cleanupSuccess, + cleanUpNotebooks, }} > {children} diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index 0c008d0..4080225 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -5,8 +5,22 @@ import { StrategySection } from '../components/StrategySection'; import { ClusterCard } from '../components/ClusterCard'; export const DashboardPage: React.FC = () => { - const { isRunning, runPipeline, strategies, selectedStrategyIndex, changeStrategy, notes, updateClusterName } = - useAppState(); + const { + isRunning, + runPipeline, + strategies, + selectedStrategyIndex, + changeStrategy, + notes, + updateClusterName, + isApplying, + applyProgress, + applyError, + applySuccess, + applyChanges, + isUndoing, + isCleaningUp, + } = useAppState(); const selectedStrategy = strategies[selectedStrategyIndex]; @@ -30,6 +44,13 @@ export const DashboardPage: React.FC = () => { .map(Number) .sort((a, b) => clusters[b].length - clusters[a].length); + const handleApply = () => { + applyChanges({ + method: 'both', + parentNotebookName: '', + }); + }; + return (
@@ -41,10 +62,10 @@ export const DashboardPage: React.FC = () => { />
- {sortedClusterIds.map((id, idx) => ( + {sortedClusterIds.map((id) => ( { )}
+ + {selectedStrategy && ( +
+
+
Apply the new categorization
+
+ This will automatically move notes into their corresponding notebooks and apply the semantic + tags. +
+
+
+ +
+ + {isApplying && ( +
+ Applying changes: {applyProgress.current} / {applyProgress.total} notes processed... +
+ )} + + {applySuccess && ( +
Categorization applied successfully!
+ )} + + {applyError &&
Error: {applyError}
} +
+ )}
); }; diff --git a/src/webview/pages/HistoryPage.tsx b/src/webview/pages/HistoryPage.tsx index 849d150..12f8bb7 100644 --- a/src/webview/pages/HistoryPage.tsx +++ b/src/webview/pages/HistoryPage.tsx @@ -1,13 +1,104 @@ import * as React from 'react'; +import { useAppState } from '../context/AppStateContext'; export const HistoryPage: React.FC = () => { + const { + settings, + hasChangeLog, + isUndoing, + undoProgress, + undoError, + undoSuccess, + undoChanges, + isCleaningUp, + cleanupError, + cleanupSuccess, + cleanUpNotebooks, + } = useAppState(); + + let logDetails: any = null; + if (hasChangeLog && settings.changeLog) { + try { + logDetails = JSON.parse(settings.changeLog); + } catch (e) { + console.error('Failed to parse changelog JSON', e); + } + } + return (
-
Change Log
-
- A history of categorization runs and note changes will be displayed here once persistence is implemented - in a future update. -
+
Change Log / History
+ + {hasChangeLog && logDetails ? ( +
+
Active Categorization State
+
+
+ Applied At: {new Date(logDetails.timestamp).toLocaleString()} +
+
+ Method Used: {logDetails.method} +
+
+ Modified Items: {logDetails.notes?.length || 0} notes +
+
+ + + + + +
+ Note: Cleaning up empty original notebooks will delete previous notebooks that + became empty. Reverting changes after this will place restored notes in your default notebook + folder. +
+ + {isUndoing && ( +
+ Reverting changes: {undoProgress.current} / {undoProgress.total} notes processed... +
+ )} + + {isCleaningUp && ( +
Checking & cleaning up empty notebooks...
+ )} + + {undoSuccess &&
Reverted changes successfully!
} + + {cleanupSuccess &&
{cleanupSuccess}
} + + {undoError &&
Error: {undoError}
} + + {cleanupError &&
Error: {cleanupError}
} +
+ ) : ( + <> +
+ No active categorization state in the change log. Run the categorization pipeline and apply + changes to see history here. +
+ {undoSuccess && ( +
+ Reverted changes successfully! +
+ )} + {cleanupSuccess && ( +
+ {cleanupSuccess} +
+ )} + + )}
); }; diff --git a/src/webview/panel.css b/src/webview/panel.css index 0b7862c..dacdb90 100644 --- a/src/webview/panel.css +++ b/src/webview/panel.css @@ -486,3 +486,189 @@ body { max-width: 200px; width: 100%; } + +/* --- Apply & Revert Changes UI --- */ + +.apply-section { + padding: 16px; + background: var(--joplin-background-color); + border-top: 1px solid var(--joplin-divider-color); + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.apply-header { + display: flex; + flex-direction: column; + gap: 4px; +} + +.apply-title { + font-size: 0.92em; + font-weight: 600; +} + +.apply-subtitle { + font-size: 0.78em; + opacity: 0.6; + line-height: 1.4; +} + +.apply-action-row { + display: flex; + width: 100%; +} + +.btn-apply-primary { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font-size: 0.85em; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + height: 34px; +} + +.btn-apply-primary:hover { + opacity: 0.9; +} + +.btn-apply-primary:active { + transform: scale(0.99); +} +.btn-apply-primary:disabled { + opacity: 0.55; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.status-banner-apply { + padding: 8px 12px; + border-radius: 6px; + font-size: 0.8em; + font-weight: 500; + margin-top: 4px; +} + +.status-banner-apply.error { + background: rgba(235, 87, 87, 0.15); + color: #eb5757; + border: 1px solid rgba(235, 87, 87, 0.3); +} + +.status-banner-apply.success { + background: rgba(39, 174, 96, 0.15); + color: #27ae60; + border: 1px solid rgba(39, 174, 96, 0.3); +} + +.status-banner-apply.info { + background: rgba(74, 144, 217, 0.15); + color: var(--accent); + border: 1px solid rgba(74, 144, 217, 0.3); +} + +.undo-history-card { + background: var(--joplin-background-color-hover); + border: 1px solid var(--joplin-divider-color); + border-radius: 8px; + padding: 16px; + width: 100%; + max-width: 400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; +} + +.undo-history-title { + font-weight: 600; + font-size: 0.95em; + border-bottom: 1px solid var(--joplin-divider-color); + padding-bottom: 6px; +} + +.undo-history-detail { + font-size: 0.82em; + line-height: 1.6; + opacity: 0.8; +} + +.btn-undo { + width: 100%; + padding: 8px 14px; + border: 1px solid #eb5757; + border-radius: 6px; + background: transparent; + color: #eb5757; + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.btn-undo:hover { + background: #eb5757; + color: #fff; +} + +.btn-undo:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-cleanup { + width: 100%; + padding: 8px 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: transparent; + color: var(--accent); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.btn-cleanup:hover { + background: var(--accent); + color: #fff; +} + +.btn-cleanup:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cleanup-note { + font-size: 0.78em; + opacity: 0.65; + line-height: 1.4; + margin-top: 10px; + border-top: 1px dashed var(--joplin-divider-color); + padding-top: 10px; +} + +/* --- Focus-visible for keyboard accessibility --- */ + +.btn-run:focus-visible, +.btn-apply-primary:focus-visible, +.btn-undo:focus-visible, +.btn-cleanup:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + From 9406a000910e7fa2e6c0848afa50f08028faa0b6 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Tue, 7 Jul 2026 18:06:58 +0530 Subject: [PATCH 2/4] style: fix formatting checks for CI --- src/commands/applyChanges.ts | 8 ++++-- src/index.ts | 42 ++++++++++++++++------------- src/webview/pages/DashboardPage.tsx | 6 ++++- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/commands/applyChanges.ts b/src/commands/applyChanges.ts index f0d5539..ec62db9 100644 --- a/src/commands/applyChanges.ts +++ b/src/commands/applyChanges.ts @@ -58,7 +58,11 @@ export async function applyCategorizationChanges( const allFoldersList: any[] = []; let folderPage = 1; while (folderPage <= MAX_PAGES) { - const res = await joplin.data.get(['folders'], { page: folderPage, limit: 100, fields: ['id', 'title', 'parent_id'] }); + const res = await joplin.data.get(['folders'], { + page: folderPage, + limit: 100, + fields: ['id', 'title', 'parent_id'], + }); allFoldersList.push(...res.items); if (!res.has_more) break; folderPage++; @@ -173,7 +177,7 @@ export async function applyCategorizationChanges( }); originalParentId = noteObj.parent_id || ''; noteTitle = noteObj.title || ''; - noteBody = needsBody ? (noteObj.body || '') : ''; + noteBody = needsBody ? noteObj.body || '' : ''; } catch (fetchErr) { log(`Error fetching note data for ${note.noteId}: ${fetchErr}`); continue; diff --git a/src/index.ts b/src/index.ts index 7f54065..bd13cc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -134,12 +134,14 @@ joplin.plugins.register({ (state) => { panelState = state; }, - ).catch((err) => { - log('Error in apply background task: ' + err); - panelState = { type: 'apply_error', message: err.message || String(err) }; - }).finally(() => { - operationInProgress = false; - }); + ) + .catch((err) => { + log('Error in apply background task: ' + err); + panelState = { type: 'apply_error', message: err.message || String(err) }; + }) + .finally(() => { + operationInProgress = false; + }); return panelState; case 'undo': @@ -150,12 +152,14 @@ joplin.plugins.register({ panelState = { type: 'undo_status', text: 'Initializing undo...' }; undoCategorizationChanges((state) => { panelState = state; - }).catch((err) => { - log('Error in undo background task: ' + err); - panelState = { type: 'undo_error', message: err.message || String(err) }; - }).finally(() => { - operationInProgress = false; - }); + }) + .catch((err) => { + log('Error in undo background task: ' + err); + panelState = { type: 'undo_error', message: err.message || String(err) }; + }) + .finally(() => { + operationInProgress = false; + }); return panelState; case 'cleanUpEmptyNotebooks': @@ -166,12 +170,14 @@ joplin.plugins.register({ panelState = { type: 'cleanup_status', text: 'Checking empty notebooks...' }; cleanUpEmptyNotebooks((state) => { panelState = state; - }).catch((err) => { - log('Error in cleanup background task: ' + err); - panelState = { type: 'cleanup_error', message: err.message || String(err) }; - }).finally(() => { - operationInProgress = false; - }); + }) + .catch((err) => { + log('Error in cleanup background task: ' + err); + panelState = { type: 'cleanup_error', message: err.message || String(err) }; + }) + .finally(() => { + operationInProgress = false; + }); return panelState; } }); diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index 4080225..4ee76d0 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -87,7 +87,11 @@ export const DashboardPage: React.FC = () => {
-
From e4afcca1ddd223a4305031eebef17e45df8f9767 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Tue, 7 Jul 2026 21:59:03 +0530 Subject: [PATCH 3/4] fix: resolve copilot review issues --- src/commands/applyChanges.ts | 44 ++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/commands/applyChanges.ts b/src/commands/applyChanges.ts index ec62db9..89cdaa1 100644 --- a/src/commands/applyChanges.ts +++ b/src/commands/applyChanges.ts @@ -192,10 +192,10 @@ export async function applyCategorizationChanges( if (mainTagId) { try { await joplin.data.post(['tags', mainTagId, 'notes'], null, { id: note.noteId }); + addedTagIds.push(mainTagId); } catch (tagErr) { log(`Tag ${mainTagId} may already be on note ${note.noteId}: ${tagErr}`); } - addedTagIds.push(mainTagId); } // Get all extracted specific tags for this cluster @@ -211,13 +211,13 @@ export async function applyCategorizationChanges( const { id: tagId, created } = await getOrCreateTagOptimized(tagText); try { await joplin.data.post(['tags', tagId, 'notes'], null, { id: note.noteId }); + addedTagIds.push(tagId); + if (created) { + createdTagIds.push(tagId); + } } catch (tagErr) { log(`Tag ${tagId} may already be on note ${note.noteId}: ${tagErr}`); } - addedTagIds.push(tagId); - if (created) { - createdTagIds.push(tagId); - } } } @@ -237,9 +237,13 @@ export async function applyCategorizationChanges( } if (targetFolderId && targetFolderId !== originalParentId) { - await joplin.data.put(['notes', note.noteId], null, { parent_id: targetFolderId }); - changeEntry.originalParentId = originalParentId; - modified = true; + try { + await joplin.data.put(['notes', note.noteId], null, { parent_id: targetFolderId }); + changeEntry.originalParentId = originalParentId; + modified = true; + } catch (moveErr) { + log(`Failed to move note ${note.noteId} to folder ${targetFolderId}: ${moveErr}`); + } } } @@ -328,11 +332,33 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess }); } - // 2. Delete created folders if they exist + // 2. Delete created folders if they exist (check for notes AND subfolders) if (changeLog.createdFolderIds && changeLog.createdFolderIds.length > 0) { setPanelState({ type: 'undo_status', text: 'Deleting created notebooks...' }); + + // Fetch all folders once to check for subfolders in memory + const undoAllFolders: any[] = []; + let undoPage = 1; + while (undoPage <= 500) { + const res = await joplin.data.get(['folders'], { + page: undoPage, + limit: 100, + fields: ['id', 'parent_id'], + }); + undoAllFolders.push(...res.items); + if (!res.has_more) break; + undoPage++; + } + const undoParentIds = new Set(undoAllFolders.map((f: any) => f.parent_id).filter((pid) => !!pid)); + for (const folderId of changeLog.createdFolderIds) { try { + // Skip if folder has subfolders + if (undoParentIds.has(folderId)) { + log(`Undo: skipping folder ${folderId} — has subfolders`); + continue; + } + // Skip if folder still has notes const notesInFolder = await joplin.data.get(['folders', folderId, 'notes'], { limit: 1 }); if (notesInFolder.items.length > 0) { log(`Undo: skipping non-empty folder ${folderId}`); From 66f8bc8ad9e67f9a2c2446f3040f87c621ee93db Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 8 Jul 2026 21:50:31 +0530 Subject: [PATCH 4/4] style: ui refinement, clean stylesheet structure, and visual polish --- src/webview/components/ClusterCard.tsx | 14 + src/webview/components/EmptyState.tsx | 14 + src/webview/components/Header.tsx | 31 +- src/webview/pages/HistoryPage.tsx | 40 +- src/webview/panel.css | 822 ++++++++++++++----------- 5 files changed, 562 insertions(+), 359 deletions(-) diff --git a/src/webview/components/ClusterCard.tsx b/src/webview/components/ClusterCard.tsx index 03b06c7..144ca79 100644 --- a/src/webview/components/ClusterCard.tsx +++ b/src/webview/components/ClusterCard.tsx @@ -113,6 +113,20 @@ export const ClusterCard: React.FC = ({ title, noteIndices, no if (!note) return null; return (
handleNoteClick(note.noteId)}> + + + + {note.title || 'Untitled'}
); diff --git a/src/webview/components/EmptyState.tsx b/src/webview/components/EmptyState.tsx index e7004fd..bafae78 100644 --- a/src/webview/components/EmptyState.tsx +++ b/src/webview/components/EmptyState.tsx @@ -3,6 +3,20 @@ import * as React from 'react'; export const EmptyState: React.FC = () => { return (
+ + + +
No categories yet
Click Run to categorize your notes using on-device AI.
diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx index de88e6c..0a55644 100644 --- a/src/webview/components/Header.tsx +++ b/src/webview/components/Header.tsx @@ -8,9 +8,36 @@ interface HeaderProps { export const Header: React.FC = ({ isRunning, onRun }) => { return (
-
Note Categorizer
+
+ + + + + Note Categorizer +
); diff --git a/src/webview/pages/HistoryPage.tsx b/src/webview/pages/HistoryPage.tsx index 12f8bb7..47b6a73 100644 --- a/src/webview/pages/HistoryPage.tsx +++ b/src/webview/pages/HistoryPage.tsx @@ -26,9 +26,7 @@ export const HistoryPage: React.FC = () => { } return ( -
-
Change Log / History
- +
{hasChangeLog && logDetails ? (
Active Categorization State
@@ -37,7 +35,12 @@ export const HistoryPage: React.FC = () => { Applied At: {new Date(logDetails.timestamp).toLocaleString()}
- Method Used: {logDetails.method} + Method Used:{' '} + {logDetails.method === 'both' + ? 'Notebooks & Tags' + : logDetails.method === 'notebooks' + ? 'Notebooks Only' + : 'Tags Only'}
Modified Items: {logDetails.notes?.length || 0} notes @@ -82,22 +85,43 @@ export const HistoryPage: React.FC = () => { {cleanupError &&
Error: {cleanupError}
}
) : ( - <> +
+ + + + +
No history found
No active categorization state in the change log. Run the categorization pipeline and apply changes to see history here.
{undoSuccess && ( -
+
Reverted changes successfully!
)} {cleanupSuccess && ( -
+
{cleanupSuccess}
)} - +
)}
); diff --git a/src/webview/panel.css b/src/webview/panel.css index dacdb90..c4a217a 100644 --- a/src/webview/panel.css +++ b/src/webview/panel.css @@ -1,38 +1,90 @@ -:root { - --accent: #4a90d9; -} +/* RESET & BASE */ -* { +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html, -body, -#root { - height: 100vh; -} - body { - font-family: var(--joplin-font-family); - font-size: var(--joplin-font-size); + font-family: var(--joplin-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif); + font-size: var(--joplin-font-size, 13px); color: var(--joplin-color); background-color: var(--joplin-background-color); line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; overflow: hidden; } +/* LAYOUT SHELL */ + +.panel-container { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.panel-main { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 16px; +} + +.page-empty-state, +.page-dashboard, +.page-history { + display: flex; + flex-direction: column; +} + +/* NAVIGATION TABS */ + +.panel-navigation { + display: flex; + gap: 0; + background: var(--joplin-background-color); + border-bottom: 1px solid var(--joplin-divider-color); + padding: 0 16px; +} + +.nav-tab { + position: relative; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--joplin-color); + opacity: 0.5; + font-family: inherit; + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + transition: opacity 150ms ease, border-color 150ms ease; + letter-spacing: 0.01em; +} + +.nav-tab:hover { + opacity: 0.8; +} + +.nav-tab.active { + opacity: 1; + font-weight: 600; + border-bottom-color: var(--joplin-color); +} + +/* HEADER */ + .panel-header { display: flex; align-items: center; justify-content: space-between; - padding: 12px 14px; + padding-bottom: 16px; + margin-bottom: 16px; border-bottom: 1px solid var(--joplin-divider-color); - background: var(--joplin-background-color); - position: sticky; - top: 0; - z-index: 10; } .panel-header-title { @@ -40,43 +92,105 @@ body { align-items: center; gap: 8px; font-weight: 600; - font-size: 1.05em; + font-size: 1em; + letter-spacing: -0.01em; +} + +.panel-header-title svg { + opacity: 0.5; } -.btn-run { +/* BUTTONS — Secondary (ghost) */ + +.btn-run, +.btn-undo, +.btn-cleanup { display: inline-flex; align-items: center; + justify-content: center; gap: 5px; padding: 6px 14px; - border: none; + border: 1px solid var(--joplin-divider-color); + border-radius: 6px; + background: transparent; + color: var(--joplin-color); + font-size: 0.82em; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease; + white-space: nowrap; +} + +.btn-run:hover, +.btn-undo:hover, +.btn-cleanup:hover { + background: color-mix(in srgb, var(--joplin-color) 5%, var(--joplin-background-color)); + border-color: color-mix(in srgb, var(--joplin-color) 18%, var(--joplin-divider-color)); +} + +.btn-run:active, +.btn-undo:active, +.btn-cleanup:active { + background: color-mix(in srgb, var(--joplin-color) 8%, var(--joplin-background-color)); +} + +.btn-run:disabled, +.btn-undo:disabled, +.btn-cleanup:disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} + +.btn-run svg, +.btn-undo svg, +.btn-cleanup svg { + opacity: 0.6; +} + +/* BUTTONS — Primary (solid) */ + +.btn-apply-primary { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 8px 16px; + border: 1px solid transparent; border-radius: 6px; - background: var(--accent); - color: #fff; + background: var(--joplin-color); + color: var(--joplin-background-color); font-size: 0.85em; font-weight: 600; font-family: inherit; cursor: pointer; - transition: opacity 0.2s, transform 0.15s; + transition: opacity 150ms ease; + letter-spacing: 0.01em; } -.btn-run:hover { - opacity: 0.88; - transform: translateY(-1px); +.btn-apply-primary:hover:not(:disabled) { + opacity: 0.85; } -.btn-run:active { - transform: translateY(0); +.btn-apply-primary:active:not(:disabled) { + opacity: 0.75; } -.btn-run:disabled { - opacity: 0.5; +.btn-apply-primary:disabled { + opacity: 0.25; cursor: not-allowed; - transform: none; + pointer-events: none; } +/* PROGRESS BAR */ + .status-bar { - padding: 10px 14px; - border-bottom: 1px solid var(--joplin-divider-color); + padding: 14px; + background: color-mix(in srgb, var(--joplin-color) 3%, var(--joplin-background-color)); + border: 1px solid var(--joplin-divider-color); + border-radius: 8px; + margin-bottom: 16px; display: none; } @@ -85,38 +199,43 @@ body { } .status-text { - font-size: 0.85em; - opacity: 0.75; - margin-bottom: 6px; + font-size: 0.82em; + font-weight: 500; + margin-bottom: 10px; + opacity: 0.8; } .progress-container { width: 100%; - height: 6px; + height: 3px; background: var(--joplin-divider-color); - border-radius: 3px; + border-radius: 4px; overflow: hidden; - margin-bottom: 6px; + margin-bottom: 8px; } .progress-fill { height: 100%; - background: var(--accent); - border-radius: 3px; + background: var(--joplin-color); + opacity: 0.7; width: 0%; - transition: width 0.35s ease; + border-radius: 4px; + transition: width 300ms ease; } .progress-label { - font-size: 0.8em; - opacity: 0.6; + font-size: 0.75em; + opacity: 0.45; display: flex; justify-content: space-between; } +/* STRATEGY SECTION */ + .strategy-section { - padding: 10px 14px; + padding: 14px 0; border-bottom: 1px solid var(--joplin-divider-color); + margin-bottom: 12px; display: none; } @@ -127,44 +246,44 @@ body { .strategy-selector-row { display: flex; align-items: center; - gap: 8px; + gap: 10px; margin-bottom: 8px; } .strategy-selector-label { font-size: 0.82em; font-weight: 600; + opacity: 0.7; white-space: nowrap; } .strategy-select { flex: 1; - padding: 4px 8px; + padding: 5px 8px; border: 1px solid var(--joplin-divider-color); - border-radius: 5px; + border-radius: 6px; background: var(--joplin-background-color); color: var(--joplin-color); font-size: 0.82em; font-family: inherit; cursor: pointer; - transition: border-color 0.2s; + outline: none; + transition: border-color 150ms ease; } -.strategy-select:hover, .strategy-select:focus { - outline: none; - border-color: var(--accent); + border-color: color-mix(in srgb, var(--joplin-color) 30%, var(--joplin-divider-color)); } .strategy-score { - font-size: 0.82em; - opacity: 0.7; - margin-bottom: 6px; + font-size: 0.78em; + opacity: 0.5; + margin-bottom: 10px; } .strategy-score strong { - color: var(--accent); opacity: 1; + font-weight: 600; } .strategy-pills { @@ -175,46 +294,78 @@ body { .strategy-pill { display: inline-block; - padding: 2px 8px; - border-radius: 10px; - font-size: 0.75em; - background: var(--joplin-divider-color); - opacity: 0.8; + padding: 3px 10px; + border-radius: 100px; + font-size: 0.72em; + font-weight: 500; + background: color-mix(in srgb, var(--joplin-color) 4%, var(--joplin-background-color)); + border: 1px solid var(--joplin-divider-color); + color: var(--joplin-color); + opacity: 0.55; white-space: nowrap; + transition: opacity 150ms ease, border-color 150ms ease; + cursor: default; } .strategy-pill.active { - background: var(--accent); - color: #fff; opacity: 1; font-weight: 600; + border-color: color-mix(in srgb, var(--joplin-color) 25%, var(--joplin-divider-color)); + background: color-mix(in srgb, var(--joplin-color) 8%, var(--joplin-background-color)); } +/* CLUSTER LIST */ + .cluster-list { - padding: 6px 0; display: none; + flex-direction: column; + gap: 0; + margin-bottom: 8px; } .cluster-list.visible { - display: block; + display: flex; } +/* INDIVIDUAL CLUSTER CARD */ + .cluster-card { - border-bottom: 1px solid var(--joplin-divider-color); + border: 1px solid var(--joplin-divider-color); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; + transition: border-color 150ms ease; } +.cluster-card:hover { + border-color: color-mix(in srgb, var(--joplin-color) 12%, var(--joplin-divider-color)); +} + +.cluster-card.noise { + opacity: 0.6; +} + +/* CLUSTER HEADER ROW */ + .cluster-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - padding: 10px 14px; + padding: 12px 14px; cursor: pointer; - transition: background-color 0.15s; user-select: none; + transition: background 150ms ease; + height: 64px; + box-sizing: border-box; +} + +.cluster-card.expanded .cluster-header { + height: auto; + min-height: 64px; } .cluster-header:hover { - background: var(--joplin-background-color-hover); + background: color-mix(in srgb, var(--joplin-color) 3%, var(--joplin-background-color)); } .cluster-header-left { @@ -226,122 +377,194 @@ body { flex: 1; } -.cluster-tags { +.cluster-title-container { display: flex; - flex-wrap: wrap; + align-items: center; gap: 6px; - margin-top: 4px; + min-width: 0; + width: 100%; } -.cluster-tag { - font-size: 0.8em; +.cluster-title { + font-size: 0.88em; font-weight: 600; - background: rgba(74, 144, 217, 0.14); /* fallback for older Chromium */ - background: color-mix(in srgb, var(--accent) 14%, transparent); - color: var(--accent); - padding: 2px 8px; - border-radius: 6px; white-space: nowrap; - letter-spacing: 0.2px; - transition: all 0.15s ease; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; +} + +.cluster-edit-btn { + background: transparent; + border: none; + padding: 2px; + cursor: pointer; + color: var(--joplin-color); + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 150ms ease; } -.cluster-tag:hover { - background: rgba(74, 144, 217, 0.22); /* fallback for older Chromium */ - background: color-mix(in srgb, var(--accent) 22%, transparent); +.cluster-header:hover .cluster-edit-btn { + opacity: 0.35; } -.cluster-title { - font-size: 0.9em; +.cluster-edit-btn:hover { + opacity: 0.7 !important; +} + +.cluster-title-input { + font-size: 0.88em; font-weight: 600; - white-space: nowrap; + font-family: inherit; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--joplin-color) 20%, var(--joplin-divider-color)); + border-radius: 4px; + background: var(--joplin-background-color); + color: var(--joplin-color); + outline: none; + width: 140px; +} + +/* TAGS */ + +.cluster-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-left: 0; + max-height: 20px; overflow: hidden; - text-overflow: ellipsis; + width: 100%; +} + +.cluster-card.expanded .cluster-tags { + max-height: none; + overflow: visible; +} + +.cluster-tag { + font-size: 0.7em; + font-weight: 500; + color: var(--joplin-color); + opacity: 0.5; + background: color-mix(in srgb, var(--joplin-color) 4%, var(--joplin-background-color)); + padding: 1px 7px; + border: 1px solid color-mix(in srgb, var(--joplin-divider-color) 60%, transparent); + border-radius: 100px; + white-space: nowrap; } +/* COUNT & CHEVRON */ + .cluster-count { - font-size: 0.78em; - opacity: 0.55; + font-size: 0.75em; + opacity: 0.4; white-space: nowrap; flex-shrink: 0; + margin-left: 8px; + margin-top: 2px; + font-weight: 500; } .cluster-chevron { - width: 0; - height: 0; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - border-left: 5px solid currentColor; - opacity: 0.5; - transition: transform 0.2s ease; + width: 7px; + height: 7px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 200ms ease; + opacity: 0.3; flex-shrink: 0; - margin-left: 4px; + margin-left: 8px; + margin-right: 2px; + margin-top: 5px; } .cluster-card.expanded .cluster-chevron { - transform: rotate(90deg); + transform: rotate(45deg); } +/* EXPANDED NOTE LIST */ + .cluster-notes { max-height: 0; overflow: hidden; - transition: max-height 0.3s ease; + transition: max-height 250ms ease-out; } .cluster-card.expanded .cluster-notes { - max-height: 5000px; + max-height: 2000px; + border-top: 1px solid var(--joplin-divider-color); } .note-item { display: flex; align-items: center; - padding: 6px 14px 6px 28px; + gap: 8px; + padding: 7px 14px 7px 28px; cursor: pointer; - transition: background-color 0.12s; - font-size: 0.84em; + font-size: 0.82em; + transition: background 100ms ease; } .note-item:hover { - background: var(--joplin-background-color-hover); + background: color-mix(in srgb, var(--joplin-color) 3%, var(--joplin-background-color)); +} + +.note-item:last-child { + border-radius: 0 0 7px 7px; } .note-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + opacity: 0.8; } -.cluster-card.noise .cluster-title { - opacity: 0.7; -} +/* EMPTY STATES */ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 48px 24px; + padding: 48px 16px; text-align: center; } +.empty-illustration { + color: var(--joplin-color); + opacity: 0.2; + margin-bottom: 16px; +} + .empty-title { - font-size: 1em; + font-size: 0.95em; font-weight: 600; margin-bottom: 6px; + letter-spacing: -0.01em; } .empty-subtitle { - font-size: 0.84em; - opacity: 0.55; + font-size: 0.8em; + opacity: 0.45; line-height: 1.5; - max-width: 280px; + max-width: 240px; } +/* ERROR BANNER */ + .error-banner { - padding: 10px 14px; - background: var(--joplin-color-error); - color: #fff; - font-size: 0.84em; + padding: 10px 16px; + background: color-mix(in srgb, #dc2626 10%, var(--joplin-background-color)); + color: #dc2626; + border-bottom: 1px solid color-mix(in srgb, #dc2626 20%, var(--joplin-divider-color)); + font-size: 0.82em; + font-weight: 500; display: none; } @@ -349,326 +572,227 @@ body { display: block; } -/* --- Container & Navigation Layout --- */ +/* APPLY SECTION */ -.panel-container { +.apply-section { + padding: 16px 0 0 0; + border-top: 1px solid var(--joplin-divider-color); display: flex; flex-direction: column; - height: 100vh; - overflow: hidden; -} - -.panel-main { - flex: 1; - overflow-y: auto; - min-height: 0; + gap: 12px; + margin-top: 8px; } -.panel-navigation { +.apply-header { display: flex; - background: var(--joplin-background-color); - border-bottom: 1px solid var(--joplin-divider-color); - width: 100%; + flex-direction: column; + gap: 4px; } -.nav-tab { - flex: 1; - padding: 10px 14px; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--joplin-color); - opacity: 0.65; - font-family: inherit; - font-size: 0.85em; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - text-align: center; +.apply-title { + font-size: 0.88em; + font-weight: 600; + letter-spacing: -0.01em; } -.nav-tab:hover { - opacity: 0.9; - background: var(--joplin-background-color-hover); +.apply-subtitle { + font-size: 0.78em; + opacity: 0.45; + line-height: 1.45; } -.nav-tab.active { - color: var(--accent); - border-bottom-color: var(--accent); - font-weight: 600; +.apply-action-row { + display: flex; } -/* --- Scrollbar Customization --- */ +/* STATUS BANNERS */ -::-webkit-scrollbar { - width: 6px; - height: 6px; +.status-banner-apply { + padding: 8px 12px; + border-radius: 6px; + font-size: 0.8em; + font-weight: 500; + margin-top: 8px; + border: 1px solid var(--joplin-divider-color); } -::-webkit-scrollbar-track { - background: transparent; +.status-banner-apply.info { + background: color-mix(in srgb, var(--joplin-color) 3%, var(--joplin-background-color)); + color: var(--joplin-color); + opacity: 0.75; } -::-webkit-scrollbar-thumb { - background: var(--joplin-divider-color); - border-radius: 3px; - transition: background-color 0.2s ease; +.status-banner-apply.success { + background: color-mix(in srgb, #16a34a 6%, var(--joplin-background-color)); + color: #16a34a; + border-color: color-mix(in srgb, #16a34a 15%, var(--joplin-divider-color)); } -::-webkit-scrollbar-thumb:hover { - background: var(--accent); +.status-banner-apply.error { + background: color-mix(in srgb, #dc2626 6%, var(--joplin-background-color)); + color: #dc2626; + border-color: color-mix(in srgb, #dc2626 15%, var(--joplin-divider-color)); } -/* --- Config Card (Settings Page) --- */ +/* HISTORY PAGE */ -.config-card { - text-align: left; - font-size: 0.82em; - opacity: 0.7; +.undo-history-card { border: 1px solid var(--joplin-divider-color); - border-radius: 6px; - padding: 12px; + border-radius: 8px; + padding: 16px; width: 100%; - max-width: 300px; - background: var(--joplin-background-color-hover); + max-width: 320px; + margin: 8px auto 0 auto; + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; } -.config-card-header { +.undo-history-title { font-weight: 600; - margin-bottom: 6px; + font-size: 0.88em; + padding-bottom: 8px; border-bottom: 1px solid var(--joplin-divider-color); - padding-bottom: 4px; + letter-spacing: -0.01em; } -.config-card-item { - margin: 4px 0; +.undo-history-detail { + font-size: 0.8em; + line-height: 1.5; + opacity: 0.75; + display: flex; + flex-direction: column; + gap: 4px; } -/* --- Cluster Title Editing --- */ - -.cluster-title-container { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; +.undo-history-detail strong { + opacity: 1; + font-weight: 600; } -.cluster-edit-btn { - background: transparent; - border: none; - padding: 2px 4px; - border-radius: 4px; - cursor: pointer; - color: var(--joplin-color); +.cleanup-note { + font-size: 0.75em; opacity: 0.4; - display: inline-flex; - align-items: center; - justify-content: center; - transition: opacity 0.15s, background-color 0.15s; + line-height: 1.4; + margin-top: 4px; + border-top: 1px solid var(--joplin-divider-color); + padding-top: 8px; } -.cluster-edit-btn:hover { - opacity: 0.9; - background-color: var(--joplin-divider-color); -} +/* SETTINGS PAGE */ -.cluster-title-input { - font-size: 0.9em; - font-weight: 600; - font-family: inherit; - padding: 2px 6px; - border: 1px solid var(--accent); - border-radius: 4px; - background: var(--joplin-background-color); - color: var(--joplin-color); - outline: none; - min-width: 120px; - max-width: 200px; +.settings-container { + display: flex; + flex-direction: column; + gap: 16px; width: 100%; + text-align: left; } -/* --- Apply & Revert Changes UI --- */ - -.apply-section { - padding: 16px; - background: var(--joplin-background-color); - border-top: 1px solid var(--joplin-divider-color); +.settings-group { display: flex; flex-direction: column; - gap: 12px; - margin-top: 8px; + gap: 6px; + padding-bottom: 14px; + border-bottom: 1px solid var(--joplin-divider-color); } -.apply-header { - display: flex; - flex-direction: column; - gap: 4px; +.settings-group:last-child { + border-bottom: none; } -.apply-title { - font-size: 0.92em; +.settings-label { + font-size: 0.85em; font-weight: 600; + letter-spacing: -0.01em; } -.apply-subtitle { +.settings-desc { font-size: 0.78em; - opacity: 0.6; - line-height: 1.4; + opacity: 0.45; + line-height: 1.45; } -.apply-action-row { +.settings-input-row { display: flex; - width: 100%; -} - -.btn-apply-primary { - width: 100%; - display: inline-flex; align-items: center; - justify-content: center; - padding: 8px 16px; - border: none; - border-radius: 6px; - background: var(--accent); - color: #fff; - font-size: 0.85em; - font-weight: 600; - font-family: inherit; - cursor: pointer; - transition: opacity 0.2s, transform 0.1s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - height: 34px; -} - -.btn-apply-primary:hover { - opacity: 0.9; -} - -.btn-apply-primary:active { - transform: scale(0.99); -} -.btn-apply-primary:disabled { - opacity: 0.55; - cursor: not-allowed; - box-shadow: none; - transform: none; + gap: 8px; + margin-top: 2px; } -.status-banner-apply { - padding: 8px 12px; +.settings-input-row select, +.settings-input-row input[type="text"] { + flex: 1; + padding: 5px 8px; + border: 1px solid var(--joplin-divider-color); border-radius: 6px; - font-size: 0.8em; - font-weight: 500; - margin-top: 4px; -} - -.status-banner-apply.error { - background: rgba(235, 87, 87, 0.15); - color: #eb5757; - border: 1px solid rgba(235, 87, 87, 0.3); -} - -.status-banner-apply.success { - background: rgba(39, 174, 96, 0.15); - color: #27ae60; - border: 1px solid rgba(39, 174, 96, 0.3); + background: var(--joplin-background-color); + color: var(--joplin-color); + font-size: 0.82em; + font-family: inherit; + outline: none; + transition: border-color 150ms ease; } -.status-banner-apply.info { - background: rgba(74, 144, 217, 0.15); - color: var(--accent); - border: 1px solid rgba(74, 144, 217, 0.3); +.settings-input-row select:focus, +.settings-input-row input[type="text"]:focus { + border-color: color-mix(in srgb, var(--joplin-color) 30%, var(--joplin-divider-color)); } -.undo-history-card { - background: var(--joplin-background-color-hover); +.config-card { + font-size: 0.78em; + opacity: 0.6; border: 1px solid var(--joplin-divider-color); border-radius: 8px; - padding: 16px; - width: 100%; - max-width: 400px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 12px; - text-align: left; + padding: 12px 14px; + background: color-mix(in srgb, var(--joplin-color) 2%, var(--joplin-background-color)); + line-height: 1.55; } -.undo-history-title { +.config-card-header { font-weight: 600; - font-size: 0.95em; - border-bottom: 1px solid var(--joplin-divider-color); - padding-bottom: 6px; + opacity: 1; + margin-bottom: 6px; } -.undo-history-detail { - font-size: 0.82em; - line-height: 1.6; - opacity: 0.8; +.config-card-item { + margin: 3px 0; } -.btn-undo { - width: 100%; - padding: 8px 14px; - border: 1px solid #eb5757; - border-radius: 6px; - background: transparent; - color: #eb5757; - font-size: 0.85em; +.config-card-item strong { font-weight: 600; - cursor: pointer; - transition: background-color 0.2s, color 0.2s; + opacity: 1; } -.btn-undo:hover { - background: #eb5757; - color: #fff; -} +/* SCROLLBAR */ -.btn-undo:disabled { - opacity: 0.5; - cursor: not-allowed; +::-webkit-scrollbar { + width: 5px; + height: 5px; } -.btn-cleanup { - width: 100%; - padding: 8px 14px; - border: 1px solid var(--accent); - border-radius: 6px; +::-webkit-scrollbar-track { background: transparent; - color: var(--accent); - font-size: 0.85em; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s, color 0.2s; } -.btn-cleanup:hover { - background: var(--accent); - color: #fff; -} - -.btn-cleanup:disabled { - opacity: 0.5; - cursor: not-allowed; +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--joplin-color) 10%, transparent); + border-radius: 100px; } -.cleanup-note { - font-size: 0.78em; - opacity: 0.65; - line-height: 1.4; - margin-top: 10px; - border-top: 1px dashed var(--joplin-divider-color); - padding-top: 10px; +::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--joplin-color) 20%, transparent); } -/* --- Focus-visible for keyboard accessibility --- */ +/* FOCUS RINGS */ .btn-run:focus-visible, .btn-apply-primary:focus-visible, .btn-undo:focus-visible, -.btn-cleanup:focus-visible { - outline: 2px solid var(--accent); +.btn-cleanup:focus-visible, +.strategy-select:focus-visible, +.nav-tab:focus-visible { + outline: 2px solid color-mix(in srgb, var(--joplin-color) 40%, transparent); outline-offset: 2px; } -