diff --git a/package.json b/package.json index 68faee6..41643b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-note-categorization", - "version": "0.1.4", + "version": "0.1.5", "scripts": { "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", diff --git a/src/commands/applyChanges.ts b/src/commands/applyChanges.ts index 3d2ba6a..61e152a 100644 --- a/src/commands/applyChanges.ts +++ b/src/commands/applyChanges.ts @@ -9,7 +9,7 @@ import { deleteCreatedTags, } from './applyTags'; import { - fetchExistingFolders, + fetchAllFolders, initializeClusterNotebooks, moveNoteToFolder, restoreNotebook, @@ -23,6 +23,8 @@ export interface ChangeLogEntry { notes: { noteId: string; originalParentId?: string; + originalParentTitle?: string; + originalParentGrandparentId?: string; addedTagId?: string; addedTagIds?: string[]; }[]; @@ -60,7 +62,7 @@ export async function applyCategorizationChanges( // Fetch existing items using modular helpers const existingTagsMap = await fetchExistingTags(); - const existingFoldersMap = await fetchExistingFolders(); + const { byKey: existingFoldersMap, byId: allFoldersById } = await fetchAllFolders(); const uniqueClusterIds = Array.from(new Set(assignments.filter((id) => id >= 0))); @@ -95,6 +97,8 @@ export async function applyCategorizationChanges( const changeLogNotes: { noteId: string; originalParentId?: string; + originalParentTitle?: string; + originalParentGrandparentId?: string; addedTagId?: string; addedTagIds?: string[]; }[] = []; @@ -106,6 +110,8 @@ export async function applyCategorizationChanges( const changeEntry: { noteId: string; originalParentId?: string; + originalParentTitle?: string; + originalParentGrandparentId?: string; addedTagId?: string; addedTagIds?: string[]; } = { @@ -167,6 +173,13 @@ export async function applyCategorizationChanges( ); if (folderResult.modified) { changeEntry.originalParentId = folderResult.originalParentId; + const folderInfo = folderResult.originalParentId + ? allFoldersById.get(folderResult.originalParentId) + : undefined; + if (folderInfo) { + changeEntry.originalParentTitle = folderInfo.title; + changeEntry.originalParentGrandparentId = folderInfo.parent_id; + } modified = true; } } @@ -193,6 +206,20 @@ export async function applyCategorizationChanges( await joplin.settings.setValue('categorization.changeLog', JSON.stringify(changeLogEntry)); await joplin.settings.setValue('categorization.changeLogSummary', formatChangeLogSummary(changeLogEntry)); + if (options.method === 'notebooks' || options.method === 'both') { + setPanelState({ type: 'apply_status', text: 'Cleaning up empty original notebooks...' }); + const originalParentIds = new Set(); + for (const note of changeLogNotes) { + if (note.originalParentId) { + originalParentIds.add(note.originalParentId); + } + } + if (originalParentIds.size > 0) { + const deletedCount = await cleanUpFolders(originalParentIds); + log(`Auto-cleanup: removed ${deletedCount} empty notebook(s)`); + } + } + setPanelState({ type: 'apply_complete' }); } catch (err) { log('Error in applyCategorizationChanges: ' + err); @@ -214,6 +241,26 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess const changeLog: ChangeLogEntry = JSON.parse(changeLogStr); const total = changeLog.notes.length; + const recreatedFolderMap = new Map(); + + // Batch-check which original parent folders still exist + const uniqueParentIds = new Set(); + for (const entry of changeLog.notes) { + if (entry.originalParentId) { + uniqueParentIds.add(entry.originalParentId); + } + } + const missingFolderIds = new Set(); + for (const folderId of uniqueParentIds) { + try { + const folder = await joplin.data.get(['folders', folderId], { fields: ['id', 'deleted_time'] }); + if (folder.deleted_time) { + await joplin.data.put(['folders', folderId], null, { deleted_time: 0 }); + } + } catch { + missingFolderIds.add(folderId); + } + } // 1. Restore parent notebooks and remove tag associations from notes for (let i = 0; i < total; i++) { @@ -231,7 +278,14 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess // Restore parent notebook if (entry.originalParentId) { - await restoreNotebook(entry.noteId, entry.originalParentId); + await restoreNotebook( + entry.noteId, + entry.originalParentId, + entry.originalParentTitle, + entry.originalParentGrandparentId, + recreatedFolderMap, + missingFolderIds.has(entry.originalParentId), + ); } setPanelState({ @@ -266,41 +320,3 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess }); } } - -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; - } - - const deletedCount = await cleanUpFolders(originalParentIds); - - setPanelState({ - type: 'cleanup_complete', - message: `Cleaned up ${deletedCount} empty original notebook(s) successfully!`, - }); - } catch (err) { - log('Error in cleanUpEmptyNotebooks: ' + err); - setPanelState({ - type: 'cleanup_error', - message: err instanceof Error ? err.message : String(err), - }); - } -} diff --git a/src/commands/applyNotebooks.ts b/src/commands/applyNotebooks.ts index 5117379..2bdc989 100644 --- a/src/commands/applyNotebooks.ts +++ b/src/commands/applyNotebooks.ts @@ -7,7 +7,10 @@ interface JoplinFolder { parent_id: string; } -export async function fetchExistingFolders(): Promise> { +export async function fetchAllFolders(): Promise<{ + byKey: Map; + byId: Map; +}> { const allFoldersList: JoplinFolder[] = []; let folderPage = 1; const MAX_PAGES = 500; @@ -21,9 +24,14 @@ export async function fetchExistingFolders(): Promise> { if (!res.has_more) break; folderPage++; } - return new Map( - allFoldersList.map((f) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]), - ); + return { + byKey: new Map( + allFoldersList.map((f) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]), + ), + byId: new Map( + allFoldersList.map((f) => [f.id, { title: f.title, parent_id: f.parent_id || '' }]), + ), + }; } export async function getOrCreateFolder( @@ -119,11 +127,44 @@ export async function moveNoteToFolder( return { modified: false }; } -export async function restoreNotebook(noteId: string, originalParentId: string) { +export async function restoreNotebook( + noteId: string, + originalParentId: string, + originalParentTitle?: string, + originalParentGrandparentId?: string, + recreatedFolderMap?: Map, + folderMissing = false, +): Promise { + if (!folderMissing) { + try { + await joplin.data.put(['notes', noteId], null, { parent_id: originalParentId }); + return; + } catch (folderErr) { + log(`Undo: restoring folder ${originalParentId} failed for note ${noteId}: ${folderErr}`); + } + } + + if (!originalParentTitle || !recreatedFolderMap) { + if (folderMissing) { + log(`Undo: folder ${originalParentId} missing for note ${noteId} but no title available to recreate`); + } + return; + } + try { - await joplin.data.put(['notes', noteId], null, { parent_id: originalParentId }); - } catch (folderErr) { - log(`Undo: restoring folder ${originalParentId} failed for note ${noteId}: ${folderErr}`); + const cacheKey = `${originalParentTitle}\x1F${originalParentGrandparentId || ''}`; + let newFolderId = recreatedFolderMap.get(cacheKey); + if (!newFolderId) { + const created = await joplin.data.post(['folders'], null, { + title: originalParentTitle, + parent_id: originalParentGrandparentId || undefined, + }); + newFolderId = created.id as string; + recreatedFolderMap.set(cacheKey, newFolderId); + } + await joplin.data.put(['notes', noteId], null, { parent_id: newFolderId }); + } catch (recreateErr) { + log(`Undo: failed to recreate folder '${originalParentTitle}' for note ${noteId}: ${recreateErr}`); } } diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index ed787d8..cae2ab3 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -1,7 +1,7 @@ import joplin from 'api'; import { MenuItemLocation, ToolbarButtonLocation } from 'api/types'; import { log } from '../utils/logger'; -import { runNativeUndo, runNativeCleanup, OperationState } from '../settings/registerSettings'; +import { runNativeUndo, OperationState } from '../settings/registerSettings'; export async function registerPluginCommands(operationState: OperationState, panelHandle: string): Promise { await joplin.commands.register({ @@ -14,16 +14,6 @@ export async function registerPluginCommands(operationState: OperationState, pan }, }); - await joplin.commands.register({ - name: 'aiCategorise.cleanUpEmptyNotebooks', - label: 'AI Categorise: Clean Up Empty Notebooks', - iconName: 'fas fa-broom', - execute: async () => { - log('Menu: triggering cleanUpEmptyNotebooks'); - await runNativeCleanup('Menu', operationState); - }, - }); - await joplin.commands.register({ name: 'aiCategorise.togglePanel', label: 'AI Categorise: Toggle Panel', @@ -40,12 +30,6 @@ export async function registerPluginCommands(operationState: OperationState, pan MenuItemLocation.Tools, ); - await joplin.views.menuItems.create( - 'aiCategorise.cleanUpMenuItem', - 'aiCategorise.cleanUpEmptyNotebooks', - MenuItemLocation.Tools, - ); - await joplin.views.menuItems.create( 'aiCategorise.togglePanelMenuItem', 'aiCategorise.togglePanel', diff --git a/src/manifest.json b/src/manifest.json index c2bca7d..1d08fa8 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.harsh16gupta.notecategorization", "app_min_version": "3.5", - "version": "0.1.4", + "version": "0.1.5", "name": "Note Categorization Plugin", "description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.", "author": "Harsh Gupta", diff --git a/src/panel/setupPanel.ts b/src/panel/setupPanel.ts index 2aba18c..9f592df 100644 --- a/src/panel/setupPanel.ts +++ b/src/panel/setupPanel.ts @@ -3,7 +3,7 @@ import { runPipeline } from '../pipeline/runPipeline'; import { PanelMessage, WebviewMessage, PanelNote } from '../types/panel'; import { BenchmarkResult } from '../types/cluster'; import { log } from '../utils/logger'; -import { applyCategorizationChanges, undoCategorizationChanges, cleanUpEmptyNotebooks } from '../commands/applyChanges'; +import { applyCategorizationChanges, undoCategorizationChanges } from '../commands/applyChanges'; import { OperationState } from '../settings/registerSettings'; export async function setupPanel(operationState: OperationState): Promise { @@ -129,24 +129,6 @@ export async function setupPanel(operationState: OperationState): Promise { - panelState = state; - }) - .catch((err) => { - log('Error in cleanup background task: ' + err); - panelState = { type: 'cleanup_error', message: err.message || String(err) }; - }) - .finally(() => { - operationState.inProgress = false; - }); - return panelState; } }); diff --git a/src/pipeline/clustering/autoK.ts b/src/pipeline/clustering/autoK.ts index 80c5280..80e61cf 100644 --- a/src/pipeline/clustering/autoK.ts +++ b/src/pipeline/clustering/autoK.ts @@ -34,7 +34,8 @@ export interface AutoKResult { /** * Computes the K search range [minK, maxK] based on dataset size. * - * - minK is always 2 (minimum for silhouette to be defined). + * - minK is 2 for small datasets (N < 20), 3 for larger ones (N >= 20). + * For 20+ notes, 2 categories is too coarse to be useful. * - For small datasets (N < 20): maxK = floor(N / 2). * Ensures the sweep can explore meaningful K values (e.g. N=8 → [2,4]). * - For larger datasets (N >= 20): maxK = floor(N / 3). @@ -49,7 +50,7 @@ export interface AutoKResult { export function computeKRange(n: number): [number, number] { if (n < 2) return [1, 1]; // degenerate: can't cluster at all - const minK = MIN_K; + const minK = n >= 20 ? 3 : MIN_K; let maxK: number; if (n < 20) { diff --git a/src/settings/registerSettings.ts b/src/settings/registerSettings.ts index 4a45aa7..4758bf8 100644 --- a/src/settings/registerSettings.ts +++ b/src/settings/registerSettings.ts @@ -1,7 +1,7 @@ import joplin from 'api'; import { SettingItemType as SettingType } from 'api/types'; import { log } from '../utils/logger'; -import { undoCategorizationChanges, cleanUpEmptyNotebooks } from '../commands/applyChanges'; +import { undoCategorizationChanges } from '../commands/applyChanges'; export interface OperationState { inProgress: boolean; @@ -35,34 +35,6 @@ export async function runNativeUndo(source: string, operationState: OperationSta } } -export async function runNativeCleanup(source: string, operationState: OperationState): Promise { - if (operationState.inProgress) { - await joplin.views.dialogs.showMessageBox(OP_IN_PROGRESS_MSG); - return; - } - operationState.inProgress = true; - try { - let lastMessage = ''; - await cleanUpEmptyNotebooks((state) => { - log(`Native ${source} Cleanup: ${'text' in state ? state.text : state.type}`); - if (state.type === 'cleanup_complete') { - lastMessage = state.message; - } else if (state.type === 'cleanup_error') { - lastMessage = `Cleanup Error: ${state.message}`; - } - }); - if (lastMessage) { - await joplin.views.dialogs.showMessageBox(lastMessage); - } - } catch (err) { - await joplin.views.dialogs.showMessageBox( - `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } finally { - operationState.inProgress = false; - } -} - export async function registerPluginSettings(operationState: OperationState): Promise { try { await joplin.settings.registerSection('aiCategorization', { @@ -105,15 +77,6 @@ export async function registerPluginSettings(operationState: OperationState): Pr description: 'Check this box and click Apply/OK to revert note movements and tags from the previous run.', }, - 'categorization.cleanUpAction': { - value: false, - type: SettingType.Bool, - section: 'aiCategorization', - public: true, - label: 'Clean Up Empty Notebooks', - description: - 'Check this box and click Apply/OK to check for and remove empty notebooks leftover after note moves.', - }, }); // Handle native options checkbox triggers @@ -126,14 +89,6 @@ export async function registerPluginSettings(operationState: OperationState): Pr await runNativeUndo('Settings', operationState); } } - if (event.keys.includes('categorization.cleanUpAction')) { - const val = await joplin.settings.value('categorization.cleanUpAction'); - if (val) { - await joplin.settings.setValue('categorization.cleanUpAction', false); - log('Native Settings: triggering cleanUpEmptyNotebooks'); - await runNativeCleanup('Settings', operationState); - } - } }); } catch (err) { log('Error registering settings: ' + err); diff --git a/src/types/panel.ts b/src/types/panel.ts index 81e3f42..b572248 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -41,10 +41,7 @@ export type PanelMessage = | { 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 }; + | { type: 'undo_error'; message: string }; // Webview → Plugin export type WebviewMessage = @@ -56,5 +53,4 @@ export type WebviewMessage = | { type: 'getSettings' } | { type: 'updateSetting'; key: string; value: string } | ApplyMessage - | { type: 'undo' } - | { type: 'cleanUpEmptyNotebooks' }; + | { type: 'undo' }; diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx index 86087a2..e94dff0 100644 --- a/src/webview/context/AppStateContext.tsx +++ b/src/webview/context/AppStateContext.tsx @@ -46,12 +46,6 @@ interface AppStateContextType { undoSuccess: boolean; undoChanges: () => Promise; hasChangeLog: boolean; - - // cleanup states - isCleaningUp: boolean; - cleanupError: string | null; - cleanupSuccess: string | null; - cleanUpNotebooks: () => Promise; } const AppStateContext = React.createContext(undefined); @@ -79,13 +73,9 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil undoProgress, undoError, undoSuccess, - isCleaningUp, - cleanupError, - cleanupSuccess, resetApplyState, applyChanges, undoChanges, - cleanUpNotebooks, setIsApplying, setApplyProgress, setApplyError, @@ -94,9 +84,6 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setUndoProgress, setUndoError, setUndoSuccess, - setIsCleaningUp, - setCleanupError, - setCleanupSuccess, } = useApplyState(() => startPolling()); // Initialize pipeline state hook @@ -218,25 +205,6 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil 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; } }, [ @@ -258,9 +226,6 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setUndoProgress, setUndoError, setUndoSuccess, - setIsCleaningUp, - setCleanupError, - setCleanupSuccess, ], ); @@ -358,10 +323,6 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil undoSuccess, undoChanges, hasChangeLog, - isCleaningUp, - cleanupError, - cleanupSuccess, - cleanUpNotebooks, }} > {children} diff --git a/src/webview/context/useApplyState.ts b/src/webview/context/useApplyState.ts index f8b2a47..8d119b2 100644 --- a/src/webview/context/useApplyState.ts +++ b/src/webview/context/useApplyState.ts @@ -12,17 +12,11 @@ export function useApplyState(startPolling: () => void) { 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 resetApplyState = React.useCallback(() => { setApplySuccess(false); setApplyError(null); setUndoSuccess(false); setUndoError(null); - setCleanupSuccess(null); - setCleanupError(null); }, []); const applyChanges = React.useCallback( @@ -38,8 +32,6 @@ export function useApplyState(startPolling: () => void) { setApplySuccess(false); setUndoSuccess(false); setUndoError(null); - setCleanupSuccess(null); - setCleanupError(null); try { if (typeof webviewApi === 'undefined') { @@ -71,8 +63,6 @@ export function useApplyState(startPolling: () => void) { setUndoSuccess(false); setApplySuccess(false); setApplyError(null); - setCleanupSuccess(null); - setCleanupError(null); try { if (typeof webviewApi === 'undefined') { @@ -88,29 +78,6 @@ export function useApplyState(startPolling: () => void) { } }, [startPolling]); - const cleanUpNotebooks = React.useCallback(async () => { - setIsCleaningUp(true); - setCleanupError(null); - setCleanupSuccess(null); - setApplySuccess(false); - setApplyError(null); - setUndoSuccess(false); - setUndoError(null); - - try { - if (typeof webviewApi === 'undefined') { - setCleanupError('Joplin API not available'); - setIsCleaningUp(false); - return; - } - await webviewApi.postMessage({ type: 'cleanUpEmptyNotebooks' }); - startPolling(); - } catch (err) { - setCleanupError('Failed to start cleanup: ' + String(err)); - setIsCleaningUp(false); - } - }, [startPolling]); - return { isApplying, applyProgress, @@ -120,13 +87,9 @@ export function useApplyState(startPolling: () => void) { undoProgress, undoError, undoSuccess, - isCleaningUp, - cleanupError, - cleanupSuccess, resetApplyState, applyChanges, undoChanges, - cleanUpNotebooks, setIsApplying, setApplyProgress, setApplyError, @@ -135,8 +98,5 @@ export function useApplyState(startPolling: () => void) { setUndoProgress, setUndoError, setUndoSuccess, - setIsCleaningUp, - setCleanupError, - setCleanupSuccess, }; } diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index a9160f4..2790e40 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -21,7 +21,6 @@ export const DashboardPage: React.FC = () => { applySuccess, applyChanges, isUndoing, - isCleaningUp, settings, } = useAppState(); @@ -76,7 +75,7 @@ export const DashboardPage: React.FC = () => { }; const handleApply = () => { - if (applySuccess || isApplying || isUndoing || isCleaningUp) { + if (applySuccess || isApplying || isUndoing) { return; } applyChanges({ @@ -181,7 +180,7 @@ export const DashboardPage: React.FC = () => { - - -
- 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}
} ) : (
@@ -114,14 +79,6 @@ export const HistoryPage: React.FC = () => { Reverted changes successfully!
)} - {cleanupSuccess && ( -
- {cleanupSuccess} -
- )} )} diff --git a/src/webview/panel.css b/src/webview/panel.css index b4efb35..1d2b2c3 100644 --- a/src/webview/panel.css +++ b/src/webview/panel.css @@ -67,8 +67,7 @@ body { /* BUTTONS — Secondary (ghost) */ .btn-run, -.btn-undo, -.btn-cleanup { +.btn-undo { display: inline-flex; align-items: center; justify-content: center; @@ -87,29 +86,25 @@ body { } .btn-run:hover, -.btn-undo:hover, -.btn-cleanup:hover { +.btn-undo: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 { +.btn-undo:active { background: color-mix(in srgb, var(--joplin-color) 8%, var(--joplin-background-color)); } .btn-run:disabled, -.btn-undo:disabled, -.btn-cleanup:disabled { +.btn-undo:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; } .btn-run svg, -.btn-undo svg, -.btn-cleanup svg { +.btn-undo svg { opacity: 0.6; } @@ -635,15 +630,6 @@ body { font-weight: 600; } -.cleanup-note { - font-size: 0.75em; - opacity: 0.4; - line-height: 1.4; - margin-top: 4px; - border-top: 1px solid var(--joplin-divider-color); - padding-top: 8px; -} - /* SETTINGS PAGE */ .settings-container { @@ -754,7 +740,6 @@ body { .btn-run:focus-visible, .btn-apply-primary:focus-visible, .btn-undo:focus-visible, -.btn-cleanup:focus-visible, .strategy-select:focus-visible, .nav-tab:focus-visible { outline: 2px solid color-mix(in srgb, var(--joplin-color) 40%, transparent); diff --git a/test/commands/applyChanges.test.ts b/test/commands/applyChanges.test.ts new file mode 100644 index 0000000..425a4b6 --- /dev/null +++ b/test/commands/applyChanges.test.ts @@ -0,0 +1,181 @@ +import joplin from 'api'; +import { applyCategorizationChanges, undoCategorizationChanges } from '../../src/commands/applyChanges'; +import { + fetchAllFolders, + initializeClusterNotebooks, + moveNoteToFolder, + restoreNotebook, + cleanUpFolders, +} from '../../src/commands/applyNotebooks'; + +jest.mock('api', () => ({ + __esModule: true, + default: { + data: { + get: jest.fn(), + put: jest.fn(), + post: jest.fn(), + }, + settings: { + value: jest.fn(), + setValue: jest.fn(), + }, + }, +})); + +jest.mock('../../src/commands/applyNotebooks', () => ({ + fetchAllFolders: jest.fn(), + initializeClusterNotebooks: jest.fn(), + moveNoteToFolder: jest.fn(), + restoreNotebook: jest.fn(), + deleteCreatedFolders: jest.fn(), + cleanUpFolders: jest.fn(), +})); + +jest.mock('../../src/commands/applyTags', () => ({ + fetchExistingTags: jest.fn().mockResolvedValue(new Map()), + initializeClusterTags: jest.fn().mockResolvedValue({}), + applyTagsToNote: jest.fn().mockResolvedValue([]), + removeTagsFromNote: jest.fn().mockResolvedValue(undefined), + deleteCreatedTags: jest.fn().mockResolvedValue(undefined), +})); + +describe('applyChanges commands', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('applyCategorizationChanges runs auto-cleanup after note moves', async () => { + (fetchAllFolders as jest.Mock).mockResolvedValue({ + byKey: new Map(), + byId: new Map([['orig-folder-id', { title: 'Orig Folder', parent_id: 'grandparent-id' }]]), + }); + (initializeClusterNotebooks as jest.Mock).mockResolvedValue({ + folderMap: { 0: 'target-folder-id' }, + uncategorizedFolderId: '', + }); + (joplin.data.get as jest.Mock).mockResolvedValue({ parent_id: 'orig-folder-id', title: 'Note 1' }); + (moveNoteToFolder as jest.Mock).mockResolvedValue({ originalParentId: 'orig-folder-id', modified: true }); + + await applyCategorizationChanges( + { method: 'notebooks', parentNotebookName: '' }, + [{ noteId: 'note-1', title: 'Note 1' }], + [0], + { 0: 'Cluster 1' }, + {}, + jest.fn(), + ); + + expect(cleanUpFolders).toHaveBeenCalledTimes(1); + const calledSet = (cleanUpFolders as jest.Mock).mock.calls[0][0]; + expect(calledSet).toEqual(new Set(['orig-folder-id'])); + }); + + it('applyCategorizationChanges skips cleanup for tags-only method', async () => { + (fetchAllFolders as jest.Mock).mockResolvedValue({ + byKey: new Map(), + byId: new Map(), + }); + (joplin.data.get as jest.Mock).mockResolvedValue({ parent_id: 'orig-folder-id', title: 'Note 1', body: '' }); + + await applyCategorizationChanges( + { method: 'tags', parentNotebookName: '' }, + [{ noteId: 'note-1', title: 'Note 1' }], + [0], + { 0: 'Cluster 1' }, + {}, + jest.fn(), + ); + + expect(cleanUpFolders).not.toHaveBeenCalled(); + }); + + it('applyCategorizationChanges stores folder metadata in change log', async () => { + (fetchAllFolders as jest.Mock).mockResolvedValue({ + byKey: new Map(), + byId: new Map([['orig-folder-id', { title: 'Orig Folder', parent_id: 'grandparent-id' }]]), + }); + (initializeClusterNotebooks as jest.Mock).mockResolvedValue({ + folderMap: { 0: 'target-folder-id' }, + uncategorizedFolderId: '', + }); + (joplin.data.get as jest.Mock).mockResolvedValue({ parent_id: 'orig-folder-id', title: 'Note 1' }); + (moveNoteToFolder as jest.Mock).mockResolvedValue({ originalParentId: 'orig-folder-id', modified: true }); + + await applyCategorizationChanges( + { method: 'notebooks', parentNotebookName: '' }, + [{ noteId: 'note-1', title: 'Note 1' }], + [0], + { 0: 'Cluster 1' }, + {}, + jest.fn(), + ); + + const setValueCalls = (joplin.settings.setValue as jest.Mock).mock.calls; + const changeLogCall = setValueCalls.find((call) => call[0] === 'categorization.changeLog'); + expect(changeLogCall).toBeDefined(); + + const changeLog = JSON.parse(changeLogCall[1]); + expect(changeLog.notes[0].originalParentTitle).toBe('Orig Folder'); + expect(changeLog.notes[0].originalParentGrandparentId).toBe('grandparent-id'); + }); + + it('undoCategorizationChanges passes recreatedFolderMap to restoreNotebook', async () => { + const mockLog = { + timestamp: Date.now(), + method: 'notebooks', + notes: [ + { + noteId: 'note-1', + originalParentId: 'orig-folder-id', + originalParentTitle: 'Orig Folder', + originalParentGrandparentId: 'grandparent-id', + }, + ], + }; + (joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify(mockLog)); + + await undoCategorizationChanges(jest.fn()); + + expect(restoreNotebook).toHaveBeenCalledWith( + 'note-1', + 'orig-folder-id', + 'Orig Folder', + 'grandparent-id', + expect.any(Map), + false, + ); + }); + + it('undoCategorizationChanges restores trashed folders before moving notes', async () => { + const mockLog = { + timestamp: Date.now(), + method: 'notebooks', + notes: [ + { + noteId: 'note-1', + originalParentId: 'trashed-folder-id', + originalParentTitle: 'Test Bench', + originalParentGrandparentId: '', + }, + ], + }; + (joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify(mockLog)); + (joplin.data.get as jest.Mock).mockResolvedValue({ id: 'trashed-folder-id', deleted_time: 1722870000000 }); + + await undoCategorizationChanges(jest.fn()); + + // Should restore the folder from trash + expect(joplin.data.put).toHaveBeenCalledWith(['folders', 'trashed-folder-id'], null, { deleted_time: 0 }); + + // Should then restore the note to that folder (folderMissing = false) + expect(restoreNotebook).toHaveBeenCalledWith( + 'note-1', + 'trashed-folder-id', + 'Test Bench', + '', + expect.any(Map), + false, + ); + }); +}); diff --git a/test/commands/applyNotebooks.test.ts b/test/commands/applyNotebooks.test.ts new file mode 100644 index 0000000..991b860 --- /dev/null +++ b/test/commands/applyNotebooks.test.ts @@ -0,0 +1,122 @@ +import joplin from 'api'; +import { restoreNotebook } from '../../src/commands/applyNotebooks'; + +jest.mock('api', () => ({ + __esModule: true, + default: { + data: { + get: jest.fn(), + put: jest.fn(), + post: jest.fn(), + }, + }, +})); + +describe('restoreNotebook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('restoreNotebook moves note to originalParentId on success', async () => { + (joplin.data.put as jest.Mock).mockResolvedValue(undefined); + await restoreNotebook('note-1', 'folder-1'); + expect(joplin.data.put).toHaveBeenCalledWith(['notes', 'note-1'], null, { parent_id: 'folder-1' }); + expect(joplin.data.post).not.toHaveBeenCalled(); + }); + + it('restoreNotebook re-creates folder on failure when title is available', async () => { + (joplin.data.put as jest.Mock) + .mockRejectedValueOnce(new Error('Folder deleted')) + .mockResolvedValueOnce(undefined); + (joplin.data.post as jest.Mock).mockResolvedValue({ id: 'new-folder-id' }); + + const map = new Map(); + await restoreNotebook('note-1', 'deleted-folder-id', 'Work Notes', 'grandparent-id', map); + + expect(joplin.data.post).toHaveBeenCalledWith(['folders'], null, { + title: 'Work Notes', + parent_id: 'grandparent-id', + }); + expect(joplin.data.put).toHaveBeenNthCalledWith(2, ['notes', 'note-1'], null, { + parent_id: 'new-folder-id', + }); + }); + + it('restoreNotebook uses recreatedFolderMap cache', async () => { + const map = new Map(); + const cacheKey = `Work Notes\x1Fgrandparent-id`; + map.set(cacheKey, 'cached-folder-id'); + + (joplin.data.put as jest.Mock) + .mockRejectedValueOnce(new Error('Folder deleted')) + .mockResolvedValueOnce(undefined); + + await restoreNotebook('note-1', 'deleted-folder-id', 'Work Notes', 'grandparent-id', map); + + expect(joplin.data.post).not.toHaveBeenCalled(); + expect(joplin.data.put).toHaveBeenNthCalledWith(2, ['notes', 'note-1'], null, { + parent_id: 'cached-folder-id', + }); + }); + + it('restoreNotebook caches across multiple calls', async () => { + const map = new Map(); + (joplin.data.put as jest.Mock) + .mockRejectedValueOnce(new Error('Folder deleted')) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Folder deleted')) + .mockResolvedValueOnce(undefined); + + (joplin.data.post as jest.Mock).mockResolvedValue({ id: 'new-folder-id' }); + + await restoreNotebook('note-1', 'deleted-folder-id', 'Work Notes', 'grandparent-id', map); + await restoreNotebook('note-2', 'deleted-folder-id', 'Work Notes', 'grandparent-id', map); + + expect(joplin.data.post).toHaveBeenCalledTimes(1); + expect(joplin.data.put).toHaveBeenNthCalledWith(2, ['notes', 'note-1'], null, { + parent_id: 'new-folder-id', + }); + expect(joplin.data.put).toHaveBeenNthCalledWith(4, ['notes', 'note-2'], null, { + parent_id: 'new-folder-id', + }); + }); + + it('restoreNotebook logs and returns on failure without title (backward compat)', async () => { + (joplin.data.put as jest.Mock).mockRejectedValue(new Error('Folder deleted')); + + await expect(restoreNotebook('note-1', 'deleted-folder-id', undefined)).resolves.not.toThrow(); + expect(joplin.data.post).not.toHaveBeenCalled(); + }); + + it('restoreNotebook handles root-level folder re-creation', async () => { + (joplin.data.put as jest.Mock) + .mockRejectedValueOnce(new Error('Folder deleted')) + .mockResolvedValueOnce(undefined); + (joplin.data.post as jest.Mock).mockResolvedValue({ id: 'root-folder-id' }); + + const map = new Map(); + await restoreNotebook('note-1', 'deleted-folder-id', 'Root Notes', '', map); + + expect(joplin.data.post).toHaveBeenCalledWith(['folders'], null, { + title: 'Root Notes', + parent_id: undefined, + }); + }); + + it('restoreNotebook skips put and re-creates folder when folderMissing is true', async () => { + (joplin.data.put as jest.Mock).mockResolvedValue(undefined); + (joplin.data.post as jest.Mock).mockResolvedValue({ id: 'recreated-id' }); + + const map = new Map(); + await restoreNotebook('note-1', 'missing-folder-id', 'My Folder', 'parent-id', map, true); + + expect(joplin.data.post).toHaveBeenCalledWith(['folders'], null, { + title: 'My Folder', + parent_id: 'parent-id', + }); + expect(joplin.data.put).toHaveBeenCalledTimes(1); + expect(joplin.data.put).toHaveBeenCalledWith(['notes', 'note-1'], null, { + parent_id: 'recreated-id', + }); + }); +}); diff --git a/test/pipeline/clustering/autoK.test.ts b/test/pipeline/clustering/autoK.test.ts index f6bdf93..b59488e 100644 --- a/test/pipeline/clustering/autoK.test.ts +++ b/test/pipeline/clustering/autoK.test.ts @@ -99,12 +99,12 @@ describe('autoK computeKRange', () => { }); it('computes correct range for larger datasets (N >= 20, uses N/3)', () => { - expect(computeKRange(20)).toEqual([2, 6]); // floor(20/3) = 6 - expect(computeKRange(30)).toEqual([2, 10]); // floor(30/3) = 10 - expect(computeKRange(45)).toEqual([2, 15]); // floor(45/3) = 15, hits cap - expect(computeKRange(56)).toEqual([2, 15]); // floor(56/3) = 18, capped at 15 - expect(computeKRange(100)).toEqual([2, 15]); // floor(100/3) = 33, capped at 15 - expect(computeKRange(500)).toEqual([2, 15]); // capped at MAX_K_CAP=15 + expect(computeKRange(20)).toEqual([3, 6]); // floor(20/3) = 6 + expect(computeKRange(30)).toEqual([3, 10]); // floor(30/3) = 10 + expect(computeKRange(45)).toEqual([3, 15]); // floor(45/3) = 15, hits cap + expect(computeKRange(56)).toEqual([3, 15]); // floor(56/3) = 18, capped at 15 + expect(computeKRange(100)).toEqual([3, 15]); // floor(100/3) = 33, capped at 15 + expect(computeKRange(500)).toEqual([3, 15]); // capped at MAX_K_CAP=15 }); }); @@ -118,8 +118,9 @@ describe('autoK findOptimalK', () => { it('identifies 2 well-separated clusters', () => { const result = findOptimalK(TWO_CLUSTERS, 'kmeans', euclideanDistance, 42); - expect(result.bestK).toBe(2); - expect(result.silhouetteScore).toBeGreaterThan(0.9); + // With minK=3 for N>=20, the algorithm picks the best K >= 3 + expect(result.bestK).toBeGreaterThanOrEqual(3); + expect(result.silhouetteScore).toBeGreaterThan(0.5); expect(result.assignments).toHaveLength(TWO_CLUSTERS.length); });