diff --git a/package.json b/package.json index d9729a3..b309d17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/manifest.json b/src/manifest.json index eaf6d74..d2dbd20 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.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", diff --git a/src/panel/setupPanel.ts b/src/panel/setupPanel.ts index 8d93541..8bec5e7 100644 --- a/src/panel/setupPanel.ts +++ b/src/panel/setupPanel.ts @@ -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'; @@ -30,33 +32,51 @@ export async function setupPanel(operationState: OperationState): Promise { 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; @@ -130,6 +150,58 @@ export async function setupPanel(operationState: OperationState): Promise(); + 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.' }; diff --git a/src/pipeline/noteReader.ts b/src/pipeline/noteReader.ts index 46dbcdc..5d6dd63 100644 --- a/src/pipeline/noteReader.ts +++ b/src/pipeline/noteReader.ts @@ -1,4 +1,5 @@ import joplin from 'api'; +import { FolderItem, NotebookFilterConfig } from '../types/notebook'; export interface NoteItem { id: string; @@ -9,19 +10,168 @@ export interface NoteItem { parent_id: string; } -export const fetchAllNotes = async (): Promise => { - // Fetch all active folder IDs - const activeFolderIds = new Set(); - let folderPage = 1; +/** + * Fetches all active folders from Joplin. + */ +export const fetchAllFoldersList = async (): Promise => { + 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 => { + const childrenMap = new Map(); + 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): Set => { + const result = new Set(); + 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 => { + 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(); + } + 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(); + 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 = new Map(), +): FolderItem[] => { + const folderMap = new Map(); + 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> => { + let page = 1; + const allNoteIds = new Set(); + 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 => { + // Fetch all active folders + const allFolders = await fetchAllFoldersList(); + const effectiveFolderIds = resolveEffectiveFolderIds(allFolders, filterConfig); + + if (effectiveFolderIds.size === 0) { + return []; } // Fetch all notes @@ -38,8 +188,8 @@ export const fetchAllNotes = async (): Promise => { 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; }; diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index e34faac..79d7b5e 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -1,7 +1,8 @@ -import { fetchAllNotes } from './noteReader'; +import { fetchAllNotes, fetchAllJoplinNoteIds } from './noteReader'; import { benchmark } from './clustering/benchmark'; import { weightedAverageVectorsWithNorm } from './vectorAggregator'; import { PanelNote } from '../types/panel'; +import { NotebookFilterConfig } from '../types/notebook'; import { MetricType } from '../types/cluster'; import { log, logErr } from '../utils/logger'; import { VectorCache } from './vectorCache'; @@ -34,19 +35,23 @@ interface IndexedVector { * This process is decoupled from console logging so the panel (or any other caller) * can receive live updates. */ -export const runPipeline = async (installDir: string, callbacks: PipelineCallbacks): Promise => { +export const runPipeline = async ( + installDir: string, + callbacks: PipelineCallbacks, + filterConfig?: NotebookFilterConfig, +): Promise => { try { callbacks.onStatus('Fetching notes...'); - const notes = await fetchAllNotes(); - log(`Fetched ${notes.length} notes`); + const notes = await fetchAllNotes(filterConfig); + log(`Fetched ${notes.length} notes (filterMode: ${filterConfig?.mode || 'all'})`); if (notes.length === 0) { - callbacks.onError('No notes found. Create some notes and try again.'); + callbacks.onError('No notes found matching the selected notebook filter.'); return; } if (notes.length < 3) { - callbacks.onError('Too few notes for clustering (need at least 3).'); + callbacks.onError('Too few notes for clustering in selected notebooks (need at least 3).'); return; } @@ -154,14 +159,16 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac const cache = await VectorCache.create(); - // Remove notes from cache that are no longer in Joplin + // Remove notes from cache that are no longer in Joplin (safe Joplin-wide check) const indexedIds = await cache.getIndexedIds(); - const joplinNoteIds = new Set(notes.map((n) => n.id)); - const idsToDelete = indexedIds.filter((id) => !joplinNoteIds.has(id)); + if (indexedIds.length > 0) { + const allJoplinNoteIds = await fetchAllJoplinNoteIds(); + const idsToDelete = indexedIds.filter((id) => !allJoplinNoteIds.has(id)); - if (idsToDelete.length > 0) { - log(`Removing ${idsToDelete.length} obsolete notes from cache`); - await cache.deleteItems(idsToDelete); + if (idsToDelete.length > 0) { + log(`Removing ${idsToDelete.length} obsolete notes from cache`); + await cache.deleteItems(idsToDelete); + } } await cache.beginUpdate(); diff --git a/src/settings/registerSettings.ts b/src/settings/registerSettings.ts index ded29ec..e1ac8b3 100644 --- a/src/settings/registerSettings.ts +++ b/src/settings/registerSettings.ts @@ -95,6 +95,14 @@ export async function registerPluginSettings(operationState: OperationState): Pr label: 'Last Categorization Summary', description: 'Summary of the last applied categorization.', }, + 'categorization.notebookFilter': { + value: '', + type: SettingType.String, + section: 'aiCategorization', + public: false, + label: 'Notebook Filter Configuration', + description: 'Internal configuration for included/excluded notebooks in categorization.', + }, 'categorization.undoAction': { value: false, type: SettingType.Bool, diff --git a/src/types/notebook.ts b/src/types/notebook.ts new file mode 100644 index 0000000..1c1528d --- /dev/null +++ b/src/types/notebook.ts @@ -0,0 +1,32 @@ +export type NotebookFilterMode = 'all' | 'include' | 'exclude'; + +export interface NotebookFilterConfig { + mode: NotebookFilterMode; + selectedFolderIds: string[]; + includeSubNotebooks: boolean; +} + +export interface FolderItem { + id: string; + title: string; + parent_id: string; + noteCount?: number; + children?: FolderItem[]; +} + +export const DEFAULT_NOTEBOOK_FILTER: NotebookFilterConfig = { + mode: 'all', + selectedFolderIds: [], + includeSubNotebooks: true, +}; + +export function isValidFilterConfig(obj: unknown): obj is NotebookFilterConfig { + if (typeof obj !== 'object' || obj === null) return false; + const o = obj as Record; + return ( + (o.mode === 'all' || o.mode === 'include' || o.mode === 'exclude') && + Array.isArray(o.selectedFolderIds) && + o.selectedFolderIds.every((id) => typeof id === 'string') && + typeof o.includeSubNotebooks === 'boolean' + ); +} diff --git a/src/types/panel.ts b/src/types/panel.ts index ec8ca9c..3065573 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -1,6 +1,7 @@ import { BenchmarkResult } from './cluster'; +import { NotebookFilterConfig, FolderItem } from './notebook'; -export type { BenchmarkResult }; +export type { BenchmarkResult, NotebookFilterConfig, FolderItem }; export interface PanelNote { noteId: string; @@ -57,12 +58,15 @@ export type PanelMessage = // Webview → Plugin export type WebviewMessage = - | { type: 'run' } + | { type: 'run'; filterConfig?: NotebookFilterConfig } | { type: 'poll' } | { type: 'getInitialState' } | { type: 'syncState'; strategies: BenchmarkResult[]; notes: PanelNote[]; selectedStrategyIndex: number } | { type: 'openNote'; noteId: string } | { type: 'getSettings' } | { type: 'updateSetting'; key: string; value: string } + | { type: 'getNotebooks' } + | { type: 'getFilterConfig' } + | { type: 'saveFilterConfig'; filterConfig: NotebookFilterConfig } | ApplyMessage | { type: 'undo' }; diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx index 0a55644..dd5c5e0 100644 --- a/src/webview/components/Header.tsx +++ b/src/webview/components/Header.tsx @@ -1,11 +1,35 @@ import * as React from 'react'; +import { NotebookFilterConfig, FolderItem } from '../../types/notebook'; interface HeaderProps { isRunning: boolean; onRun: () => void; + filterConfig?: NotebookFilterConfig; + folders?: FolderItem[]; + onOpenFilterModal?: () => void; } -export const Header: React.FC = ({ isRunning, onRun }) => { +export const Header: React.FC = ({ isRunning, onRun, filterConfig, folders, onOpenFilterModal }) => { + const { fullLabel, shortLabel } = React.useMemo(() => { + if (!filterConfig || filterConfig.mode === 'all') { + return { fullLabel: 'All Notebooks', shortLabel: 'All' }; + } + // Filter out stale folder IDs that no longer exist + const folderIdSet = folders && folders.length > 0 ? new Set(folders.map((f) => f.id)) : null; + const validCount = folderIdSet + ? filterConfig.selectedFolderIds.filter((id) => folderIdSet.has(id)).length + : filterConfig.selectedFolderIds.length; + if (filterConfig.mode === 'include') { + return { fullLabel: `Include (${validCount})`, shortLabel: `Inc (${validCount})` }; + } + if (filterConfig.mode === 'exclude') { + return { fullLabel: `Exclude (${validCount})`, shortLabel: `Exc (${validCount})` }; + } + return { fullLabel: 'Notebooks', shortLabel: 'Filter' }; + }, [filterConfig, folders]); + + const isFiltered = filterConfig && filterConfig.mode !== 'all'; + return (
@@ -24,21 +48,50 @@ export const Header: React.FC = ({ isRunning, onRun }) => { Note Categorizer
- +
+ {onOpenFilterModal && ( + + )} + +
); }; diff --git a/src/webview/components/NotebookFilterModal.tsx b/src/webview/components/NotebookFilterModal.tsx new file mode 100644 index 0000000..0e197e3 --- /dev/null +++ b/src/webview/components/NotebookFilterModal.tsx @@ -0,0 +1,427 @@ +import * as React from 'react'; +import { FolderItem, NotebookFilterConfig, NotebookFilterMode } from '../../types/notebook'; +import { TreeNode } from './TreeNode'; + +interface NotebookFilterModalProps { + isOpen: boolean; + onClose: () => void; + filterConfig: NotebookFilterConfig; + onSave: (config: NotebookFilterConfig) => void; + folderTree: FolderItem[]; + folders: FolderItem[]; + counts: { [folderId: string]: number }; + isLoading: boolean; + onRefresh: () => void; +} + +export const NotebookFilterModal: React.FC = ({ + isOpen, + onClose, + filterConfig, + onSave, + folderTree, + folders, + counts, + isLoading, + onRefresh, +}) => { + const [mode, setMode] = React.useState(filterConfig.mode); + const [includeIds, setIncludeIds] = React.useState>(new Set()); + const [excludeIds, setExcludeIds] = React.useState>(new Set()); + const [includeSubNotebooks, setIncludeSubNotebooks] = React.useState( + filterConfig.includeSubNotebooks ?? true, + ); + const [searchQuery, setSearchQuery] = React.useState(''); + const [expandedIds, setExpandedIds] = React.useState>(new Set()); + const wasOpenRef = React.useRef(false); + + // Derive the active selection from the current mode + const selectedIds = mode === 'include' ? includeIds : excludeIds; + const setSelectedIds = mode === 'include' ? setIncludeIds : setExcludeIds; + + // Memoized map of parentId -> child folder IDs + const childrenMap = React.useMemo(() => { + const map = new Map(); + for (const f of folders) { + const pid = f.parent_id || ''; + const list = map.get(pid) || []; + list.push(f.id); + map.set(pid, list); + } + return map; + }, [folders]); + + // Fast BFS descendant collector + const getDescendants = React.useCallback( + (rootIds: Set): Set => { + const res = new Set(); + const queue = Array.from(rootIds); + let idx = 0; + while (idx < queue.length) { + const cur = queue[idx++]; + res.add(cur); + const kids = childrenMap.get(cur); + if (kids) { + for (const kid of kids) { + if (!res.has(kid)) { + res.add(kid); + queue.push(kid); + } + } + } + } + return res; + }, + [childrenMap], + ); + + // Precomputed visible folder IDs for search matching (O(n) once per search query change) + const visibleFolderIds = React.useMemo(() => { + if (!searchQuery.trim()) return null; + const query = searchQuery.trim().toLowerCase(); + const visible = new Set(); + + const checkVisibility = (node: FolderItem): boolean => { + const selfMatch = node.title.toLowerCase().includes(query); + let hasChildMatch = false; + if (node.children && node.children.length > 0) { + for (const child of node.children) { + if (checkVisibility(child)) { + hasChildMatch = true; + } + } + } + if (selfMatch || hasChildMatch) { + visible.add(node.id); + return true; + } + return false; + }; + + folderTree.forEach(checkVisibility); + return visible; + }, [searchQuery, folderTree]); + + // Compute implicitly selected folder IDs (children of selected parents when includeSubNotebooks is on) + const implicitIds = React.useMemo(() => { + if (!includeSubNotebooks || mode === 'all' || selectedIds.size === 0) return new Set(); + + const descendants = getDescendants(selectedIds); + const result = new Set(); + for (const id of descendants) { + if (!selectedIds.has(id)) { + result.add(id); + } + } + return result; + }, [includeSubNotebooks, mode, selectedIds, getDescendants]); + + // Expand all top-level nodes by default when folderTree is available + React.useEffect(() => { + if (folderTree && folderTree.length > 0) { + setExpandedIds((prev) => { + if (prev.size === 0) { + return new Set(folderTree.map((f) => f.id)); + } + return prev; + }); + } + }, [folderTree]); + + // Sync local state ONLY when modal transitions from closed to open + React.useEffect(() => { + if (isOpen && !wasOpenRef.current) { + const savedMode = filterConfig.mode || 'all'; + const savedIds = new Set(filterConfig.selectedFolderIds || []); + setMode(savedMode); + // Load saved IDs into the correct mode's state; reset the other + setIncludeIds(savedMode === 'include' ? savedIds : new Set()); + setExcludeIds(savedMode === 'exclude' ? savedIds : new Set()); + setIncludeSubNotebooks(filterConfig.includeSubNotebooks ?? true); + setSearchQuery(''); + onRefresh(); + } + wasOpenRef.current = isOpen; + }, [isOpen]); + + // Handle Escape key to close modal + React.useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const handleToggleFolder = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleToggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleSelectAll = () => { + setSelectedIds(new Set(folders.map((f) => f.id))); + }; + + const handleClearAll = () => { + setSelectedIds(new Set()); + }; + + const handleSave = () => { + onSave({ + mode, + selectedFolderIds: mode === 'all' ? [] : Array.from(selectedIds), + includeSubNotebooks, + }); + onClose(); + }; + + // Calculate estimated matching notes count + const estimatedNoteCount = React.useMemo(() => { + const validFolderIds = new Set(folders.map((f) => f.id)); + + if (mode === 'all') { + return Object.entries(counts).reduce((sum, [id, count]) => (validFolderIds.has(id) ? sum + count : sum), 0); + } + + const effectiveIds = includeSubNotebooks ? getDescendants(selectedIds) : selectedIds; + + if (mode === 'include') { + let total = 0; + effectiveIds.forEach((id) => { + total += counts[id] || 0; + }); + return total; + } + + if (mode === 'exclude') { + let total = 0; + folders.forEach((f) => { + if (!effectiveIds.has(f.id)) { + total += counts[f.id] || 0; + } + }); + return total; + } + + return 0; + }, [mode, selectedIds, includeSubNotebooks, folders, counts, getDescendants]); + + const staleFolderCount = React.useMemo(() => { + if (mode === 'all') return 0; + const currentFolderIds = new Set(folders.map((f) => f.id)); + return Array.from(selectedIds).filter((id) => !currentFolderIds.has(id)).length; + }, [mode, selectedIds, folders]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+ + + + Notebook Filter +
+ +
+ +
+ {/* Mode Segmented Control */} +
+ + + +
+ +
+ {mode === 'all' && 'Categorizes all active notes across your entire Joplin workspace.'} + {mode === 'include' && + (includeSubNotebooks + ? 'Only categorizes notes residing in the selected notebooks and their sub-notebooks.' + : 'Only categorizes notes residing in the selected notebooks (sub-notebooks not included).')} + {mode === 'exclude' && + (includeSubNotebooks + ? 'Categorizes all notes EXCEPT those in the selected notebooks and their sub-notebooks.' + : 'Categorizes all notes EXCEPT those directly in the selected notebooks.')} +
+ + {mode !== 'all' && ( + <> + {/* Controls Row */} +
+
+ + + + + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+ +
+ + +
+
+ + {/* Recursive Sub-notebooks Toggle */} + + + {/* Tree View */} +
+ {isLoading ? ( +
Loading notebooks...
+ ) : folderTree.length === 0 ? ( +
No notebooks found.
+ ) : ( + folderTree.map((node) => ( + + )) + )} +
+ + )} +
+ +
+
+ {estimatedNoteCount} notes matching filter + {staleFolderCount > 0 && ( + + {`⚠ ${staleFolderCount} selected notebook${staleFolderCount !== 1 ? 's' : ''} no longer exist${staleFolderCount === 1 ? 's' : ''}`} + + )} +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/webview/components/TreeNode.tsx b/src/webview/components/TreeNode.tsx new file mode 100644 index 0000000..0707582 --- /dev/null +++ b/src/webview/components/TreeNode.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { FolderItem } from '../../types/notebook'; + +export interface TreeNodeProps { + node: FolderItem; + level: number; + selectedIds: Set; + implicitIds: Set; + onToggle: (id: string) => void; + searchQuery: string; + parentMatched?: boolean; + expandedIds: Set; + onToggleExpand: (id: string) => void; + visibleFolderIds: Set | null; +} + +export const TreeNode: React.FC = ({ + node, + level, + selectedIds, + implicitIds, + onToggle, + searchQuery, + parentMatched, + expandedIds, + onToggleExpand, + visibleFolderIds, +}) => { + const isSelected = selectedIds.has(node.id); + const isImplicit = !isSelected && implicitIds.has(node.id); + const hasChildren = Boolean(node.children && node.children.length > 0); + const isExpanded = expandedIds.has(node.id); + + const checkboxRef = React.useRef(null); + + // Set indeterminate state for implicitly selected checkboxes + React.useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = isImplicit; + } + }, [isImplicit]); + + // Fast O(1) visibility check using precomputed visibleFolderIds set + if (!parentMatched && visibleFolderIds && !visibleFolderIds.has(node.id)) { + return null; + } + + const thisNodeMatches = !searchQuery || node.title.toLowerCase().includes(searchQuery.toLowerCase()); + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + + +
+ + {hasChildren && (isExpanded || searchQuery.length > 0) && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx index 33a2fc4..30ac401 100644 --- a/src/webview/context/AppStateContext.tsx +++ b/src/webview/context/AppStateContext.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { PanelNote, BenchmarkResult, ProgressState, ApplyOptions, PanelMessage } from '../../types/panel'; +import { NotebookFilterConfig, FolderItem } from '../../types/notebook'; import { useSettingsState } from './useSettingsState'; import { useApplyState } from './useApplyState'; import { usePipelineState } from './usePipelineState'; +import { useNotebookFilter } from './useNotebookFilter'; const POLL_INTERVAL_MS = 500; @@ -19,7 +21,7 @@ interface AppStateContextType { activeView: ViewType; isNativeAiUsed: boolean; isAiNamingUsed: boolean; - runPipeline: () => void; + runPipeline: (filterConfig?: NotebookFilterConfig) => void; changeStrategy: (index: number) => void; setView: (view: ViewType) => void; updateClusterName: (clusterId: number, newName: string) => void; @@ -35,6 +37,17 @@ interface AppStateContextType { updateSetting: (key: string, value: string) => Promise; fetchSettings: () => Promise; + // notebook filter states + filterConfig: NotebookFilterConfig; + folders: FolderItem[]; + folderTree: FolderItem[]; + counts: { [folderId: string]: number }; + isFilterModalOpen: boolean; + isLoadingNotebooks: boolean; + setIsFilterModalOpen: (open: boolean) => void; + fetchNotebooksAndFilter: () => Promise; + saveFilter: (newConfig: NotebookFilterConfig) => Promise; + // apply states isApplying: boolean; applyProgress: { current: number; total: number }; @@ -66,6 +79,19 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil // Initialize settings hook const { settings, hasChangeLog, fetchSettings, updateSetting } = useSettingsState(); + // Initialize notebook filter hook + const { + filterConfig, + folders, + folderTree, + counts, + isFilterModalOpen, + isLoadingNotebooks, + setIsFilterModalOpen, + fetchNotebooksAndFilter, + saveFilter, + } = useNotebookFilter(); + // Initialize apply state hook const { isApplying, @@ -119,6 +145,13 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setIsAiNamingUsed, } = usePipelineState(() => startPolling(), resetApplyState); + const handleRunPipeline = React.useCallback( + (overrideFilter?: NotebookFilterConfig) => { + runPipeline(overrideFilter || filterConfig); + }, + [runPipeline, filterConfig], + ); + const handlePollResponse = React.useCallback( (msg: PanelMessage | { type: 'idle' }) => { const processMessage = (m: PanelMessage | { type: 'idle' }) => { @@ -274,7 +307,7 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil pollIntervalRef.current = setInterval(async () => { if (typeof webviewApi === 'undefined') return; try { - const state = await webviewApi.postMessage({ type: 'poll' }); + const state = await webviewApi.postMessage({ type: 'poll' }); if (state) { handlePollResponseRef.current(state); } @@ -288,7 +321,7 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil fetchSettings(); if (typeof webviewApi !== 'undefined') { webviewApi - .postMessage({ type: 'getInitialState' }) + .postMessage({ type: 'getInitialState' }) .then((initialState) => { if (initialState) { handlePollResponse(initialState); @@ -356,7 +389,7 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil activeView, isNativeAiUsed, isAiNamingUsed, - runPipeline, + runPipeline: handleRunPipeline, changeStrategy, setView, updateClusterName, @@ -365,6 +398,15 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil settings, updateSetting, fetchSettings, + filterConfig, + folders, + folderTree, + counts, + isFilterModalOpen, + isLoadingNotebooks, + setIsFilterModalOpen, + fetchNotebooksAndFilter, + saveFilter, isApplying, applyProgress, applyError, diff --git a/src/webview/context/useNotebookFilter.ts b/src/webview/context/useNotebookFilter.ts new file mode 100644 index 0000000..9c42df5 --- /dev/null +++ b/src/webview/context/useNotebookFilter.ts @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { DEFAULT_NOTEBOOK_FILTER, FolderItem, NotebookFilterConfig } from '../../types/notebook'; + +interface FilterConfigResponse { + filterConfig?: NotebookFilterConfig; +} + +interface NotebooksResponse { + folders?: FolderItem[]; + folderTree?: FolderItem[]; + counts?: { [folderId: string]: number }; +} + +export function useNotebookFilter() { + const [filterConfig, setFilterConfig] = React.useState(DEFAULT_NOTEBOOK_FILTER); + const [folders, setFolders] = React.useState([]); + const [folderTree, setFolderTree] = React.useState([]); + const [counts, setCounts] = React.useState<{ [folderId: string]: number }>({}); + const [isFilterModalOpen, setIsFilterModalOpen] = React.useState(false); + const [isLoadingNotebooks, setIsLoadingNotebooks] = React.useState(false); + + const fetchNotebooksAndFilter = React.useCallback(async () => { + if (typeof webviewApi === 'undefined') return; + setIsLoadingNotebooks(true); + try { + const [filterRes, notebooksRes] = await Promise.all([ + webviewApi.postMessage({ type: 'getFilterConfig' }), + webviewApi.postMessage({ type: 'getNotebooks' }), + ]); + + if (filterRes && filterRes.filterConfig) { + setFilterConfig(filterRes.filterConfig); + } + if (notebooksRes) { + setFolders(notebooksRes.folders || []); + setFolderTree(notebooksRes.folderTree || []); + setCounts(notebooksRes.counts || {}); + } + } catch (err) { + console.error('Error fetching notebooks or filter:', err); + } finally { + setIsLoadingNotebooks(false); + } + }, []); + + const saveFilter = React.useCallback(async (newConfig: NotebookFilterConfig) => { + setFilterConfig(newConfig); + if (typeof webviewApi !== 'undefined') { + try { + await webviewApi.postMessage({ + type: 'saveFilterConfig', + filterConfig: newConfig, + }); + } catch (err) { + console.error('Error saving filter config:', err); + } + } + }, []); + + React.useEffect(() => { + fetchNotebooksAndFilter(); + }, [fetchNotebooksAndFilter]); + + return { + filterConfig, + setFilterConfig, + folders, + folderTree, + counts, + isFilterModalOpen, + setIsFilterModalOpen, + isLoadingNotebooks, + fetchNotebooksAndFilter, + saveFilter, + }; +} diff --git a/src/webview/context/usePipelineState.ts b/src/webview/context/usePipelineState.ts index a91f2bb..6e4b9d0 100644 --- a/src/webview/context/usePipelineState.ts +++ b/src/webview/context/usePipelineState.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { PanelNote, BenchmarkResult, ProgressState } from '../../types/panel'; +import { NotebookFilterConfig } from '../../types/notebook'; import { ViewType } from './AppStateContext'; export function usePipelineState(startPolling: () => void, resetApplyState: () => void) { @@ -19,7 +20,7 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = const [isNativeAiUsed, setIsNativeAiUsed] = React.useState(true); const [isAiNamingUsed, setIsAiNamingUsed] = React.useState(true); - const runPipeline = async () => { + const runPipeline = async (filterConfig?: NotebookFilterConfig) => { setIsRunning(true); setStatusText('Starting pipeline...'); setProgress({ current: 0, total: 0, cached: 0, skipped: 0 }); @@ -39,7 +40,7 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = setIsRunning(false); return; } - await webviewApi.postMessage({ type: 'run' }); + await webviewApi.postMessage({ type: 'run', filterConfig }); } catch (err) { setError('Failed to start pipeline: ' + String(err)); setIsRunning(false); diff --git a/src/webview/context/useSettingsState.ts b/src/webview/context/useSettingsState.ts index 369a7b8..2a75bc3 100644 --- a/src/webview/context/useSettingsState.ts +++ b/src/webview/context/useSettingsState.ts @@ -20,9 +20,9 @@ export function useSettingsState() { const fetchSettings = React.useCallback(async () => { try { if (typeof webviewApi === 'undefined') return; - const res = await webviewApi.postMessage({ type: 'getSettings' }); + const res = await webviewApi.postMessage({ type: 'getSettings' }); if (res) { - const data = res as unknown as SettingsResponse; + const data = res; setSettings({ parentNotebook: data['categorization.parentNotebook'] || '', changeLog: data['categorization.changeLog'] || '', diff --git a/src/webview/globals.d.ts b/src/webview/globals.d.ts index afc2125..b21a9c6 100644 --- a/src/webview/globals.d.ts +++ b/src/webview/globals.d.ts @@ -1,4 +1,4 @@ -import type { WebviewMessage, PanelMessage } from '../types/panel'; +import type { WebviewMessage } from '../types/panel'; /** * Joplin injects this global into panel webviews at runtime. @@ -6,7 +6,7 @@ import type { WebviewMessage, PanelMessage } from '../types/panel'; * and returns the handler's response (PanelMessage or undefined). */ interface JoplinWebviewApi { - postMessage(message: WebviewMessage): Promise; + postMessage(message: WebviewMessage): Promise; } declare global { diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index 068e7e7..7ee71bd 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -30,6 +30,9 @@ export const DashboardPage: React.FC = () => { updateSetting, isNativeAiUsed, isAiNamingUsed, + filterConfig, + folders, + setIsFilterModalOpen, } = useAppState(); const selectedStrategy = strategies[selectedStrategyIndex]; @@ -107,7 +110,13 @@ export const DashboardPage: React.FC = () => { return (
-
+
runPipeline()} + filterConfig={filterConfig} + folders={folders} + onOpenFilterModal={() => setIsFilterModalOpen(true)} + /> {!isNativeAiUsed && !isNativeAiDismissed && ( { undoProgress, undoSuccess, undoError, + filterConfig, + folders, + setIsFilterModalOpen, } = useAppState(); const [isDismissed, setIsDismissed] = React.useState(false); return (
-
+
runPipeline()} + filterConfig={filterConfig} + folders={folders} + onOpenFilterModal={() => setIsFilterModalOpen(true)} + /> {isRunning && !isNativeAiUsed && !isDismissed && ( { - const { activeView, error } = useAppState(); + const { + activeView, + error, + isFilterModalOpen, + setIsFilterModalOpen, + filterConfig, + saveFilter, + folderTree, + folders, + counts, + isLoadingNotebooks, + fetchNotebooksAndFilter, + } = useAppState(); return (
{error &&
Error: {error}
}
{activeView === 'idle' ? : }
+ + setIsFilterModalOpen(false)} + filterConfig={filterConfig} + onSave={saveFilter} + folderTree={folderTree} + folders={folders} + counts={counts} + isLoading={isLoadingNotebooks} + onRefresh={fetchNotebooksAndFilter} + />
); }; diff --git a/test/panel/setupPanel.test.ts b/test/panel/setupPanel.test.ts index 5fafc87..1bad4b5 100644 --- a/test/panel/setupPanel.test.ts +++ b/test/panel/setupPanel.test.ts @@ -3,6 +3,7 @@ import joplin from 'api'; import { setupPanel } from '../../src/panel/setupPanel'; import { OperationState } from '../../src/settings/registerSettings'; import { WebviewMessage } from '../../src/types/panel'; +import { NotebookFilterConfig, DEFAULT_NOTEBOOK_FILTER } from '../../src/types/notebook'; jest.mock('api', () => ({ __esModule: true, @@ -19,6 +20,9 @@ jest.mock('api', () => ({ onMessage: jest.fn(), }, }, + data: { + get: jest.fn(), + }, commands: { execute: jest.fn().mockResolvedValue(undefined), }, @@ -348,4 +352,116 @@ describe('setupPanel & getInitialState persistence', () => { expect.any(Function), ); }); + + it('handles getFilterConfig and saveFilterConfig', async () => { + const mockFilter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['folder-1'], + includeSubNotebooks: true, + }; + (joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify(mockFilter)); + + const res = await messageHandler({ type: 'getFilterConfig' }); + expect(res.filterConfig).toEqual(mockFilter); + + const updatedFilter: NotebookFilterConfig = { + mode: 'exclude', + selectedFolderIds: ['folder-2'], + includeSubNotebooks: false, + }; + await messageHandler({ + type: 'saveFilterConfig', + filterConfig: updatedFilter, + }); + + expect(joplin.settings.setValue).toHaveBeenCalledWith( + 'categorization.notebookFilter', + JSON.stringify(updatedFilter), + ); + }); + + it('handles getFilterConfig when setting has invalid/corrupted JSON', async () => { + // Corrupted string + (joplin.settings.value as jest.Mock).mockResolvedValue('not-valid-json{'); + let res = await messageHandler({ type: 'getFilterConfig' }); + expect(res.filterConfig).toEqual(DEFAULT_NOTEBOOK_FILTER); + + // Invalid schema shape + (joplin.settings.value as jest.Mock).mockResolvedValue( + JSON.stringify({ mode: 'invalid_mode', selectedFolderIds: 123 }), + ); + res = await messageHandler({ type: 'getFilterConfig' }); + expect(res.filterConfig).toEqual(DEFAULT_NOTEBOOK_FILTER); + }); + + it('passes filterConfig to runPipeline when run message is received', async () => { + const { runPipeline } = jest.requireMock('../../src/pipeline/runPipeline'); + const mockFilter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['folder-123'], + includeSubNotebooks: true, + }; + + await messageHandler({ + type: 'run', + filterConfig: mockFilter, + }); + + expect(runPipeline).toHaveBeenCalledWith('/mock/install/dir', expect.any(Object), mockFilter); + }); + + it('getNotebooks handler returns folders, folderTree, and counts', async () => { + const mockFolders = [ + { id: 'f1', title: 'Work', parent_id: '' }, + { id: 'f2', title: 'Personal', parent_id: '' }, + { id: 'f3', title: 'Sub-Work', parent_id: 'f1' }, + ]; + + (joplin.data.get as jest.Mock).mockImplementation((path: string[]) => { + if (path[0] === 'folders') { + return Promise.resolve({ items: mockFolders, has_more: false }); + } + if (path[0] === 'notes') { + return Promise.resolve({ + items: [{ parent_id: 'f1' }, { parent_id: 'f1' }, { parent_id: 'f2' }], + has_more: false, + }); + } + return Promise.resolve({ items: [], has_more: false }); + }); + + const result = await messageHandler({ type: 'getNotebooks' }); + expect(result.folders).toHaveLength(3); + expect(result.folderTree).toHaveLength(2); // 2 root folders + expect(result.counts['f1']).toBe(2); + expect(result.counts['f2']).toBe(1); + expect(result.counts['f3']).toBeUndefined(); + }); + + it('run handler falls back to saved notebookFilter setting when msg.filterConfig is undefined', async () => { + const { runPipeline } = jest.requireMock('../../src/pipeline/runPipeline'); + const savedFilter: NotebookFilterConfig = { + mode: 'exclude', + selectedFolderIds: ['folder-abc'], + includeSubNotebooks: false, + }; + + (joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify(savedFilter)); + + await messageHandler({ type: 'run' }); + + expect(runPipeline).toHaveBeenCalledWith('/mock/install/dir', expect.any(Object), savedFilter); + }); + + it('run handler falls back to DEFAULT_NOTEBOOK_FILTER when saved setting is corrupt/invalid', async () => { + const { runPipeline } = jest.requireMock('../../src/pipeline/runPipeline'); + + (joplin.settings.value as jest.Mock).mockResolvedValue('invalid-json{{{'); + await messageHandler({ type: 'run' }); + expect(runPipeline).toHaveBeenCalledWith('/mock/install/dir', expect.any(Object), DEFAULT_NOTEBOOK_FILTER); + + (joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify({ mode: 'invalid_mode' })); + await messageHandler({ type: 'run' }); + expect(runPipeline).toHaveBeenCalledWith('/mock/install/dir', expect.any(Object), DEFAULT_NOTEBOOK_FILTER); + }); }); diff --git a/test/pipeline/noteReader.test.ts b/test/pipeline/noteReader.test.ts new file mode 100644 index 0000000..12f64a1 --- /dev/null +++ b/test/pipeline/noteReader.test.ts @@ -0,0 +1,311 @@ +import joplin from 'api'; +import { + buildFolderChildrenMap, + getDescendantFolderIds, + resolveEffectiveFolderIds, + buildFolderTree, + fetchAllNotes, + fetchAllJoplinNoteIds, +} from '../../src/pipeline/noteReader'; +import { FolderItem, NotebookFilterConfig } from '../../src/types/notebook'; + +jest.mock('api', () => ({ + __esModule: true, + default: { + data: { + get: jest.fn(), + }, + }, +})); + +describe('noteReader pipeline and filtering helpers', () => { + const mockFolders: FolderItem[] = [ + { id: 'f-root-1', title: 'Work', parent_id: '' }, + { id: 'f-child-1', title: 'Project A', parent_id: 'f-root-1' }, + { id: 'f-subchild-1', title: 'Specs', parent_id: 'f-child-1' }, + { id: 'f-child-2', title: 'Project B', parent_id: 'f-root-1' }, + { id: 'f-root-2', title: 'Personal', parent_id: '' }, + { id: 'f-child-3', title: 'Finance', parent_id: 'f-root-2' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('buildFolderChildrenMap and getDescendantFolderIds', () => { + it('builds a map of parent folder ID to children IDs correctly', () => { + const map = buildFolderChildrenMap(mockFolders); + expect(map.get('')).toEqual(['f-root-1', 'f-root-2']); + expect(map.get('f-root-1')).toEqual(['f-child-1', 'f-child-2']); + expect(map.get('f-child-1')).toEqual(['f-subchild-1']); + expect(map.get('f-root-2')).toEqual(['f-child-3']); + }); + + it('collects all descendants recursively', () => { + const map = buildFolderChildrenMap(mockFolders); + const descendants = getDescendantFolderIds(['f-root-1'], map); + expect(descendants).toEqual(new Set(['f-root-1', 'f-child-1', 'f-subchild-1', 'f-child-2'])); + }); + }); + + describe('resolveEffectiveFolderIds', () => { + it('returns all folder IDs when mode is "all" or filterConfig is undefined', () => { + const allIds = new Set(mockFolders.map((f) => f.id)); + expect(resolveEffectiveFolderIds(mockFolders, undefined)).toEqual(allIds); + expect( + resolveEffectiveFolderIds(mockFolders, { + mode: 'all', + selectedFolderIds: [], + includeSubNotebooks: true, + }), + ).toEqual(allIds); + }); + + it('includes only selected folders when includeSubNotebooks is false in include mode', () => { + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['f-root-1'], + includeSubNotebooks: false, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + expect(result).toEqual(new Set(['f-root-1'])); + }); + + it('includes selected folders and all sub-folders recursively when includeSubNotebooks is true', () => { + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['f-root-1'], + includeSubNotebooks: true, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + expect(result).toEqual(new Set(['f-root-1', 'f-child-1', 'f-subchild-1', 'f-child-2'])); + }); + + it('returns empty set if include mode has empty selection', () => { + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: [], + includeSubNotebooks: true, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + expect(result).toEqual(new Set()); + }); + + it('excludes selected folders and their sub-folders when in exclude mode', () => { + const filter: NotebookFilterConfig = { + mode: 'exclude', + selectedFolderIds: ['f-root-1'], + includeSubNotebooks: true, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + expect(result).toEqual(new Set(['f-root-2', 'f-child-3'])); + }); + + it('excludes only direct selected folder when includeSubNotebooks is false in exclude mode', () => { + const filter: NotebookFilterConfig = { + mode: 'exclude', + selectedFolderIds: ['f-root-1'], + includeSubNotebooks: false, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + expect(result).toEqual(new Set(['f-child-1', 'f-subchild-1', 'f-child-2', 'f-root-2', 'f-child-3'])); + }); + }); + + describe('buildFolderTree', () => { + it('builds a nested tree structure with note counts', () => { + const counts = new Map([ + ['f-root-1', 5], + ['f-child-1', 10], + ['f-subchild-1', 2], + ['f-root-2', 8], + ]); + const tree = buildFolderTree(mockFolders, counts); + + expect(tree).toHaveLength(2); + expect(tree[0].id).toBe('f-root-1'); + expect(tree[0].noteCount).toBe(5); + expect(tree[0].children).toHaveLength(2); + expect(tree[0].children![0].id).toBe('f-child-1'); + expect(tree[0].children![0].noteCount).toBe(10); + expect(tree[0].children![0].children![0].id).toBe('f-subchild-1'); + expect(tree[0].children![0].children![0].noteCount).toBe(2); + + expect(tree[1].id).toBe('f-root-2'); + expect(tree[1].noteCount).toBe(8); + expect(tree[1].children![0].id).toBe('f-child-3'); + expect(tree[1].children![0].noteCount).toBe(0); + }); + }); + + describe('fetchAllNotes API integration', () => { + it('fetches and filters notes based on effective folder IDs', async () => { + (joplin.data.get as jest.Mock).mockImplementation((path: string[]) => { + if (path[0] === 'folders') { + return Promise.resolve({ + items: mockFolders, + has_more: false, + }); + } + if (path[0] === 'notes') { + return Promise.resolve({ + items: [ + { + id: 'n1', + title: 'Work note', + body: 'b1', + updated_time: 1, + user_updated_time: 1, + parent_id: 'f-child-1', + }, + { + id: 'n2', + title: 'Personal note', + body: 'b2', + updated_time: 1, + user_updated_time: 1, + parent_id: 'f-child-3', + }, + { + id: 'n3', + title: 'Orphan note', + body: 'b3', + updated_time: 1, + user_updated_time: 1, + parent_id: 'non-existent-folder', + }, + ], + has_more: false, + }); + } + return Promise.resolve({ items: [], has_more: false }); + }); + + // Filter to include only Personal + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['f-root-2'], + includeSubNotebooks: true, + }; + + const notes = await fetchAllNotes(filter); + expect(notes).toHaveLength(1); + expect(notes[0].id).toBe('n2'); + }); + + it('returns empty array when filter matches 0 folders', async () => { + (joplin.data.get as jest.Mock).mockImplementation((path: string[]) => { + if (path[0] === 'folders') { + return Promise.resolve({ + items: mockFolders, + has_more: false, + }); + } + return Promise.resolve({ items: [], has_more: false }); + }); + + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: [], + includeSubNotebooks: true, + }; + + const notes = await fetchAllNotes(filter); + expect(notes).toEqual([]); + }); + }); + + describe('fetchAllJoplinNoteIds', () => { + it('fetches all note IDs across multiple pages', async () => { + (joplin.data.get as jest.Mock).mockImplementation((_path: string[], opts: { page: number }) => { + if (opts.page === 1) { + return Promise.resolve({ + items: [{ id: 'n1' }, { id: 'n2' }], + has_more: true, + }); + } + return Promise.resolve({ + items: [{ id: 'n3' }], + has_more: false, + }); + }); + + const ids = await fetchAllJoplinNoteIds(); + expect(ids).toEqual(new Set(['n1', 'n2', 'n3'])); + expect(ids.size).toBe(3); + }); + + it('returns empty set when Joplin has no notes', async () => { + (joplin.data.get as jest.Mock).mockResolvedValue({ + items: [], + has_more: false, + }); + + const ids = await fetchAllJoplinNoteIds(); + expect(ids.size).toBe(0); + }); + }); + + describe('fetchAllNotes edge cases', () => { + it('returns all active notes when filterConfig is undefined', async () => { + (joplin.data.get as jest.Mock).mockImplementation((path: string[]) => { + if (path[0] === 'folders') { + return Promise.resolve({ + items: mockFolders, + has_more: false, + }); + } + if (path[0] === 'notes') { + return Promise.resolve({ + items: [ + { + id: 'n1', + title: 'Work note', + body: 'b1', + updated_time: 1, + user_updated_time: 1, + parent_id: 'f-child-1', + }, + { + id: 'n2', + title: 'Personal note', + body: 'b2', + updated_time: 1, + user_updated_time: 1, + parent_id: 'f-child-3', + }, + ], + has_more: false, + }); + } + return Promise.resolve({ items: [], has_more: false }); + }); + + const notes = await fetchAllNotes(undefined); + expect(notes).toHaveLength(2); + }); + + it('silently ignores stale folder IDs in selectedFolderIds', () => { + const filter: NotebookFilterConfig = { + mode: 'include', + selectedFolderIds: ['f-root-1', 'non-existent-folder-id'], + includeSubNotebooks: false, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + // Only the valid folder should be included + expect(result).toEqual(new Set(['f-root-1'])); + expect(result.has('non-existent-folder-id')).toBe(false); + }); + + it('returns all folders when exclude mode has only stale IDs', () => { + const filter: NotebookFilterConfig = { + mode: 'exclude', + selectedFolderIds: ['non-existent-1', 'non-existent-2'], + includeSubNotebooks: true, + }; + const result = resolveEffectiveFolderIds(mockFolders, filter); + // All stale IDs are filtered out, selectedSet becomes empty, so all folders returned + expect(result).toEqual(new Set(mockFolders.map((f) => f.id))); + }); + }); +});