Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
98 changes: 57 additions & 41 deletions src/commands/applyChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
deleteCreatedTags,
} from './applyTags';
import {
fetchExistingFolders,
fetchAllFolders,
initializeClusterNotebooks,
moveNoteToFolder,
restoreNotebook,
Expand All @@ -23,6 +23,8 @@ export interface ChangeLogEntry {
notes: {
noteId: string;
originalParentId?: string;
originalParentTitle?: string;
originalParentGrandparentId?: string;
addedTagId?: string;
addedTagIds?: string[];
}[];
Expand Down Expand Up @@ -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)));

Expand Down Expand Up @@ -95,6 +97,8 @@ export async function applyCategorizationChanges(
const changeLogNotes: {
noteId: string;
originalParentId?: string;
originalParentTitle?: string;
originalParentGrandparentId?: string;
addedTagId?: string;
addedTagIds?: string[];
}[] = [];
Expand All @@ -106,6 +110,8 @@ export async function applyCategorizationChanges(
const changeEntry: {
noteId: string;
originalParentId?: string;
originalParentTitle?: string;
originalParentGrandparentId?: string;
addedTagId?: string;
addedTagIds?: string[];
} = {
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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<string>();
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);
Expand All @@ -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<string, string>();

// Batch-check which original parent folders still exist
const uniqueParentIds = new Set<string>();
for (const entry of changeLog.notes) {
if (entry.originalParentId) {
uniqueParentIds.add(entry.originalParentId);
}
}
const missingFolderIds = new Set<string>();
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++) {
Expand All @@ -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({
Expand Down Expand Up @@ -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<string>();
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),
});
}
}
57 changes: 49 additions & 8 deletions src/commands/applyNotebooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ interface JoplinFolder {
parent_id: string;
}

export async function fetchExistingFolders(): Promise<Map<string, string>> {
export async function fetchAllFolders(): Promise<{
byKey: Map<string, string>;
byId: Map<string, { title: string; parent_id: string }>;
}> {
const allFoldersList: JoplinFolder[] = [];
let folderPage = 1;
const MAX_PAGES = 500;
Expand All @@ -21,9 +24,14 @@ export async function fetchExistingFolders(): Promise<Map<string, string>> {
if (!res.has_more) break;
folderPage++;
}
return new Map<string, string>(
allFoldersList.map((f) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]),
);
return {
byKey: new Map<string, string>(
allFoldersList.map((f) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]),
),
byId: new Map<string, { title: string; parent_id: string }>(
allFoldersList.map((f) => [f.id, { title: f.title, parent_id: f.parent_id || '' }]),
),
};
}

export async function getOrCreateFolder(
Expand Down Expand Up @@ -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<string, string>,
folderMissing = false,
): Promise<void> {
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}`);
}
}

Expand Down
18 changes: 1 addition & 17 deletions src/commands/registerCommands.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await joplin.commands.register({
Expand All @@ -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',
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 1 addition & 19 deletions src/panel/setupPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -129,24 +129,6 @@ export async function setupPanel(operationState: OperationState): Promise<string
operationState.inProgress = false;
});
return panelState;

case 'cleanUpEmptyNotebooks':
if (operationState.inProgress) {
return { type: 'cleanup_error', message: 'Another operation is already in progress.' };
}
operationState.inProgress = 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(() => {
operationState.inProgress = false;
});
return panelState;
}
});

Expand Down
5 changes: 3 additions & 2 deletions src/pipeline/clustering/autoK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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) {
Expand Down
Loading
Loading