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.10",
"version": "0.1.11",
"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
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.10",
"version": "0.1.11",
"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
114 changes: 93 additions & 21 deletions src/panel/setupPanel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import joplin from 'api';
import { runPipeline } from '../pipeline/runPipeline';
import { fetchAllFoldersList, buildFolderTree } from '../pipeline/noteReader';
import { PanelMessage, WebviewMessage, PanelNote } from '../types/panel';
import { BenchmarkResult } from '../types/cluster';
import { DEFAULT_NOTEBOOK_FILTER, NotebookFilterConfig, isValidFilterConfig } from '../types/notebook';
import { log } from '../utils/logger';
import { applyCategorizationChanges, undoCategorizationChanges } from '../commands/applyChanges';
import { OperationState } from '../settings/registerSettings';
Expand Down Expand Up @@ -30,33 +32,51 @@ export async function setupPanel(operationState: OperationState): Promise<string

await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => {
switch (msg.type) {
case 'run':
case 'run': {
panelState = { type: 'status', text: 'Starting pipeline...' };
log('Panel: starting pipeline');

runPipeline(installDir, {
onStatus: (text, isNativeAiUsed) => {
panelState = { type: 'status', text, isNativeAiUsed };
},
onProgress: (current, total, cached, skipped, isNativeAiUsed) => {
panelState = { type: 'progress', current, total, cached, skipped, isNativeAiUsed };
},
onComplete: (strategies, notes, isNativeAiUsed, isAiNamingUsed) => {
lastResultsState = {
strategies,
notes,
selectedStrategyIndex: 0,
isNativeAiUsed,
isAiNamingUsed,
};
panelState = { type: 'results', strategies, notes, isNativeAiUsed, isAiNamingUsed };
},
onError: (message) => {
panelState = { type: 'error', message };
let activeFilter = msg.filterConfig;
if (!activeFilter) {
try {
const raw = await joplin.settings.value('categorization.notebookFilter');
if (raw) {
const parsed = JSON.parse(raw);
activeFilter = isValidFilterConfig(parsed) ? parsed : DEFAULT_NOTEBOOK_FILTER;
}
} catch {
activeFilter = DEFAULT_NOTEBOOK_FILTER;
}
}

runPipeline(
installDir,
{
onStatus: (text, isNativeAiUsed) => {
panelState = { type: 'status', text, isNativeAiUsed };
},
onProgress: (current, total, cached, skipped, isNativeAiUsed) => {
panelState = { type: 'progress', current, total, cached, skipped, isNativeAiUsed };
},
onComplete: (strategies, notes, isNativeAiUsed, isAiNamingUsed) => {
lastResultsState = {
strategies,
notes,
selectedStrategyIndex: 0,
isNativeAiUsed,
isAiNamingUsed,
};
panelState = { type: 'results', strategies, notes, isNativeAiUsed, isAiNamingUsed };
},
onError: (message) => {
panelState = { type: 'error', message };
},
},
});
activeFilter,
);

return panelState;
}

case 'poll':
return panelState;
Expand Down Expand Up @@ -130,6 +150,58 @@ export async function setupPanel(operationState: OperationState): Promise<string
await joplin.settings.setValue(msg.key, msg.value);
return { success: true };

case 'getNotebooks': {
try {
const folders = await fetchAllFoldersList();
const countsMap = new Map<string, number>();
try {
let page = 1;
const MAX_PAGES = 500;
while (page <= MAX_PAGES) {
const res = await joplin.data.get(['notes'], { fields: ['parent_id'], page, limit: 100 });
if (!res || !res.items) break;
for (const n of res.items) {
if (n && n.parent_id) {
countsMap.set(n.parent_id, (countsMap.get(n.parent_id) || 0) + 1);
}
}
if (!res.has_more) break;
page++;
}
} catch (noteCountErr) {
log('Warning: could not fetch note counts for notebooks: ' + noteCountErr);
}
const folderTree = buildFolderTree(folders, countsMap);
const counts: { [folderId: string]: number } = {};
countsMap.forEach((v, k) => {
counts[k] = v;
});
return { folders, folderTree, counts };
} catch (err) {
log('Error in getNotebooks: ' + err);
return { folders: [], folderTree: [], counts: {} };
}
}

case 'getFilterConfig': {
const raw = await joplin.settings.value('categorization.notebookFilter');
let filterConfig: NotebookFilterConfig = DEFAULT_NOTEBOOK_FILTER;
if (raw) {
try {
const parsed = JSON.parse(raw);
filterConfig = isValidFilterConfig(parsed) ? parsed : DEFAULT_NOTEBOOK_FILTER;
} catch {
filterConfig = DEFAULT_NOTEBOOK_FILTER;
}
}
return { filterConfig };
}

case 'saveFilterConfig': {
await joplin.settings.setValue('categorization.notebookFilter', JSON.stringify(msg.filterConfig));
return { success: true };
}

case 'apply':
if (operationState.inProgress) {
return { type: 'apply_error', message: 'Another operation is already in progress.' };
Expand Down
170 changes: 160 additions & 10 deletions src/pipeline/noteReader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import joplin from 'api';
import { FolderItem, NotebookFilterConfig } from '../types/notebook';

export interface NoteItem {
id: string;
Expand All @@ -9,19 +10,168 @@ export interface NoteItem {
parent_id: string;
}

export const fetchAllNotes = async (): Promise<NoteItem[]> => {
// Fetch all active folder IDs
const activeFolderIds = new Set<string>();
let folderPage = 1;
/**
* Fetches all active folders from Joplin.
*/
export const fetchAllFoldersList = async (): Promise<FolderItem[]> => {
const folders: FolderItem[] = [];
let page = 1;
while (true) {
const result = await joplin.data.get(['folders'], {
fields: ['id'],
page: folderPage,
fields: ['id', 'title', 'parent_id'],
page,
limit: 50,
});
result.items.forEach((f: { id: string }) => activeFolderIds.add(f.id));
folders.push(...result.items);
if (!result.has_more) break;
folderPage++;
page++;
}
return folders;
};

/**
* Given a list of folders, builds a map of parentId -> child folder IDs.
*/
export const buildFolderChildrenMap = (folders: FolderItem[]): Map<string, string[]> => {
const childrenMap = new Map<string, string[]>();
for (const folder of folders) {
const parentId = folder.parent_id || '';
const children = childrenMap.get(parentId) || [];
children.push(folder.id);
childrenMap.set(parentId, children);
}
return childrenMap;
};

/**
* Recursively collects all descendant folder IDs for a given set of target folder IDs.
*/
export const getDescendantFolderIds = (targetFolderIds: string[], childrenMap: Map<string, string[]>): Set<string> => {
const result = new Set<string>();
const queue = [...targetFolderIds];

while (queue.length > 0) {
const currentId = queue.shift()!;
result.add(currentId);
const children = childrenMap.get(currentId);
if (children) {
for (const childId of children) {
if (!result.has(childId)) {
queue.push(childId);
}
}
}
}

return result;
};

/**
* Resolves the effective set of folder IDs permitted by the given filter configuration.
*/
export const resolveEffectiveFolderIds = (
allFolders: FolderItem[],
filterConfig?: NotebookFilterConfig,
): Set<string> => {
const allFolderIds = new Set(allFolders.map((f) => f.id));

if (!filterConfig || filterConfig.mode === 'all') {
return allFolderIds;
}

const selectedSet = new Set(filterConfig.selectedFolderIds.filter((id) => allFolderIds.has(id)));
const childrenMap = buildFolderChildrenMap(allFolders);

if (filterConfig.mode === 'include') {
if (selectedSet.size === 0) {
return new Set<string>();
}
if (filterConfig.includeSubNotebooks) {
return getDescendantFolderIds(Array.from(selectedSet), childrenMap);
}
return selectedSet;
}

if (filterConfig.mode === 'exclude') {
if (selectedSet.size === 0) {
return allFolderIds;
}
const excludedSet = filterConfig.includeSubNotebooks
? getDescendantFolderIds(Array.from(selectedSet), childrenMap)
: selectedSet;

const allowed = new Set<string>();
for (const id of allFolderIds) {
if (!excludedSet.has(id)) {
allowed.add(id);
}
}
return allowed;
}

return allFolderIds;
};

/**
* Builds a hierarchical tree of folders from a flat list.
*/
export const buildFolderTree = (
folders: FolderItem[],
noteCountsByFolder: Map<string, number> = new Map(),
): FolderItem[] => {
const folderMap = new Map<string, FolderItem>();
for (const f of folders) {
folderMap.set(f.id, {
id: f.id,
title: f.title,
parent_id: f.parent_id || '',
noteCount: noteCountsByFolder.get(f.id) || 0,
children: [],
});
}

const rootFolders: FolderItem[] = [];
for (const f of folders) {
const node = folderMap.get(f.id)!;
if (f.parent_id && folderMap.has(f.parent_id)) {
folderMap.get(f.parent_id)!.children!.push(node);
} else {
rootFolders.push(node);
}
}

return rootFolders;
};

/**
* Fetches all note IDs from Joplin (useful for global cache reconciliation).
*/
export const fetchAllJoplinNoteIds = async (): Promise<Set<string>> => {
let page = 1;
const allNoteIds = new Set<string>();
while (true) {
const result = await joplin.data.get(['notes'], {
fields: ['id'],
page,
limit: 100,
});
result.items.forEach((n: { id: string }) => allNoteIds.add(n.id));
if (!result.has_more) break;
page++;
}
return allNoteIds;
};

/**
* Fetches notes from Joplin filtered by the provided notebook filter configuration.
*/
export const fetchAllNotes = async (filterConfig?: NotebookFilterConfig): Promise<NoteItem[]> => {
// Fetch all active folders
const allFolders = await fetchAllFoldersList();
const effectiveFolderIds = resolveEffectiveFolderIds(allFolders, filterConfig);

if (effectiveFolderIds.size === 0) {
return [];
}

// Fetch all notes
Expand All @@ -38,8 +188,8 @@ export const fetchAllNotes = async (): Promise<NoteItem[]> => {
page++;
}

// Filter out notes whose parent folder no longer exists (orphaned/deleted folders)
const activeNotes = allNotes.filter((note) => activeFolderIds.has(note.parent_id));
// Filter out notes whose parent folder is not in the allowed active set
const activeNotes = allNotes.filter((note) => effectiveFolderIds.has(note.parent_id));

return activeNotes;
};
Loading
Loading