diff --git a/docs/llm-enrichment.md b/docs/llm-enrichment.md index 4aa5296..f6de8d0 100644 --- a/docs/llm-enrichment.md +++ b/docs/llm-enrichment.md @@ -27,6 +27,40 @@ what Pass A produced. turns it on; it has no effect until AI-based semantic analysis is also enabled and has produced semantic edges to label. +## Cost and data handling + +Pass B sends text to whatever AI provider you have set up in Joplin's own AI +configuration, so the cost and privacy behavior follow that configuration, +not this plugin. Note Graph never stores or forwards note content anywhere +of its own accord; its only outbound traffic is the `joplin.ai.chat()` call +described below. Joplin routes that call to the provider you chose in +Joplin's Configuration screen (**AI** page). The plugin has no separate +server, no separate terms and no third-party destination of its own. + +What actually leaves your machine and what it costs therefore depends +entirely on that provider: + +- **Which provider.** `joplin.ai.chat()` uses whichever chat model Joplin is + configured to talk to. If you point Joplin at a local or self-hosted + model, note content stays on your machine; if you use a cloud provider, + the excerpts go to that provider's servers and are handled under its + terms. The plugin does not select or influence the provider. + +- **How much is sent.** Only notes and edges that already carry a semantic + edge are ever sent, in batches of 4, with each note body truncated to 300 + characters (`MAX_BODY_EXCERPT_LENGTH`). Unchanged notes and edges are + served from the in-memory cache and never re-sent, so re-running + enrichment on a mostly unchanged graph sends very little new text. + +- **Credentials and billing.** Any API key, account, rate limit, or billing + relationship belongs to Joplin's AI setup, not to Note Graph. The plugin + neither reads nor manages credentials and has no usage meter or cost + estimate of its own. + +In short, treat Pass B as an extension of Joplin's AI chat. Its cost and +privacy posture are whatever you already accepted when you enabled AI in +Joplin. The plugin adds nothing on top of that. + ## Where it runs: `LLMEnricher` `LLMEnricher` (`src/services/llm/LLMEnricher.ts`) is called from diff --git a/src/data/Database/GraphCacheRepository.test.ts b/src/data/Database/GraphCacheRepository.test.ts index 3680f97..3cda8c2 100644 --- a/src/data/Database/GraphCacheRepository.test.ts +++ b/src/data/Database/GraphCacheRepository.test.ts @@ -6,15 +6,26 @@ import { Note } from '../Types'; class FakeConnection implements IVectorDatabase { public opened = false; private graphRow: { notes_json: string; graph_json: string } | null = null; - private syncStateRow: { events_cursor: string | null; embeddings_cursor: string | null } | null = - null; + private syncStateRow: { + events_cursor: string | null; + embeddings_cursor: string | null; + } | null = null; + private scopeStateRow: { scope_key: string | null } | null = null; + private enrichmentRows: { + kind: string; + id: string; + updated_time: number; + enrichment_json: string; + }[] = []; public async open(): Promise { this.opened = true; } public async run(sql: string, params: unknown[]): Promise { - if (sql.includes('INTO graph_cache')) { + if (sql.includes('DELETE FROM graph_cache')) { + this.graphRow = null; + } else if (sql.includes('INTO graph_cache')) { const [notesJson, graphJson] = params as [string, string, number]; this.graphRow = { notes_json: notesJson, graph_json: graphJson }; } else if (sql.includes('embeddings_cursor')) { @@ -29,6 +40,28 @@ class FakeConnection implements IVectorDatabase { events_cursor: cursor, embeddings_cursor: this.syncStateRow?.embeddings_cursor ?? null, }; + } else if (sql.includes('INTO scope_state')) { + const [scopeKey] = params as [string]; + this.scopeStateRow = { scope_key: scopeKey }; + } else if (sql.includes('INTO enrichment_cache')) { + const [kind, id, updatedTime, enrichmentJson] = params as [ + string, + string, + number, + string + ]; + const existing = this.enrichmentRows.find((row) => row.kind === kind && row.id === id); + if (existing) { + existing.updated_time = updatedTime; + existing.enrichment_json = enrichmentJson; + } else { + this.enrichmentRows.push({ + kind, + id, + updated_time: updatedTime, + enrichment_json: enrichmentJson, + }); + } } } @@ -39,6 +72,12 @@ class FakeConnection implements IVectorDatabase { if (sql.includes('FROM sync_state')) { return (this.syncStateRow ? [this.syncStateRow] : []) as unknown as T[]; } + if (sql.includes('FROM scope_state')) { + return (this.scopeStateRow ? [this.scopeStateRow] : []) as unknown as T[]; + } + if (sql.includes('FROM enrichment_cache')) { + return this.enrichmentRows as unknown as T[]; + } return []; } } @@ -53,7 +92,9 @@ const note: Note = { }; const graphData: GraphData = { - nodes: [{ data: { id: 'n1', label: 'Note 1', noteId: 'n1', degree: 0, community: 0, size: 1 } }], + nodes: [ + { data: { id: 'n1', label: 'Note 1', noteId: 'n1', degree: 0, community: 0, size: 1 } }, + ], edges: [], }; @@ -136,6 +177,85 @@ describe('GraphCacheRepository', () => { }); }); + describe('scope key', () => { + it('returns null when no scope has ever been saved', async () => { + expect(await repo.loadScopeKey()).toBeNull(); + }); + + it('round-trips the scope key through save/load', async () => { + await repo.saveScopeKey('current:folder-1'); + expect(await repo.loadScopeKey()).toBe('current:folder-1'); + }); + + it('overwrites the previous scope key on a second save', async () => { + await repo.saveScopeKey('all'); + await repo.saveScopeKey('current:folder-2'); + expect(await repo.loadScopeKey()).toBe('current:folder-2'); + }); + }); + + describe('clearGraph', () => { + it('removes the cached graph so a later load returns null', async () => { + await repo.saveGraph([note], graphData); + + await repo.clearGraph(); + + expect(await repo.loadGraph()).toBeNull(); + }); + + it('leaves the events and embeddings cursors untouched', async () => { + await repo.saveGraph([note], graphData); + await repo.saveEventsCursor('cursor-1'); + await repo.saveEmbeddingsCursor('embeddings-cursor-1'); + + await repo.clearGraph(); + + expect(await repo.loadEventsCursor()).toBe('cursor-1'); + expect(await repo.loadEmbeddingsCursor()).toBe('embeddings-cursor-1'); + }); + }); + + describe('enrichment cache', () => { + it('returns an empty list when nothing has been persisted', async () => { + expect(await repo.loadEnrichments()).toEqual([]); + }); + + it('round-trips node and edge enrichments through save/load', async () => { + await repo.saveEnrichments([ + { kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Cat' } }, + { + kind: 'edge', + id: 'a::b::semantic', + updatedTime: 2, + enrichment: { relationshipLabel: 'links' }, + }, + ]); + + expect(await repo.loadEnrichments()).toEqual([ + { kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Cat' } }, + { + kind: 'edge', + id: 'a::b::semantic', + updatedTime: 2, + enrichment: { relationshipLabel: 'links' }, + }, + ]); + }); + + it('upserts on a repeated save for the same kind and id', async () => { + await repo.saveEnrichments([ + { kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Old' } }, + ]); + await repo.saveEnrichments([ + { kind: 'node', id: 'n1', updatedTime: 3, enrichment: { category: 'New' } }, + ]); + + expect(await repo.loadEnrichments()).toEqual([ + { kind: 'node', id: 'n1', updatedTime: 3, enrichment: { category: 'New' } }, + ]); + }); + }); + describe('write serialization', () => { it('serializes interleaved graph and cursor writes instead of racing them', async () => { const order: string[] = []; @@ -145,7 +265,10 @@ describe('GraphCacheRepository', () => { await originalRun(sql, params); }; - await Promise.all([repo.saveGraph([note], graphData), repo.saveEventsCursor('cursor-1')]); + await Promise.all([ + repo.saveGraph([note], graphData), + repo.saveEventsCursor('cursor-1'), + ]); expect(order).toHaveLength(2); expect(await repo.loadGraph()).not.toBeNull(); diff --git a/src/data/Database/GraphCacheRepository.ts b/src/data/Database/GraphCacheRepository.ts index fb4ba0c..bb10b2a 100644 --- a/src/data/Database/GraphCacheRepository.ts +++ b/src/data/Database/GraphCacheRepository.ts @@ -21,6 +21,23 @@ const SYNC_STATE_SCHEMA = ` ) `; +const SCOPE_STATE_SCHEMA = ` + CREATE TABLE IF NOT EXISTS scope_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + scope_key TEXT + ) +`; + +const ENRICHMENT_CACHE_SCHEMA = ` + CREATE TABLE IF NOT EXISTS enrichment_cache ( + kind TEXT NOT NULL, + id TEXT NOT NULL, + updated_time INTEGER NOT NULL, + enrichment_json TEXT NOT NULL, + PRIMARY KEY (kind, id) + ) +`; + interface GraphCacheRow { notes_json: string; graph_json: string; @@ -31,6 +48,24 @@ interface SyncStateRow { embeddings_cursor: string | null; } +interface ScopeStateRow { + scope_key: string | null; +} + +interface EnrichmentCacheRow { + kind: string; + id: string; + updated_time: number; + enrichment_json: string; +} + +export interface PersistedEnrichment { + kind: 'node' | 'edge'; + id: string; + updatedTime: number; + enrichment: Record; +} + export class GraphCacheRepository { private writeLock: Promise = Promise.resolve(); @@ -38,6 +73,8 @@ export class GraphCacheRepository { private readonly db: IVectorDatabase = new VectorDatabase(DB_FILE_NAME, [ GRAPH_CACHE_SCHEMA, SYNC_STATE_SCHEMA, + SCOPE_STATE_SCHEMA, + ENRICHMENT_CACHE_SCHEMA, ]) ) {} @@ -113,6 +150,79 @@ export class GraphCacheRepository { }); } + public async loadScopeKey(): Promise { + await this.db.open(); + const rows = await this.db.all( + 'SELECT scope_key FROM scope_state WHERE id = 1', + [] + ); + return rows[0]?.scope_key ?? null; + } + + public saveScopeKey(scopeKey: string): Promise { + return this.enqueueWrite(async () => { + await this.db.open(); + await this.db.run( + `INSERT INTO scope_state (id, scope_key) + VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET scope_key = excluded.scope_key`, + [scopeKey] + ); + }); + } + + public clearGraph(): Promise { + return this.enqueueWrite(async () => { + await this.db.open(); + await this.db.run('DELETE FROM graph_cache WHERE id = 1', []); + }); + } + + public async loadEnrichments(): Promise { + await this.db.open(); + const rows = await this.db.all( + 'SELECT kind, id, updated_time, enrichment_json FROM enrichment_cache', + [] + ); + return rows.map((row) => ({ + kind: row.kind === 'node' ? 'node' : 'edge', + id: row.id, + updatedTime: row.updated_time, + enrichment: JSON.parse(row.enrichment_json) as Record, + })); + } + + public saveEnrichments(records: PersistedEnrichment[]): Promise { + if (records.length === 0) return Promise.resolve(); + return this.enqueueWrite(async () => { + await this.db.open(); + await this.db.run('BEGIN TRANSACTION', []); + try { + for (const record of records) { + await this.db.run( + `INSERT INTO enrichment_cache (kind, id, updated_time, enrichment_json) + VALUES (?, ?, ?, ?) + ON CONFLICT(kind, id) DO UPDATE SET + updated_time = excluded.updated_time, + enrichment_json = excluded.enrichment_json`, + [record.kind, record.id, record.updatedTime, JSON.stringify(record.enrichment)] + ); + } + await this.db.run('COMMIT', []); + } catch (e) { + try { + await this.db.run('ROLLBACK', []); + } catch (rollbackError) { + console.error( + 'Enrichment cache rollback failed after a write error:', + rollbackError + ); + } + throw e; + } + }); + } + private enqueueWrite(write: () => Promise): Promise { const task = this.writeLock.then(write); this.writeLock = task.then( diff --git a/src/data/FolderRepository.test.ts b/src/data/FolderRepository.test.ts new file mode 100644 index 0000000..30b5c09 --- /dev/null +++ b/src/data/FolderRepository.test.ts @@ -0,0 +1,75 @@ +import { FolderRepository } from './FolderRepository'; +import joplin from 'api'; + +const mockGet = joplin.data.get as jest.Mock; + +describe('FolderRepository', () => { + let repo: FolderRepository; + + beforeEach(() => { + repo = new FolderRepository(); + jest.clearAllMocks(); + }); + + it('fetches all folders when single page', async () => { + mockGet.mockResolvedValueOnce({ + items: [{ id: '1', parent_id: '', title: 'Notebook 1' }], + has_more: false, + }); + + const { folders, truncated } = await repo.getAllFolders(); + + expect(truncated).toBe(false); + expect(folders).toHaveLength(1); + expect(folders[0].title).toBe('Notebook 1'); + expect(mockGet).toHaveBeenCalledWith(['folders'], { + fields: ['id', 'parent_id', 'title'], + limit: 100, + page: 1, + }); + }); + + it('fetches all folders across multiple pages', async () => { + mockGet + .mockResolvedValueOnce({ items: [{ id: '1' }, { id: '2' }], has_more: true }) + .mockResolvedValueOnce({ items: [{ id: '3' }], has_more: false }); + + const { folders, truncated } = await repo.getAllFolders(); + + expect(truncated).toBe(false); + expect(folders).toHaveLength(3); + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('returns collected folders with truncated true when a page fails', async () => { + mockGet + .mockResolvedValueOnce({ items: [{ id: '1' }], has_more: true }) + .mockRejectedValueOnce(new Error('network error')); + + const { folders, truncated } = await repo.getAllFolders(); + + expect(truncated).toBe(true); + expect(folders).toHaveLength(1); + }); + + it('stops at maxFolders and returns truncated true', async () => { + mockGet.mockResolvedValueOnce({ + items: Array.from({ length: 50 }, (_, i) => ({ id: `${i + 1}` })), + has_more: true, + }); + + const { folders, truncated } = await repo.getAllFolders(30); + + expect(truncated).toBe(true); + expect(folders).toHaveLength(30); + }); + + it('handles missing items in response gracefully', async () => { + mockGet.mockResolvedValueOnce({ has_more: false }); + + const { folders, truncated } = await repo.getAllFolders(); + + expect(truncated).toBe(false); + expect(folders).toEqual([]); + }); +}); diff --git a/src/data/FolderRepository.ts b/src/data/FolderRepository.ts new file mode 100644 index 0000000..3cfd3cb --- /dev/null +++ b/src/data/FolderRepository.ts @@ -0,0 +1,41 @@ +import joplin from 'api'; + +const FOLDER_FIELDS = ['id', 'parent_id', 'title']; + +export interface Folder { + id: string; + parent_id: string; + title: string; +} + +export class FolderRepository { + public async getAllFolders( + maxFolders = 5000 + ): Promise<{ folders: Folder[]; truncated: boolean }> { + const folders: Folder[] = []; + let page = 1; + let hasMore = true; + while (hasMore) { + const remaining = maxFolders - folders.length; + if (remaining <= 0) { + return { folders, truncated: true }; + } + + try { + const response = await joplin.data.get(['folders'], { + fields: FOLDER_FIELDS, + limit: Math.min(remaining, 100), + page, + }); + const items: Folder[] = response.items ?? []; + folders.push(...items.slice(0, remaining)); + hasMore = response.has_more === true; + page++; + } catch (error) { + console.error('Failed to fetch folders page:', error); + return { folders, truncated: true }; + } + } + return { folders, truncated: false }; + } +} diff --git a/src/index.ts b/src/index.ts index ea2a40d..c854901 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,15 @@ import { postStatus, postProgress, postEnrichmentProgress, + postFocusNote, + isNoteGraphPanelVisible, + ScopeState, + NotebookOption, } from './ui/webview'; import { NoteRepository } from './data/NoteRepository'; import { NotePreprocessor } from './data/NotePreprocessor'; import { EventsRepository } from './data/EventsRepository'; +import { FolderRepository } from './data/FolderRepository'; import { GraphCacheRepository } from './data/Database/GraphCacheRepository'; import { GraphBuilder } from './services/graph/GraphBuilder'; import { Note } from './data/Types'; @@ -23,29 +28,51 @@ import { registerGraphSettings, isAiAnalysisEnabled, isLlmEnrichmentEnabled, + getScopeSettings, AI_ANALYSIS_ENABLED_KEY, RETRY_EMBEDDING_KEY, RETRY_ENRICHMENT_KEY, NOTE_GRAPH_SETTING_KEYS, + SCOPE_SETTING_KEYS, + SCOPE_MODE_KEY, + SCOPE_SELECTED_NOTEBOOKS_KEY, } from './services/settings/GraphSettings'; +import { NoteScopeResolver, ResolvedScope, ScopeMode, currentScopeKey } from './services/settings/NoteScopeResolver'; const SHOW_NOTE_GRAPH_COMMAND = 'showNoteGraph'; const SHOW_NOTE_GRAPH_MENU_ITEM = 'showNoteGraphMenuItem'; const graphCache = new GraphCacheRepository(); const analysisController = new AnalysisController(new GraphBuilder(), graphCache); +const noteScopeResolver = new NoteScopeResolver(); + +let currentScope: ResolvedScope = { folderIds: null, scopeKey: 'all' }; +let currentScopeMode: ScopeMode = 'all'; /** * Loads all notes from the Joplin API and enriches them with links and tags. * @returns enriched notes ready for graph building. */ -export const loadNotes = async (): Promise => { +export const loadNotes = async (): Promise<{ notes: Note[]; scopeKey: string }> => { const noteRepository = new NoteRepository(); const { notes } = await noteRepository.getAllNotes(); const preprocessor = new NotePreprocessor(); const enrichedNotes = await preprocessor.process(notes); - console.info(`Enriched ${enrichedNotes.length} notes.`); - return enrichedNotes; + + const scopeSettings = await getScopeSettings(); + const resolvedScope = await noteScopeResolver.resolve(scopeSettings); + currentScope = resolvedScope; + currentScopeMode = scopeSettings.mode; + const { folderIds, scopeKey } = resolvedScope; + const scopedNotes = folderIds + ? enrichedNotes.filter((n) => folderIds.has(n.parent_id)) + : enrichedNotes; + + console.info( + `Enriched ${enrichedNotes.length} notes` + + (folderIds ? `, scoped to ${scopedNotes.length} (${scopeKey}).` : '.') + ); + return { notes: scopedNotes, scopeKey }; }; const logPanelPostFailure = (e: unknown): void => { @@ -94,9 +121,13 @@ const runSemanticAnalysis = async (notes: Note[]): Promise => { await runEnrichmentFollowUp(); }; -const countUnlabeledSemanticEdges = (graphData: GraphData): { total: number; unlabeled: number } => { +const countUnlabeledSemanticEdges = ( + graphData: GraphData +): { total: number; unlabeled: number } => { const semanticEdges = graphData.edges.filter((edge) => edge.data.type === 'semantic'); - const unlabeled = semanticEdges.filter((edge) => edge.data.relationshipLabel === undefined).length; + const unlabeled = semanticEdges.filter( + (edge) => edge.data.relationshipLabel === undefined + ).length; return { total: semanticEdges.length, unlabeled }; }; @@ -106,7 +137,9 @@ const reportAndBackfillEnrichment = async (graphData: GraphData): Promise if (total === 0) return; if (unlabeled === 0) { - console.info(`LLM enrichment: cached graph already has labels for all ${total} semantic edge(s).`); + console.info( + `LLM enrichment: cached graph already has labels for all ${total} semantic edge(s).` + ); return; } @@ -131,11 +164,41 @@ const runPostCacheLoadFollowUps = async (cached: GraphData): Promise => { } }; -const performFullReload = async (): Promise => { - const enrichedNotes = await loadNotes(); - console.info(`Loaded ${enrichedNotes.length} notes.`); - await postGraphData(analysisController.buildStructural(enrichedNotes)); - await runSemanticAnalysis(enrichedNotes); +let fullReloadInFlight: Promise | null = null; +let fullReloadQueued = false; + +const performFullReload = (): Promise => { + if (fullReloadInFlight) { + fullReloadQueued = true; + return fullReloadInFlight; + } + + fullReloadInFlight = (async () => { + do { + fullReloadQueued = false; + await analysisController.seedEnrichmentFromStore(); + const { notes: enrichedNotes, scopeKey } = await loadNotes(); + analysisController.setScopeKey(scopeKey); + await postGraphData(analysisController.buildStructural(enrichedNotes)); + await runSemanticAnalysis(enrichedNotes); + } while (fullReloadQueued); + })().finally(() => { + fullReloadInFlight = null; + }); + + return fullReloadInFlight; +}; + +let scopeReloadTimer: ReturnType | null = null; + +const scheduleScopeReload = (): void => { + if (scopeReloadTimer) clearTimeout(scopeReloadTimer); + scopeReloadTimer = setTimeout(() => { + scopeReloadTimer = null; + performFullReload().catch((e) => { + console.error('Failed to reload after a scope change:', e); + }); + }, 200); }; const incrementalUpdater = new IncrementalUpdater( @@ -153,10 +216,12 @@ const incrementalUpdater = new IncrementalUpdater( undefined, undefined, () => { - postStatus('Note graph update paused after repeated failures; will retry on your next edit.').catch( - logPanelPostFailure - ); + postStatus( + 'Note graph update paused after repeated failures; will retry on your next edit.' + ).catch(logPanelPostFailure); }, + Date.now, + () => currentScope, (progress) => { postEnrichmentProgress(progress.current, progress.total).catch(logPanelPostFailure); } @@ -167,6 +232,32 @@ let inFlightLoad: Promise | null = null; let lastLoadFailureTime = 0; const LOAD_RETRY_COOLDOWN_MS = 30_000; +const syncScopeWithCache = async (): Promise => { + try { + const scopeSettings = await getScopeSettings(); + const resolvedScope = await noteScopeResolver.resolve(scopeSettings); + currentScope = resolvedScope; + currentScopeMode = scopeSettings.mode; + const { scopeKey } = resolvedScope; + analysisController.setScopeKey(scopeKey); + + await analysisController.migrateCachedEnrichment(); + + const cachedScopeKey = await graphCache.loadScopeKey(); + if (cachedScopeKey !== null && cachedScopeKey !== scopeKey) { + console.info( + `Note Graph scope changed (${cachedScopeKey} -> ${scopeKey}); discarding the cached graph.` + ); + await graphCache.clearGraph(); + } + } catch (e) { + console.error( + 'Failed to check the note graph scope against the cache; proceeding with the cache as-is.', + e + ); + } +}; + const ensureGraphLoaded = (): Promise => { if (analysisController.hasNotes()) { return Promise.resolve(); @@ -177,11 +268,16 @@ const ensureGraphLoaded = (): Promise => { inFlightLoad = (async () => { try { + await syncScopeWithCache(); const cached = await analysisController.loadFromCache(); if (cached) { - console.info(`Loaded graph from cache: ${cached.nodes.length} notes, no recompute.`); + console.info( + `Loaded graph from cache: ${cached.nodes.length} notes, no recompute.` + ); await postGraphData(cached); - await postStatus('Loaded from local cache - not recomputed. Refreshes as you edit or sync.'); + await postStatus( + 'Loaded from local cache - not recomputed. Refreshes as you edit or sync.' + ); void runPostCacheLoadFollowUps(cached); return; } @@ -198,12 +294,22 @@ const ensureGraphLoaded = (): Promise => { return inFlightLoad; }; +const focusOnOpenNote = async (): Promise => { + try { + const openNote = await joplin.workspace.selectedNote(); + await postFocusNote(openNote?.id ?? null); + } catch (error) { + console.error('Failed to focus the note graph on the open note:', error); + } +}; + const noteGraphCommand = { name: SHOW_NOTE_GRAPH_COMMAND, label: 'Show Note Graph', execute: async () => { try { await showAiNoteGraphPanel(); + await focusOnOpenNote(); await ensureGraphLoaded(); } catch (error) { console.error('Failed to load note graph:', error); @@ -211,6 +317,31 @@ const noteGraphCommand = { }, }; +const focusPanelOnNoteSelectionChange = async (noteIds: string[]): Promise => { + try { + if (!(await isNoteGraphPanelVisible())) return; + await postFocusNote(noteIds[0] ?? null); + } catch (error) { + console.error('Failed to sync note graph focus to the note selection change:', error); + } +}; + +const refreshCurrentNotebookScope = async (): Promise => { + if (currentScopeMode !== 'current') return; + try { + if (!analysisController.hasNotes()) return; + if (!(await isNoteGraphPanelVisible())) return; + + const selectedFolder = await joplin.workspace.selectedFolder().catch(() => null); + const scopeKey = currentScopeKey(selectedFolder?.id ?? null); + if (scopeKey === currentScope.scopeKey) return; + + await performFullReload(); + } catch (error) { + console.error('Failed to refresh current-notebook scope:', error); + } +}; + const recomputeAndPost = async (): Promise => { const graphData = await analysisController.recompute(); if (!graphData) return; @@ -244,6 +375,11 @@ const handleSettingsChange = async (event: { keys: string[] }): Promise => return; } + if (event.keys.some((key) => SCOPE_SETTING_KEYS.includes(key))) { + scheduleScopeReload(); + return; + } + if (event.keys.includes(AI_ANALYSIS_ENABLED_KEY)) { await runSemanticAnalysis(analysisController.getCurrentNotes()); return; @@ -288,6 +424,26 @@ const registerCommands = async (): Promise => { await joplin.commands.register(noteGraphCommand); }; +const onRequestFolders = async (): Promise => { + const { folders } = await new FolderRepository().getAllFolders(); + return folders.map((folder) => ({ id: folder.id, title: folder.title })); +}; + +const onGetScopeState = async (): Promise => { + return await getScopeSettings(); +}; + +const onSetScope = async ( + mode: ScopeState['mode'], + selectedNotebookIds: string[] +): Promise => { + await joplin.settings.setValue(SCOPE_MODE_KEY, mode); + await joplin.settings.setValue( + SCOPE_SELECTED_NOTEBOOKS_KEY, + JSON.stringify(selectedNotebookIds) + ); +}; + const registerMenuItems = async (): Promise => { await joplin.views.menuItems.create( SHOW_NOTE_GRAPH_MENU_ITEM, @@ -309,10 +465,17 @@ joplin.plugins.register({ () => { analysisController.cancelCurrentRun(); postStatus('Analysis cancelled.').catch(logPanelPostFailure); - } + }, + onRequestFolders, + onGetScopeState, + onSetScope ); await registerCommands(); await registerMenuItems(); await workspaceListener.register(); + await joplin.workspace.onNoteSelectionChange((event) => { + void focusPanelOnNoteSelectionChange(event.value); + void refreshCurrentNotebookScope(); + }); }, }); diff --git a/src/services/AnalysisController.test.ts b/src/services/AnalysisController.test.ts index 811e70e..05e3e1e 100644 --- a/src/services/AnalysisController.test.ts +++ b/src/services/AnalysisController.test.ts @@ -23,7 +23,9 @@ jest.mock('../data/Database/VectorRepository', () => ({ jest.mock('../data/Database/GraphCacheRepository'); const MockGraphBuilder = GraphBuilder as jest.MockedClass; -const MockGraphCacheRepository = GraphCacheRepository as jest.MockedClass; +const MockGraphCacheRepository = GraphCacheRepository as jest.MockedClass< + typeof GraphCacheRepository +>; const MockProviderResolver = ProviderResolver as jest.Mocked; const MockOrchestrator = EmbeddingOrchestrator as jest.MockedClass; const MockLLMEnricher = LLMEnricher as jest.MockedClass; @@ -85,9 +87,13 @@ describe('AnalysisController', () => { mockGetSimilaritySettings.mockResolvedValue({ threshold: 0.5, topK: 5 }); mockGraphCache = new MockGraphCacheRepository() as jest.Mocked; mockGraphCache.saveGraph.mockResolvedValue(undefined); + mockGraphCache.saveScopeKey.mockResolvedValue(undefined); mockGraphCache.loadGraph.mockResolvedValue(null); + mockGraphCache.saveEnrichments.mockResolvedValue(undefined); + mockGraphCache.loadEnrichments.mockResolvedValue([]); mockEnricher = new MockLLMEnricher() as jest.Mocked; mockEnricher.replayCached.mockReturnValue({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); + mockEnricher.takeNewEnrichments.mockReturnValue({ nodes: [], edges: [] }); mockIsLlmEnrichmentEnabled.mockResolvedValue(false); controller = new AnalysisController(mockBuilder, mockGraphCache, undefined, mockEnricher); @@ -176,6 +182,35 @@ describe('AnalysisController', () => { ); }); + it('keeps a cached semantic graph instead of downgrading it when a later call fails transiently (e.g. AI-toggle settings churn)', async () => { + mockIsAiAnalysisEnabled.mockResolvedValue(true); + MockProviderResolver.resolveWithValidation.mockResolvedValue(fakeProvider); + mockOrchestratorInstance.embedNotes.mockResolvedValue({ + embeddedNotes: [{ note: note('a'), embedding: [1, 0] }], + errors: [], + }); + mockBuilder.buildWithSimilarity.mockResolvedValue({ + nodes: [], + edges: [ + { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }, + ], + }); + const notes = [note('a')]; + await controller.embedAndBuildSemantic(notes); + jest.clearAllMocks(); + + mockIsAiAnalysisEnabled.mockResolvedValue(true); + MockProviderResolver.resolveWithValidation.mockRejectedValue( + new Error('index not ready') + ); + + const result = await controller.embedAndBuildSemantic(notes); + + expect(result).toBeNull(); + expect(mockBuilder.build).not.toHaveBeenCalled(); + expect(controller.getLastGraphData()?.edges).toHaveLength(1); + }); + it('discards a run that resolves after a newer run has already started', async () => { mockIsAiAnalysisEnabled.mockResolvedValue(true); MockProviderResolver.resolveWithValidation.mockResolvedValue(fakeProvider); @@ -336,7 +371,9 @@ describe('AnalysisController', () => { }); await controller.embedAndBuildSemantic([note('a')]); - MockProviderResolver.resolveWithValidation.mockRejectedValue(new Error('index not ready')); + MockProviderResolver.resolveWithValidation.mockRejectedValue( + new Error('index not ready') + ); await controller.embedAndBuildSemantic([note('a'), note('b')]); jest.clearAllMocks(); @@ -350,7 +387,10 @@ describe('AnalysisController', () => { mockIsAiAnalysisEnabled.mockResolvedValue(true); MockProviderResolver.resolveWithValidation.mockResolvedValue(fakeProvider); const embeddedA = { note: note('a'), embedding: [1, 0] }; - mockOrchestratorInstance.embedNotes.mockResolvedValue({ embeddedNotes: [embeddedA], errors: [] }); + mockOrchestratorInstance.embedNotes.mockResolvedValue({ + embeddedNotes: [embeddedA], + errors: [], + }); await controller.embedAndBuildSemantic([note('a')]); jest.clearAllMocks(); mockIsAiAnalysisEnabled.mockResolvedValue(true); @@ -380,7 +420,16 @@ describe('AnalysisController', () => { { data: { id: 'a', label: 'a', noteId: 'a', degree: 1, community: 0, size: 5 } }, { data: { id: 'b', label: 'b', noteId: 'b', degree: 1, community: 0, size: 5 } }, ], - edges: [{ data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' as const } }], + edges: [ + { + data: { + id: 'a::b::semantic', + source: 'a', + target: 'b', + type: 'semantic' as const, + }, + }, + ], }; beforeEach(() => { @@ -391,7 +440,10 @@ describe('AnalysisController', () => { errors: [], }); mockBuilder.buildWithSimilarity.mockResolvedValue(semanticGraphData); - mockEnricher.enrich.mockResolvedValue({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); + mockEnricher.enrich.mockResolvedValue({ + nodeEnrichments: new Map(), + edgeEnrichments: new Map(), + }); }); it('does not call the enrichment service when the setting is off', async () => { @@ -408,7 +460,9 @@ describe('AnalysisController', () => { mockIsLlmEnrichmentEnabled.mockResolvedValue(true); mockEnricher.enrich.mockResolvedValue({ nodeEnrichments: new Map([['a', { category: 'Gardening' }]]), - edgeEnrichments: new Map([['a::b::semantic', { relationshipLabel: 'inspired by' }]]), + edgeEnrichments: new Map([ + ['a::b::semantic', { relationshipLabel: 'inspired by' }], + ]), }); await controller.embedAndBuildSemantic([note('a'), note('b')]); await controller.enrichCurrentGraph(); @@ -423,8 +477,12 @@ describe('AnalysisController', () => { it('merges category, relationship label and a clamped size adjustment into the graph', async () => { mockIsLlmEnrichmentEnabled.mockResolvedValue(true); mockEnricher.enrich.mockResolvedValue({ - nodeEnrichments: new Map([['a', { category: 'Gardening', centralityAdjustment: 2 }]]), - edgeEnrichments: new Map([['a::b::semantic', { relationshipLabel: 'inspired by' }]]), + nodeEnrichments: new Map([ + ['a', { category: 'Gardening', centralityAdjustment: 2 }], + ]), + edgeEnrichments: new Map([ + ['a::b::semantic', { relationshipLabel: 'inspired by' }], + ]), }); await controller.embedAndBuildSemantic([note('a'), note('b')]); @@ -492,7 +550,16 @@ describe('AnalysisController', () => { mockIsLlmEnrichmentEnabled.mockResolvedValue(true); mockBuilder.buildWithSimilarity.mockResolvedValue({ nodes: semanticGraphData.nodes, - edges: [{ data: { id: 'a::c::semantic', source: 'a', target: 'c', type: 'semantic' as const } }], + edges: [ + { + data: { + id: 'a::c::semantic', + source: 'a', + target: 'c', + type: 'semantic' as const, + }, + }, + ], }); await controller.embedAndBuildSemantic([note('a'), note('b')]); @@ -555,19 +622,29 @@ describe('AnalysisController', () => { const input = mockEnricher.enrich.mock.calls[0][0]; expect(input.edges).toEqual([ - { id: 'a::b::semantic', source: 'a', target: 'b', updatedTime: note('a').updated_time }, + { + id: 'a::b::semantic', + source: 'a', + target: 'b', + updatedTime: note('a').updated_time, + }, ]); }); it('keys an edge enrichment cache entry on the newer of its two endpoints, not the source alone', async () => { mockIsLlmEnrichmentEnabled.mockResolvedValue(true); - const notes = [{ ...note('a'), updated_time: 100 }, { ...note('b'), updated_time: 200 }]; + const notes = [ + { ...note('a'), updated_time: 100 }, + { ...note('b'), updated_time: 200 }, + ]; await controller.embedAndBuildSemantic(notes); await controller.enrichCurrentGraph(); const input = mockEnricher.enrich.mock.calls[0][0]; - expect(input.edges).toEqual([{ id: 'a::b::semantic', source: 'a', target: 'b', updatedTime: 200 }]); + expect(input.edges).toEqual([ + { id: 'a::b::semantic', source: 'a', target: 'b', updatedTime: 200 }, + ]); }); it('passes an isStale predicate that reflects a newer run superseding this one', async () => { @@ -648,27 +725,31 @@ describe('AnalysisController', () => { expect(result).toBeNull(); }); - it('rejects a second enrichCurrentGraph call while one is already in flight', async () => { + it('waits for an in-flight enrichment to finish, then runs against the latest graph', async () => { mockIsLlmEnrichmentEnabled.mockResolvedValue(true); await controller.embedAndBuildSemantic([note('a'), note('b')]); - let resolveEnrich!: (result: Awaited>) => void; - mockEnricher.enrich.mockImplementation( - () => - new Promise((resolve) => { - resolveEnrich = resolve; - }) - ); + let resolveFirst!: (result: Awaited>) => void; + mockEnricher.enrich + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); const first = controller.enrichCurrentGraph(); await new Promise((resolve) => setImmediate(resolve)); - const second = await controller.enrichCurrentGraph(); - expect(second).toBeNull(); - expect(mockEnricher.enrich).toHaveBeenCalledTimes(1); + const secondPromise = controller.enrichCurrentGraph(); - resolveEnrich({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); + resolveFirst({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); await first; + + const second = await secondPromise; + expect(second).toBeNull(); + expect(mockEnricher.enrich).toHaveBeenCalledTimes(2); }); }); @@ -773,7 +854,18 @@ describe('AnalysisController', () => { errors: [], }); mockBuilder.buildWithSimilarity.mockResolvedValue({ - nodes: [{ data: { id: 'a', label: 'a', noteId: 'a', degree: 0, community: 0, size: 5 } }], + nodes: [ + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 0, + community: 0, + size: 5, + }, + }, + ], edges: [], }); // Pass A completes and commits first, same as production: Pass B only @@ -815,7 +907,18 @@ describe('AnalysisController', () => { errors: [], }); mockBuilder.buildWithSimilarity.mockResolvedValue({ - nodes: [{ data: { id: 'a', label: 'a', noteId: 'a', degree: 0, community: 0, size: 5 } }], + nodes: [ + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 0, + community: 0, + size: 5, + }, + }, + ], edges: [], }); await controller.embedAndBuildSemantic([note('a')]); @@ -860,6 +963,20 @@ describe('AnalysisController', () => { expect(mockGraphCache.saveGraph).toHaveBeenCalledWith(notes, graphData); }); + + it('defaults to the "all" scope key when none has been set', () => { + controller.buildStructural([note('a')]); + + expect(mockGraphCache.saveScopeKey).toHaveBeenCalledWith('all'); + }); + + it('persists whichever scope key was set via setScopeKey', () => { + controller.setScopeKey('current:folder-1'); + + controller.buildStructural([note('a')]); + + expect(mockGraphCache.saveScopeKey).toHaveBeenCalledWith('current:folder-1'); + }); }); describe('loadFromCache', () => { @@ -896,8 +1013,27 @@ describe('AnalysisController', () => { const notes = [note('a'), note('b')]; const graphData = { nodes: [ - { data: { id: 'a', label: 'a', noteId: 'a', degree: 1, community: 0, size: 1, category: 'Cat A' } }, - { data: { id: 'b', label: 'b', noteId: 'b', degree: 1, community: 0, size: 1 } }, + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 1, + community: 0, + size: 1, + category: 'Cat A', + }, + }, + { + data: { + id: 'b', + label: 'b', + noteId: 'b', + degree: 1, + community: 0, + size: 1, + }, + }, ], edges: [ { @@ -917,7 +1053,13 @@ describe('AnalysisController', () => { expect(mockEnricher.seedCache).toHaveBeenCalledWith( [{ id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }], - [{ id: 'a::b::semantic', updatedTime: 1, enrichment: { relationshipLabel: 'links to' } }] + [ + { + id: 'a::b::semantic', + updatedTime: 1, + enrichment: { relationshipLabel: 'links to' }, + }, + ] ); }); @@ -926,8 +1068,23 @@ describe('AnalysisController', () => { const graphData = { nodes: [], edges: [ - { data: { id: 'a::b::link', source: 'a', target: 'b', type: 'link' as const, relationshipLabel: 'ignored' } }, - { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' as const } }, + { + data: { + id: 'a::b::link', + source: 'a', + target: 'b', + type: 'link' as const, + relationshipLabel: 'ignored', + }, + }, + { + data: { + id: 'a::b::semantic', + source: 'a', + target: 'b', + type: 'semantic' as const, + }, + }, ], }; mockGraphCache.loadGraph.mockResolvedValue({ notes, graphData }); @@ -938,6 +1095,172 @@ describe('AnalysisController', () => { }); }); + describe('seedEnrichmentFromStore', () => { + it('seeds node and edge enrichment from the persisted table', async () => { + mockGraphCache.loadEnrichments.mockResolvedValue([ + { kind: 'node', id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }, + { + kind: 'edge', + id: 'a::b::semantic', + updatedTime: 2, + enrichment: { relationshipLabel: 'links' }, + }, + ]); + + await controller.seedEnrichmentFromStore(); + + expect(mockEnricher.seedCache).toHaveBeenCalledWith( + [{ id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }], + [ + { + id: 'a::b::semantic', + updatedTime: 2, + enrichment: { relationshipLabel: 'links' }, + }, + ] + ); + }); + + it('swallows a read failure instead of throwing', async () => { + mockGraphCache.loadEnrichments.mockRejectedValue(new Error('disk error')); + + await expect(controller.seedEnrichmentFromStore()).resolves.toBeUndefined(); + }); + }); + + describe('enrichment persistence', () => { + it('does not re-persist enrichment already on a committed graph', () => { + mockBuilder.build.mockReturnValue({ + nodes: [ + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 1, + community: 0, + size: 1, + category: 'Cat A', + }, + }, + ], + edges: [ + { + data: { + id: 'a::b::semantic', + source: 'a', + target: 'b', + type: 'semantic' as const, + relationshipLabel: 'links', + }, + }, + ], + }); + + controller.buildStructural([note('a'), note('b')]); + + expect(mockGraphCache.saveEnrichments).not.toHaveBeenCalled(); + }); + + it('persists only the enrichments newly produced by the LLM pass', async () => { + mockIsLlmEnrichmentEnabled.mockResolvedValue(true); + controller.buildStructural([note('a'), note('b')]); + + mockEnricher.enrich.mockResolvedValue({ + nodeEnrichments: new Map([['a', { category: 'Cat A' }]]), + edgeEnrichments: new Map([['a::b::semantic', { relationshipLabel: 'links' }]]), + }); + mockEnricher.takeNewEnrichments.mockReturnValue({ + nodes: [{ id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }], + edges: [ + { + id: 'a::b::semantic', + updatedTime: 1, + enrichment: { relationshipLabel: 'links' }, + }, + ], + }); + + await controller.enrichCurrentGraph(); + + expect(mockGraphCache.saveEnrichments).toHaveBeenCalledWith([ + { kind: 'node', id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }, + { + kind: 'edge', + id: 'a::b::semantic', + updatedTime: 1, + enrichment: { relationshipLabel: 'links' }, + }, + ]); + }); + + it('does not write the enrichment table when the LLM pass produced nothing new', async () => { + mockIsLlmEnrichmentEnabled.mockResolvedValue(true); + controller.buildStructural([note('a'), note('b')]); + + mockEnricher.enrich.mockResolvedValue({ nodeEnrichments: new Map(), edgeEnrichments: new Map() }); + mockEnricher.takeNewEnrichments.mockReturnValue({ nodes: [], edges: [] }); + + await controller.enrichCurrentGraph(); + + expect(mockGraphCache.saveEnrichments).not.toHaveBeenCalled(); + }); + }); + + describe('migrateCachedEnrichment', () => { + it('persists enrichment already on the cached graph before it is discarded', async () => { + const notes = [note('a'), note('b')]; + const graphData = { + nodes: [ + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 1, + community: 0, + size: 1, + category: 'Cat A', + }, + }, + ], + edges: [ + { + data: { + id: 'a::b::semantic', + source: 'a', + target: 'b', + type: 'semantic' as const, + relationshipLabel: 'links', + }, + }, + ], + }; + mockGraphCache.loadGraph.mockResolvedValue({ notes, graphData }); + + await controller.migrateCachedEnrichment(); + + expect(mockGraphCache.saveEnrichments).toHaveBeenCalledWith([ + { kind: 'node', id: 'a', updatedTime: 1, enrichment: { category: 'Cat A' } }, + { + kind: 'edge', + id: 'a::b::semantic', + updatedTime: 1, + enrichment: { relationshipLabel: 'links' }, + }, + ]); + }); + + it('does nothing when nothing has been cached', async () => { + mockGraphCache.loadGraph.mockResolvedValue(null); + + await controller.migrateCachedEnrichment(); + + expect(mockGraphCache.saveEnrichments).not.toHaveBeenCalled(); + }); + }); + + describe('applyDelta', () => { it('returns null when nothing has been loaded yet', async () => { const result = await controller.applyDelta([note('a')], []); @@ -957,7 +1280,10 @@ describe('AnalysisController', () => { expect(result).toBe(graphData); expect(mockBuilder.build).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: 'a' }), expect.objectContaining({ id: 'b' })]) + expect.arrayContaining([ + expect.objectContaining({ id: 'a' }), + expect.objectContaining({ id: 'b' }), + ]) ); expect(controller.getCurrentNotes()).toHaveLength(2); }); @@ -1021,7 +1347,10 @@ describe('AnalysisController', () => { expect(deltaResult).not.toBeNull(); expect(controller.getCurrentNotes()).toHaveLength(2); - staleEmbed.resolve({ embeddedNotes: [{ note: note('a'), embedding: [1, 0] }], errors: [] }); + staleEmbed.resolve({ + embeddedNotes: [{ note: note('a'), embedding: [1, 0] }], + errors: [], + }); const staleResult = await staleCall; expect(staleResult).toBeNull(); @@ -1091,13 +1420,17 @@ describe('AnalysisController', () => { }); mockBuilder.buildWithSimilarity.mockResolvedValue({ nodes: [], - edges: [{ data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }], + edges: [ + { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }, + ], }); await controller.embedAndBuildSemantic([note('a')]); jest.clearAllMocks(); mockIsAiAnalysisEnabled.mockResolvedValue(true); - MockProviderResolver.resolveWithValidation.mockRejectedValue(new Error('index not ready')); + MockProviderResolver.resolveWithValidation.mockRejectedValue( + new Error('index not ready') + ); const result = await controller.applyDelta([note('b')], []); @@ -1129,13 +1462,17 @@ describe('AnalysisController', () => { }); mockBuilder.buildWithSimilarity.mockResolvedValue({ nodes: [], - edges: [{ data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }], + edges: [ + { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }, + ], }); await controller.embedAndBuildSemantic([note('a')]); jest.clearAllMocks(); mockIsAiAnalysisEnabled.mockResolvedValue(true); - MockProviderResolver.resolveWithValidation.mockRejectedValueOnce(new Error('index not ready')); + MockProviderResolver.resolveWithValidation.mockRejectedValueOnce( + new Error('index not ready') + ); await controller.applyDelta([note('b')], []); expect(controller.wasLastDeltaSkippedForRetry()).toBe(true); @@ -1161,13 +1498,17 @@ describe('AnalysisController', () => { }); mockBuilder.buildWithSimilarity.mockResolvedValue({ nodes: [], - edges: [{ data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }], + edges: [ + { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }, + ], }); await controller.embedAndBuildSemantic([note('a')]); jest.clearAllMocks(); mockIsAiAnalysisEnabled.mockResolvedValue(true); - MockProviderResolver.resolveWithValidation.mockRejectedValueOnce(new Error('index not ready')); + MockProviderResolver.resolveWithValidation.mockRejectedValueOnce( + new Error('index not ready') + ); const skipped = await controller.applyDelta([note('b')], []); expect(skipped).toBeNull(); expect(controller.getCurrentNotes()).toHaveLength(1); @@ -1196,7 +1537,9 @@ describe('AnalysisController', () => { }); mockBuilder.buildWithSimilarity.mockResolvedValue({ nodes: [], - edges: [{ data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }], + edges: [ + { data: { id: 'a::b::semantic', source: 'a', target: 'b', type: 'semantic' } }, + ], }); await controller.embedAndBuildSemantic([note('a')]); jest.clearAllMocks(); @@ -1220,7 +1563,18 @@ describe('AnalysisController', () => { it('treats the first build as entirely new (no previous graph to diff against)', () => { const graphData = { - nodes: [{ data: { id: 'a', label: 'a', noteId: 'a', degree: 0, community: 0, size: 1 } }], + nodes: [ + { + data: { + id: 'a', + label: 'a', + noteId: 'a', + degree: 0, + community: 0, + size: 1, + }, + }, + ], edges: [], }; mockBuilder.build.mockReturnValue(graphData); @@ -1236,8 +1590,12 @@ describe('AnalysisController', () => { }); it('reports only what changed between two builds', () => { - const nodeA = { data: { id: 'a', label: 'a', noteId: 'a', degree: 0, community: 0, size: 1 } }; - const nodeB = { data: { id: 'b', label: 'b', noteId: 'b', degree: 0, community: 0, size: 1 } }; + const nodeA = { + data: { id: 'a', label: 'a', noteId: 'a', degree: 0, community: 0, size: 1 }, + }; + const nodeB = { + data: { id: 'b', label: 'b', noteId: 'b', degree: 0, community: 0, size: 1 }, + }; mockBuilder.build.mockReturnValueOnce({ nodes: [nodeA], edges: [] }); controller.buildStructural([note('a')]); diff --git a/src/services/AnalysisController.ts b/src/services/AnalysisController.ts index c4eed1d..a0a0779 100644 --- a/src/services/AnalysisController.ts +++ b/src/services/AnalysisController.ts @@ -4,13 +4,17 @@ import { GraphData, GraphNode, RenderedEdge } from './graph/types'; import { GraphDiffer, GraphDiff } from './graph/GraphDiffer'; import { clampSize } from './graph/CentralityScorer'; import { VectorRepository } from '../data/Database/VectorRepository'; -import { GraphCacheRepository } from '../data/Database/GraphCacheRepository'; +import { GraphCacheRepository, PersistedEnrichment } from '../data/Database/GraphCacheRepository'; import { ProviderResolver } from './embeddings/ProviderResolver'; import { EmbeddingOrchestrator } from './embeddings/Orchestrator'; import { EmbeddedNote, EmbeddingProvider, BatchProgress } from './embeddings/Types'; import { LLMEnricher, EnrichmentNodeInput, EnrichmentEdgeInput, EnrichmentProgress, EnrichmentResult, CacheSeed } from './llm/LLMEnricher'; import { NodeEnrichment, EdgeEnrichment } from './llm/ResponseParser'; -import { isAiAnalysisEnabled, isLlmEnrichmentEnabled, getSimilaritySettings } from './settings/GraphSettings'; +import { + isAiAnalysisEnabled, + isLlmEnrichmentEnabled, + getSimilaritySettings, +} from './settings/GraphSettings'; export interface SemanticBuildResult { graphData: GraphData; @@ -33,7 +37,8 @@ export class AnalysisController { private cancelledAtToken: number | null = null; private lastDeltaSkippedForRetry = false; private currentOrchestrator: EmbeddingOrchestrator | null = null; - private enrichmentInFlight = false; + private enrichmentInFlight: Promise | null = null; + private currentScopeKey = 'all'; public constructor( private readonly builder = new GraphBuilder(), @@ -81,6 +86,10 @@ export class AnalysisController { return this.lastNotes ?? []; } + public setScopeKey(scopeKey: string): void { + this.currentScopeKey = scopeKey; + } + public async loadFromCache(): Promise { try { const cached = await this.graphCache.loadGraph(); @@ -95,6 +104,44 @@ export class AnalysisController { } } + public async seedEnrichmentFromStore(): Promise { + try { + const records = await this.graphCache.loadEnrichments(); + const nodeSeeds: CacheSeed[] = []; + const edgeSeeds: CacheSeed[] = []; + for (const record of records) { + if (record.kind === 'node') { + nodeSeeds.push({ + id: record.id, + updatedTime: record.updatedTime, + enrichment: record.enrichment as unknown as NodeEnrichment, + }); + } else { + edgeSeeds.push({ + id: record.id, + updatedTime: record.updatedTime, + enrichment: record.enrichment as unknown as EdgeEnrichment, + }); + } + } + this.enrichmentService.seedCache(nodeSeeds, edgeSeeds); + } catch (e) { + console.error('Failed to seed LLM enrichment cache from disk:', e); + } + } + + public async migrateCachedEnrichment(): Promise { + try { + const cached = await this.graphCache.loadGraph(); + if (!cached) return; + const records = this.buildEnrichmentRecords(cached.notes, cached.graphData); + if (records.length === 0) return; + await this.graphCache.saveEnrichments(records); + } catch (e) { + console.error('Failed to migrate cached LLM enrichment:', e); + } + } + private seedEnrichmentCache(notes: Note[], graphData: GraphData): void { const noteById = new Map(notes.map((note) => [note.id, note])); @@ -103,12 +150,17 @@ export class AnalysisController { if (node.data.category === undefined) continue; const note = noteById.get(node.data.id); if (!note) continue; - nodeSeeds.push({ id: node.data.id, updatedTime: note.updated_time, enrichment: { category: node.data.category } }); + nodeSeeds.push({ + id: node.data.id, + updatedTime: note.updated_time, + enrichment: { category: node.data.category }, + }); } const edgeSeeds: CacheSeed[] = []; for (const edge of graphData.edges) { - if (edge.data.type !== 'semantic' || edge.data.relationshipLabel === undefined) continue; + if (edge.data.type !== 'semantic' || edge.data.relationshipLabel === undefined) + continue; const source = noteById.get(edge.data.source); const target = noteById.get(edge.data.target); if (!source || !target) continue; @@ -151,6 +203,7 @@ export class AnalysisController { return this.buildFrom(notes, token, { onProgress: guardedProgress, commitNotes: true, + avoidSemanticDowngrade: true, }); } @@ -164,7 +217,10 @@ export class AnalysisController { } ): Promise { const hadSemanticGraph = this.hasSemanticEdges(); - const { embeddedNotes, reason, aiWasEnabled } = await this.tryEmbed(notes, options.onProgress); + const { embeddedNotes, reason, aiWasEnabled } = await this.tryEmbed( + notes, + options.onProgress + ); if (this.isStale(token, options.avoidSemanticDowngrade)) return null; if (!embeddedNotes) { @@ -243,7 +299,12 @@ export class AnalysisController { onProgress?: (progress: EnrichmentProgress) => void ): Promise { if (!this.lastGraphData || !this.lastNotes) return null; - if (this.enrichmentInFlight) return null; + + const inFlight = this.enrichmentInFlight; + if (inFlight) { + await inFlight; + return this.enrichCurrentGraph(onProgress); + } const token = this.runToken; if (token === this.cancelledAtToken) return null; @@ -251,20 +312,24 @@ export class AnalysisController { const notes = this.lastNotes; const guardedProgress = onProgress ? this.guardStaleProgress(token, onProgress) : undefined; - this.enrichmentInFlight = true; - let enriched: GraphData; - try { - enriched = await this.applyEnrichment(graphData, notes, token, guardedProgress); - } finally { - this.enrichmentInFlight = false; - } + const task = (async (): Promise => { + let enriched: GraphData; + try { + enriched = await this.applyEnrichment(graphData, notes, token, guardedProgress); + } finally { + this.enrichmentInFlight = null; + } - if (this.isStale(token) || enriched === graphData) { - return null; - } + if (this.isStale(token) || enriched === graphData) { + return null; + } + + this.commitGraphData(enriched); + return enriched; + })(); - this.commitGraphData(enriched); - return enriched; + this.enrichmentInFlight = task; + return task; } public async applyDelta(upserts: Note[], removedIds: string[]): Promise { @@ -298,11 +363,72 @@ export class AnalysisController { this.persistCache(); } + private persistNewEnrichments(newEntries: { + nodes: CacheSeed[]; + edges: CacheSeed[]; + }): void { + const records: PersistedEnrichment[] = []; + for (const node of newEntries.nodes) { + records.push({ + kind: 'node', + id: node.id, + updatedTime: node.updatedTime, + enrichment: node.enrichment as unknown as Record, + }); + } + for (const edge of newEntries.edges) { + records.push({ + kind: 'edge', + id: edge.id, + updatedTime: edge.updatedTime, + enrichment: edge.enrichment as unknown as Record, + }); + } + if (records.length === 0) return; + this.graphCache.saveEnrichments(records).catch((e) => { + console.error('Failed to persist LLM enrichment cache:', e); + }); + } + + private buildEnrichmentRecords(notes: Note[], graphData: GraphData): PersistedEnrichment[] { + const noteById = new Map(notes.map((note) => [note.id, note])); + + const records: PersistedEnrichment[] = []; + for (const node of graphData.nodes) { + if (node.data.category === undefined) continue; + const note = noteById.get(node.data.id); + if (!note) continue; + records.push({ + kind: 'node', + id: node.data.id, + updatedTime: note.updated_time, + enrichment: { category: node.data.category }, + }); + } + for (const edge of graphData.edges) { + if (edge.data.type !== 'semantic' || edge.data.relationshipLabel === undefined) + continue; + const source = noteById.get(edge.data.source); + const target = noteById.get(edge.data.target); + if (!source || !target) continue; + records.push({ + kind: 'edge', + id: edge.data.id, + updatedTime: Math.max(source.updated_time, target.updated_time), + enrichment: { relationshipLabel: edge.data.relationshipLabel }, + }); + } + return records; + } + private persistCache(): void { if (!this.lastNotes || !this.lastGraphData) return; this.graphCache.saveGraph(this.lastNotes, this.lastGraphData).catch((e) => { console.error('Failed to persist graph cache:', e); }); + this.graphCache.saveScopeKey(this.currentScopeKey).catch((e) => { + console.error('Failed to persist graph cache scope key:', e); + }); } private mergeNotes( @@ -344,7 +470,10 @@ export class AnalysisController { } /** Wraps a progress callback so it stops firing once a newer run supersedes `token` — otherwise a slow, superseded run could re-show the progress bar after a newer run already hid it by posting its finished graph. */ - private guardStaleProgress(token: number, onProgress: (progress: T) => void): (progress: T) => void { + private guardStaleProgress( + token: number, + onProgress: (progress: T) => void + ): (progress: T) => void { return (progress) => { if (token === this.runToken) { onProgress(progress); @@ -367,6 +496,7 @@ export class AnalysisController { () => token !== this.runToken, onProgress ); + this.persistNewEnrichments(this.enrichmentService.takeNewEnrichments()); if (enrichment.nodeEnrichments.size === 0 && enrichment.edgeEnrichments.size === 0) { return graphData; } diff --git a/src/services/embeddings/providers/JoplinNativeProvider.test.ts b/src/services/embeddings/providers/JoplinNativeProvider.test.ts index 9357c44..e5ebc7a 100644 --- a/src/services/embeddings/providers/JoplinNativeProvider.test.ts +++ b/src/services/embeddings/providers/JoplinNativeProvider.test.ts @@ -50,8 +50,10 @@ describe('JoplinNativeProvider', () => { ai.getEmbeddings.mockRejectedValue(new Error('network error')); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); - const rejection = expect(provider.fetchVectorsByNoteIds(['n2'])).rejects.toThrow('network error'); - await jest.advanceTimersByTimeAsync(2000); + const rejection = expect(provider.fetchVectorsByNoteIds(['n2'])).rejects.toThrow( + 'network error' + ); + await jest.advanceTimersByTimeAsync(3000); await rejection; expect(provider.getFetchedModelId()).toBeNull(); @@ -76,7 +78,11 @@ describe('JoplinNativeProvider', () => { getEmbeddings: jest.Mock; }; - ai.getIndexStatus.mockResolvedValue({ ready: true, state: 'ready', modelId: 'test-model' }); + ai.getIndexStatus.mockResolvedValue({ + ready: true, + state: 'ready', + modelId: 'test-model', + }); let calls = 0; ai.getEmbeddings.mockImplementation(async () => { calls++; @@ -106,17 +112,107 @@ describe('JoplinNativeProvider', () => { getEmbeddings: jest.Mock; }; - ai.getIndexStatus.mockResolvedValue({ ready: true, state: 'ready', modelId: 'test-model' }); + ai.getIndexStatus.mockResolvedValue({ + ready: true, + state: 'ready', + modelId: 'test-model', + }); ai.getEmbeddings.mockRejectedValue(new Error('network blip')); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const resultPromise = provider.fetchVectorsByNoteIds(['n1']); const rejection = expect(resultPromise).rejects.toThrow('network blip'); - await jest.advanceTimersByTimeAsync(2000); + await jest.advanceTimersByTimeAsync(3000); await rejection; expect(ai.getEmbeddings).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('giving up'), expect.anything()); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('giving up'), + expect.anything() + ); + errorSpy.mockRestore(); + }); + + it('backs off exponentially between page-fetch retries instead of a fixed delay', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockResolvedValue({ + ready: true, + state: 'ready', + modelId: 'test-model', + }); + ai.getEmbeddings.mockRejectedValue(new Error('network blip')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const resultPromise = provider.fetchVectorsByNoteIds(['n1']); + const rejection = expect(resultPromise).rejects.toThrow('network blip'); + + await jest.advanceTimersByTimeAsync(999); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(1999); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(1); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(3); + + await rejection; + errorSpy.mockRestore(); + }); + + it('retries a transient getIndexStatus() failure before giving up on the page fetch', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + let statusCalls = 0; + ai.getIndexStatus.mockImplementation(async () => { + statusCalls++; + if (statusCalls === 1) throw new Error('rpc hiccup'); + return { ready: true, state: 'ready', modelId: 'test-model' }; + }); + ai.getEmbeddings.mockResolvedValue({ + modelId: 'test-model', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [1, 0] }], + nextCursor: undefined, + }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const resultPromise = provider.fetchVectorsByNoteIds(['n1']); + await jest.advanceTimersByTimeAsync(1000); + const vectors = await resultPromise; + + expect(ai.getIndexStatus).toHaveBeenCalledTimes(2); + expect(vectors.get('n1')).toEqual([1, 0]); + errorSpy.mockRestore(); + }); + + it('gives up after exhausting every attempt for getIndexStatus()', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockRejectedValue(new Error('rpc down')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const resultPromise = provider.fetchVectorsByNoteIds(['n1']); + const rejection = expect(resultPromise).rejects.toThrow('rpc down'); + await jest.advanceTimersByTimeAsync(3000); + await rejection; + + expect(ai.getIndexStatus).toHaveBeenCalledTimes(3); errorSpy.mockRestore(); }); }); diff --git a/src/services/embeddings/providers/JoplinNativeProvider.ts b/src/services/embeddings/providers/JoplinNativeProvider.ts index 2b8f477..6f2557b 100644 --- a/src/services/embeddings/providers/JoplinNativeProvider.ts +++ b/src/services/embeddings/providers/JoplinNativeProvider.ts @@ -53,6 +53,39 @@ export function isIndexUsable(state: AiIndexState | undefined): boolean { return !!state && !BLOCKING_STATES.has(state); } +export async function retryWithBackoff( + label: string, + fn: () => Promise, + options: { maxAttempts: number; baseDelayMs: number } +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= options.maxAttempts; attempt++) { + if (attempt > 1) { + await delay(options.baseDelayMs * 2 ** (attempt - 2)); + } + + try { + return await fn(); + } catch (e) { + lastError = e; + const willRetry = attempt < options.maxAttempts; + console.error( + `${label} failed on attempt ${attempt}/${options.maxAttempts}${ + willRetry ? '; retrying.' : '; giving up.' + }`, + e + ); + } + } + + throw lastError; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class JoplinNativeProvider implements EmbeddingProvider { public readonly id: ProviderId = 'joplin-native'; public static readonly DEFAULT_MODEL_ID = 'joplin-native'; @@ -189,39 +222,22 @@ export class JoplinNativeProvider implements EmbeddingProvider { return grouped; } - private async fetchPageWithRetry( + private fetchPageWithRetry( api: JoplinAiApi, options: GetEmbeddingsOptions ): Promise { - let lastError: unknown; - - for (let attempt = 1; attempt <= JoplinNativeProvider.MAX_ATTEMPTS_PER_PAGE; attempt++) { - if (attempt > 1) { - await this.delay(JoplinNativeProvider.RETRY_DELAY_MS); - } - - try { - return await api.getEmbeddings(options); - } catch (e) { - lastError = e; - const willRetry = attempt < JoplinNativeProvider.MAX_ATTEMPTS_PER_PAGE; - console.error( - `Embedding fetch failed on attempt ${attempt}/${JoplinNativeProvider.MAX_ATTEMPTS_PER_PAGE}${willRetry ? '; retrying.' : '; giving up.'}`, - e - ); - } - } - - throw lastError; - } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return retryWithBackoff('Embedding fetch', () => api.getEmbeddings(options), { + maxAttempts: JoplinNativeProvider.MAX_ATTEMPTS_PER_PAGE, + baseDelayMs: JoplinNativeProvider.RETRY_DELAY_MS, + }); } /** Throws if the index isn't usable yet; otherwise returns the model ID it's currently indexed with. */ private async requireUsableIndex(api: JoplinAiApi): Promise { - const status = await api.getIndexStatus(); + const status = await retryWithBackoff('getIndexStatus() call', () => api.getIndexStatus(), { + maxAttempts: JoplinNativeProvider.MAX_ATTEMPTS_PER_PAGE, + baseDelayMs: JoplinNativeProvider.RETRY_DELAY_MS, + }); if (!status || !isIndexUsable(status.state)) { throw new Error( `Joplin AI index is not usable yet (state: ${status?.state ?? 'unknown'}). ` + diff --git a/src/services/graph/GraphBuilder.test.ts b/src/services/graph/GraphBuilder.test.ts index 54c635a..3030bbd 100644 --- a/src/services/graph/GraphBuilder.test.ts +++ b/src/services/graph/GraphBuilder.test.ts @@ -59,7 +59,12 @@ describe('GraphBuilder', () => { expect(result.nodes[0].data.degree).toBe(1); expect(result.nodes[1].data.degree).toBe(1); expect(result.edges).toHaveLength(1); - expect(result.edges[0].data).toEqual({ id: 'a::b::link', source: 'a', target: 'b', type: 'link' }); + expect(result.edges[0].data).toEqual({ + id: 'a::b::link', + source: 'a', + target: 'b', + type: 'link', + }); }); it('truncates long note labels to 64 chars', () => { @@ -84,7 +89,12 @@ describe('GraphBuilder', () => { const notes = [note('a', 'A'), note('b', 'B')]; const result = builder.build(notes); expect(result.edges).toHaveLength(1); - expect(result.edges[0].data).toEqual({ id: 'a::b::link', source: 'a', target: 'b', type: 'link' }); + expect(result.edges[0].data).toEqual({ + id: 'a::b::link', + source: 'a', + target: 'b', + type: 'link', + }); }); it('applies the detected community and centrality size to each node', () => { @@ -119,6 +129,32 @@ describe('GraphBuilder', () => { consoleErrorSpy.mockRestore(); }); + describe('allNotesVeryShort', () => { + beforeEach(() => { + mockEdgeFactory.createEdges.mockReturnValue([]); + }); + + it('is true when every note body is under the very-short threshold', () => { + const notes = [ + { ...note('a', 'A'), body: 'stub' }, + { ...note('b', 'B'), body: '' }, + ]; + expect(builder.build(notes).allNotesVeryShort).toBe(true); + }); + + it('is false when at least one note has real content', () => { + const notes = [ + { ...note('a', 'A'), body: 'stub' }, + { ...note('b', 'B'), body: 'This note has a full sentence of real content in it.' }, + ]; + expect(builder.build(notes).allNotesVeryShort).toBe(false); + }); + + it('is false for an empty note set', () => { + expect(builder.build([]).allNotesVeryShort).toBe(false); + }); + }); + describe('buildWithSimilarity', () => { it('adds semantic edges computed from embeddings alongside structural edges', async () => { mockEdgeFactory.createEdges.mockReturnValue([ diff --git a/src/services/graph/GraphBuilder.ts b/src/services/graph/GraphBuilder.ts index db51a9c..4a8b437 100644 --- a/src/services/graph/GraphBuilder.ts +++ b/src/services/graph/GraphBuilder.ts @@ -6,6 +6,8 @@ import { GraphData, GraphEdge, GraphNode, RenderedEdge } from './types'; import { LouvainDetector } from './LouvainDetector'; import { CentralityScorer } from './CentralityScorer'; +const VERY_SHORT_BODY_CHARS = 20; + export class GraphBuilder { private readonly edgeFactory: EdgeFactory; private readonly louvainDetector: LouvainDetector; @@ -63,7 +65,18 @@ export class GraphBuilder { this.logGraphStats(nodes, visibleEdges, degreeMap, communities); - return { nodes, edges: visibleEdges.map((e) => ({ data: this.toRenderedEdge(e) })) }; + return { + nodes, + edges: visibleEdges.map((e) => ({ data: this.toRenderedEdge(e) })), + allNotesVeryShort: this.isAllNotesVeryShort(notes), + }; + } + + private isAllNotesVeryShort(notes: Note[]): boolean { + return ( + notes.length > 0 && + notes.every((n) => (n.body ?? '').trim().length < VERY_SHORT_BODY_CHARS) + ); } private toRenderedEdge(edge: GraphEdge): RenderedEdge { diff --git a/src/services/graph/types.ts b/src/services/graph/types.ts index da4e2be..6a2d16b 100644 --- a/src/services/graph/types.ts +++ b/src/services/graph/types.ts @@ -18,6 +18,7 @@ export interface GraphEdge { /** Comma-separated tag names when type === 'tag'. */ tagName?: string; relationshipLabel?: string; + score?: number; } export interface RenderedEdge extends GraphEdge { @@ -27,4 +28,5 @@ export interface RenderedEdge extends GraphEdge { export interface GraphData { nodes: Array<{ data: GraphNode }>; edges: Array<{ data: RenderedEdge }>; + allNotesVeryShort?: boolean; } diff --git a/src/services/llm/LLMEnricher.test.ts b/src/services/llm/LLMEnricher.test.ts index fabe9a2..e8ae4e1 100644 --- a/src/services/llm/LLMEnricher.test.ts +++ b/src/services/llm/LLMEnricher.test.ts @@ -510,6 +510,38 @@ describe('LLMEnricher', () => { expect(second.nodeEnrichments.get('n1')).toEqual({ category: 'category-n1' }); }); + it('returns only newly-produced enrichments from takeNewEnrichments', async () => { + const enricher = createEnricher(); + getChatMock().mockImplementation(async (messages) => respondValid(messages)); + + await enricher.enrich({ nodes: nodes('n1', 'n2'), edges: [edge('n1', 'n2')] }, NOT_STALE); + + const newEntries = enricher.takeNewEnrichments(); + expect(newEntries.nodes).toEqual( + expect.arrayContaining([ + { id: 'n1', updatedTime: 1, enrichment: { category: 'category-n1' } }, + { id: 'n2', updatedTime: 1, enrichment: { category: 'category-n2' } }, + ]) + ); + expect(newEntries.nodes).toHaveLength(2); + expect(newEntries.edges).toEqual([ + { id: 'n1::n2::semantic', updatedTime: 1, enrichment: { relationshipLabel: 'label-n1-n2' } }, + ]); + }); + + it('returns nothing from takeNewEnrichments for a fully-cached run', async () => { + const enricher = createEnricher(); + getChatMock().mockImplementation(async (messages) => respondValid(messages)); + + const input: EnrichmentInput = { nodes: nodes('n1', 'n2'), edges: [edge('n1', 'n2')] }; + await enricher.enrich(input, NOT_STALE); + enricher.takeNewEnrichments(); + + await enricher.enrich(input, NOT_STALE); + + expect(enricher.takeNewEnrichments()).toEqual({ nodes: [], edges: [] }); + }); + it('treats a changed edge updatedTime as a cache miss and re-enriches', async () => { const enricher = createEnricher(); getChatMock().mockImplementation(async (messages) => respondValid(messages)); diff --git a/src/services/llm/LLMEnricher.ts b/src/services/llm/LLMEnricher.ts index a9146cb..62c7b6e 100644 --- a/src/services/llm/LLMEnricher.ts +++ b/src/services/llm/LLMEnricher.ts @@ -76,6 +76,8 @@ export interface CacheSeed { export class LLMEnricher { private readonly nodeCache = new Map>(); private readonly edgeCache = new Map>(); + private readonly dirtyNodes = new Map>(); + private readonly dirtyEdges = new Map>(); private readonly edgesPerBatch: number; private readonly maxAttemptsPerBatch: number; @@ -102,6 +104,25 @@ export class LLMEnricher { } } + public takeNewEnrichments(): { + nodes: CacheSeed[]; + edges: CacheSeed[]; + } { + const nodes = Array.from(this.dirtyNodes.entries()).map(([id, cached]) => ({ + id, + updatedTime: cached.updatedTime, + enrichment: cached.enrichment, + })); + const edges = Array.from(this.dirtyEdges.entries()).map(([id, cached]) => ({ + id, + updatedTime: cached.updatedTime, + enrichment: cached.enrichment, + })); + this.dirtyNodes.clear(); + this.dirtyEdges.clear(); + return { nodes, edges }; + } + public replayCached(input: EnrichmentInput): EnrichmentResult { const nodeEnrichments = this.seedCachedNodes(input.nodes); const { hits } = this.partitionEdges(input.edges); @@ -348,6 +369,7 @@ export class LLMEnricher { } if (enrichment.category !== undefined) { this.nodeCache.set(id, { enrichment: { category: enrichment.category }, updatedTime }); + this.dirtyNodes.set(id, { enrichment: { category: enrichment.category }, updatedTime }); } into.set(id, enrichment); writtenThisRun.add(id); @@ -366,6 +388,7 @@ export class LLMEnricher { continue; } this.edgeCache.set(id, { enrichment, updatedTime }); + this.dirtyEdges.set(id, { enrichment, updatedTime }); into.set(id, enrichment); } } diff --git a/src/services/settings/GraphSettings.test.ts b/src/services/settings/GraphSettings.test.ts index e82c0e7..74d4a63 100644 --- a/src/services/settings/GraphSettings.test.ts +++ b/src/services/settings/GraphSettings.test.ts @@ -1,6 +1,11 @@ import joplin from 'api'; import { SettingItemType } from 'api/types'; -import { registerGraphSettings, isAiAnalysisEnabled, getSimilaritySettings } from './GraphSettings'; +import { + registerGraphSettings, + isAiAnalysisEnabled, + getSimilaritySettings, + getScopeSettings, +} from './GraphSettings'; describe('GraphSettings', () => { beforeEach(() => { @@ -57,6 +62,16 @@ describe('GraphSettings', () => { public: true, section: 'noteGraph', }), + 'noteGraph.scopeMode': expect.objectContaining({ + type: SettingItemType.String, + value: 'all', + public: false, + }), + 'noteGraph.scopeSelectedNotebooks': expect.objectContaining({ + type: SettingItemType.String, + value: '', + public: false, + }), }) ); }); @@ -152,4 +167,46 @@ describe('GraphSettings', () => { expect(secondResult).toEqual({ threshold: 0.5, topK: 5 }); }); }); + + describe('getScopeSettings', () => { + it('reads a JSON-encoded list of selected notebook IDs', async () => { + (joplin.settings.values as jest.Mock).mockResolvedValue({ + 'noteGraph.scopeMode': 'selected', + 'noteGraph.scopeSelectedNotebooks': JSON.stringify(['id-1', 'id-2']), + }); + + const result = await getScopeSettings(); + + expect(joplin.settings.values).toHaveBeenCalledWith([ + 'noteGraph.scopeMode', + 'noteGraph.scopeSelectedNotebooks', + ]); + expect(result).toEqual({ + mode: 'selected', + selectedNotebookIds: ['id-1', 'id-2'], + }); + }); + + it('falls back to "all" for an unrecognized or missing mode', async () => { + (joplin.settings.values as jest.Mock).mockResolvedValue({ + 'noteGraph.scopeMode': undefined, + 'noteGraph.scopeSelectedNotebooks': '', + }); + + const result = await getScopeSettings(); + + expect(result).toEqual({ mode: 'all', selectedNotebookIds: [] }); + }); + + it('reads the "current" mode', async () => { + (joplin.settings.values as jest.Mock).mockResolvedValue({ + 'noteGraph.scopeMode': 'current', + 'noteGraph.scopeSelectedNotebooks': '', + }); + + const result = await getScopeSettings(); + + expect(result.mode).toBe('current'); + }); + }); }); diff --git a/src/services/settings/GraphSettings.ts b/src/services/settings/GraphSettings.ts index 159fe16..458a185 100644 --- a/src/services/settings/GraphSettings.ts +++ b/src/services/settings/GraphSettings.ts @@ -1,6 +1,7 @@ import joplin from 'api'; import { SettingItemType } from 'api/types'; import { DEFAULT_THRESHOLD, TOP_K } from '../similarity/ThresholdPresets'; +import { ScopeMode, ScopeSettings } from './NoteScopeResolver'; const SECTION_NAME = 'noteGraph'; export const AI_ANALYSIS_ENABLED_KEY = 'noteGraph.aiAnalysisEnabled'; @@ -9,6 +10,10 @@ const MAX_EDGES_PER_NOTE_KEY = 'noteGraph.maxEdgesPerNote'; export const LLM_ENRICHMENT_ENABLED_KEY = 'noteGraph.llmEnrichmentEnabled'; export const RETRY_EMBEDDING_KEY = 'noteGraph.retryEmbedding'; export const RETRY_ENRICHMENT_KEY = 'noteGraph.retryEnrichment'; +export const SCOPE_MODE_KEY = 'noteGraph.scopeMode'; +export const SCOPE_SELECTED_NOTEBOOKS_KEY = 'noteGraph.scopeSelectedNotebooks'; + +export const SCOPE_SETTING_KEYS = [SCOPE_MODE_KEY, SCOPE_SELECTED_NOTEBOOKS_KEY]; /** All Note Graph setting keys — the single source of truth for anything that needs to check "did one of our settings change?" */ export const NOTE_GRAPH_SETTING_KEYS = [ @@ -18,6 +23,7 @@ export const NOTE_GRAPH_SETTING_KEYS = [ LLM_ENRICHMENT_ENABLED_KEY, RETRY_EMBEDDING_KEY, RETRY_ENRICHMENT_KEY, + ...SCOPE_SETTING_KEYS, ]; /** @@ -48,7 +54,8 @@ export async function registerGraphSettings(): Promise { public: true, section: SECTION_NAME, label: 'Similarity threshold (%)', - description: 'Lower value = more semantic edges. Only applies when AI analysis is enabled.', + description: + 'Lower value = more semantic edges. Only applies when AI analysis is enabled.', }, [MAX_EDGES_PER_NOTE_KEY]: { value: TOP_K, @@ -88,6 +95,18 @@ export async function registerGraphSettings(): Promise { description: 'Tick to immediately retry LLM analysis for any note/edge still missing a label. Unticks itself once the retry starts. No-op if the graph panel has not been opened yet.', }, + [SCOPE_MODE_KEY]: { + value: 'all', + type: SettingItemType.String, + public: false, + label: 'Analysis scope', + }, + [SCOPE_SELECTED_NOTEBOOKS_KEY]: { + value: '', + type: SettingItemType.String, + public: false, + label: 'Selected notebooks', + }, }); } @@ -99,6 +118,33 @@ export async function isLlmEnrichmentEnabled(): Promise { return await joplin.settings.value(LLM_ENRICHMENT_ENABLED_KEY); } +function parseScopeMode(value: unknown): ScopeMode { + return value === 'current' || value === 'selected' ? value : 'all'; +} + +function parseSelectedNotebookIds(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) && parsed.every((id) => typeof id === 'string') + ? parsed + : []; + } catch { + return []; + } +} + +export async function getScopeSettings(): Promise { + const values = await joplin.settings.values([SCOPE_MODE_KEY, SCOPE_SELECTED_NOTEBOOKS_KEY]); + const mode = parseScopeMode(values[SCOPE_MODE_KEY]); + const rawIds = + typeof values[SCOPE_SELECTED_NOTEBOOKS_KEY] === 'string' + ? values[SCOPE_SELECTED_NOTEBOOKS_KEY] + : ''; + const selectedNotebookIds = parseSelectedNotebookIds(rawIds); + + return { mode, selectedNotebookIds }; +} + const THRESHOLD_MIN_PERCENT = 0; const THRESHOLD_MAX_PERCENT = 100; const TOP_K_MIN = 1; diff --git a/src/services/settings/NoteScopeResolver.test.ts b/src/services/settings/NoteScopeResolver.test.ts new file mode 100644 index 0000000..3bc76ad --- /dev/null +++ b/src/services/settings/NoteScopeResolver.test.ts @@ -0,0 +1,138 @@ +import joplin from 'api'; +import { NoteScopeResolver, currentScopeKey } from './NoteScopeResolver'; +import { FolderRepository } from '../../data/FolderRepository'; + +jest.mock('../../data/FolderRepository'); + +const MockFolderRepository = FolderRepository as jest.MockedClass; + +function folders() { + return [ + { id: 'root-a', parent_id: '', title: 'Work' }, + { id: 'child-a1', parent_id: 'root-a', title: 'Projects' }, + { id: 'grandchild-a1a', parent_id: 'child-a1', title: 'Alpha' }, + { id: 'root-b', parent_id: '', title: 'Personal' }, + ]; +} + +describe('NoteScopeResolver', () => { + let mockFolderRepo: jest.Mocked; + let resolver: NoteScopeResolver; + + beforeEach(() => { + jest.clearAllMocks(); + mockFolderRepo = new MockFolderRepository() as jest.Mocked; + mockFolderRepo.getAllFolders.mockResolvedValue({ folders: folders(), truncated: false }); + resolver = new NoteScopeResolver(mockFolderRepo); + }); + + it('returns no filter for "all" without touching the folder tree', async () => { + const result = await resolver.resolve({ mode: 'all', selectedNotebookIds: [] }); + + expect(result).toEqual({ folderIds: null, scopeKey: 'all' }); + expect(mockFolderRepo.getAllFolders).not.toHaveBeenCalled(); + }); + + describe('mode: current', () => { + it('includes the selected notebook and its full sub-notebook tree', async () => { + (joplin.workspace.selectedFolder as jest.Mock).mockResolvedValue({ id: 'root-a' }); + + const result = await resolver.resolve({ mode: 'current', selectedNotebookIds: [] }); + + expect(result.folderIds).toEqual(new Set(['root-a', 'child-a1', 'grandchild-a1a'])); + expect(result.scopeKey).toBe('current:root-a'); + }); + + it('falls back to all notebooks when no notebook is currently selected', async () => { + (joplin.workspace.selectedFolder as jest.Mock).mockResolvedValue(null); + + const result = await resolver.resolve({ mode: 'current', selectedNotebookIds: [] }); + + expect(result).toEqual({ folderIds: null, scopeKey: 'all' }); + }); + + it('falls back to all notebooks when selectedFolder() throws', async () => { + (joplin.workspace.selectedFolder as jest.Mock).mockRejectedValue( + new Error('no folder') + ); + + const result = await resolver.resolve({ mode: 'current', selectedNotebookIds: [] }); + + expect(result).toEqual({ folderIds: null, scopeKey: 'all' }); + }); + + it('scopes to a leaf notebook with no children as just itself', async () => { + (joplin.workspace.selectedFolder as jest.Mock).mockResolvedValue({ id: 'root-b' }); + + const result = await resolver.resolve({ mode: 'current', selectedNotebookIds: [] }); + + expect(result.folderIds).toEqual(new Set(['root-b'])); + }); + }); + + describe('mode: selected', () => { + it('matches configured IDs and includes their sub-notebooks', async () => { + const result = await resolver.resolve({ + mode: 'selected', + selectedNotebookIds: ['root-a'], + }); + + expect(result.folderIds).toEqual(new Set(['root-a', 'child-a1', 'grandchild-a1a'])); + expect(result.scopeKey).toBe('selected:child-a1,grandchild-a1a,root-a'); + }); + + it('unions multiple selected notebooks', async () => { + const result = await resolver.resolve({ + mode: 'selected', + selectedNotebookIds: ['root-a', 'root-b'], + }); + + expect(result.folderIds).toEqual( + new Set(['root-a', 'child-a1', 'grandchild-a1a', 'root-b']) + ); + }); + + it('scopes by ID, so two notebooks sharing a title are not conflated', async () => { + mockFolderRepo.getAllFolders.mockResolvedValue({ + folders: [ + { id: 'work-1', parent_id: '', title: 'Work' }, + { id: 'work-2', parent_id: '', title: 'Work' }, + ], + truncated: false, + }); + + const result = await resolver.resolve({ + mode: 'selected', + selectedNotebookIds: ['work-1'], + }); + + expect(result.folderIds).toEqual(new Set(['work-1'])); + expect(result.scopeKey).toBe('selected:work-1'); + }); + + it('falls back to all notebooks when no configured ID matches', async () => { + const result = await resolver.resolve({ + mode: 'selected', + selectedNotebookIds: ['nonexistent-id'], + }); + + expect(result).toEqual({ folderIds: null, scopeKey: 'all' }); + }); + + it('falls back to all notebooks when no IDs are configured', async () => { + const result = await resolver.resolve({ mode: 'selected', selectedNotebookIds: [] }); + + expect(result).toEqual({ folderIds: null, scopeKey: 'all' }); + }); + }); + + describe('currentScopeKey', () => { + it('derives the scope key from a folder id', () => { + expect(currentScopeKey('root-a')).toBe('current:root-a'); + }); + + it('returns the all-scope key when no folder is selected', () => { + expect(currentScopeKey(null)).toBe('all'); + }); + }); +}); diff --git a/src/services/settings/NoteScopeResolver.ts b/src/services/settings/NoteScopeResolver.ts new file mode 100644 index 0000000..c1c2ce3 --- /dev/null +++ b/src/services/settings/NoteScopeResolver.ts @@ -0,0 +1,98 @@ +import joplin from 'api'; +import { Folder, FolderRepository } from '../../data/FolderRepository'; + +export type ScopeMode = 'all' | 'current' | 'selected'; + +export interface ScopeSettings { + mode: ScopeMode; + selectedNotebookIds: string[]; +} + +export interface ResolvedScope { + folderIds: Set | null; + scopeKey: string; +} + +const ALL_SCOPE: ResolvedScope = { folderIds: null, scopeKey: 'all' }; + +export function currentScopeKey(folderId: string | null): string { + return folderId ? `current:${folderId}` : ALL_SCOPE.scopeKey; +} + +export class NoteScopeResolver { + public constructor(private readonly folderRepository = new FolderRepository()) {} + + public async resolve(settings: ScopeSettings): Promise { + if (settings.mode === 'all') { + return ALL_SCOPE; + } + + const { folders, truncated } = await this.folderRepository.getAllFolders(); + if (truncated) { + console.error( + 'Note Graph scope: notebook list is incomplete; some notebooks may be missing from the scope.' + ); + } + + if (settings.mode === 'current') { + return this.resolveCurrent(folders); + } + return this.resolveSelected(folders, settings.selectedNotebookIds); + } + + private async resolveCurrent(folders: Folder[]): Promise { + const current = await joplin.workspace.selectedFolder().catch(() => null); + if (!current) { + console.info( + 'Note Graph scope: "current notebook" is selected but no notebook is open; showing all notebooks instead.' + ); + return ALL_SCOPE; + } + + const folderIds = this.expandSubtree(folders, [current.id]); + return { folderIds, scopeKey: currentScopeKey(current.id) }; + } + + private resolveSelected(folders: Folder[], ids: string[]): ResolvedScope { + const wantedIds = new Set(ids); + const roots = folders.filter((f) => wantedIds.has(f.id)); + + if (roots.length === 0) { + console.info( + 'Note Graph scope: none of the selected notebook IDs matched an existing notebook; showing all notebooks instead.' + ); + return ALL_SCOPE; + } + + const folderIds = this.expandSubtree( + folders, + roots.map((f) => f.id) + ); + const scopeKey = `selected:${Array.from(folderIds).sort().join(',')}`; + return { folderIds, scopeKey }; + } + + private expandSubtree(folders: Folder[], rootIds: string[]): Set { + const childrenByParent = new Map(); + for (const folder of folders) { + const list = childrenByParent.get(folder.parent_id); + if (list) { + list.push(folder.id); + } else { + childrenByParent.set(folder.parent_id, [folder.id]); + } + } + + const included = new Set(); + const queue = [...rootIds]; + while (queue.length > 0) { + const id = queue.shift() as string; + if (included.has(id)) continue; + included.add(id); + for (const childId of childrenByParent.get(id) ?? []) { + queue.push(childId); + } + } + return included; + } +} diff --git a/src/services/similarity/EdgeFactory.test.ts b/src/services/similarity/EdgeFactory.test.ts index 3e8dbaa..4f80957 100644 --- a/src/services/similarity/EdgeFactory.test.ts +++ b/src/services/similarity/EdgeFactory.test.ts @@ -1,4 +1,4 @@ -import { EdgeFactory } from './EdgeFactory'; +import { EdgeFactory, TAG_CAPPED_NEIGHBORS_PER_NOTE, TAG_CLIQUE_MAX_NOTES } from './EdgeFactory'; import { Note } from '../../data/Types'; function note(id: string, title: string, links: string[] = [], tags: string[] = []): Note { @@ -93,8 +93,12 @@ describe('EdgeFactory', () => { note('x', 'X', [], ['t2']), ]); - const forwardEdge = forward.find((e) => e.type === 'tag' && e.source === 'a' && e.target === 'b'); - const reversedEdge = reversed.find((e) => e.type === 'tag' && e.source === 'a' && e.target === 'b'); + const forwardEdge = forward.find( + (e) => e.type === 'tag' && e.source === 'a' && e.target === 'b' + ); + const reversedEdge = reversed.find( + (e) => e.type === 'tag' && e.source === 'a' && e.target === 'b' + ); expect(forwardEdge?.tagName).toBe(reversedEdge?.tagName); }); @@ -121,6 +125,62 @@ describe('EdgeFactory', () => { expect(factory.createEdges([note('a', 'A', ['a'])])).toEqual([]); }); + describe('large tags (more than TAG_CLIQUE_MAX_NOTES notes)', () => { + const pad = (i: number): string => String(i).padStart(2, '0'); + const bigTagNotes = (count: number): Note[] => + Array.from({ length: count }, (_, i) => note(`n${pad(i)}`, `N${i}`, [], ['big'])); + + it('caps each note to TAG_CAPPED_NEIGHBORS_PER_NOTE neighbors instead of skipping the tag', () => { + const notes = bigTagNotes(TAG_CLIQUE_MAX_NOTES + 1); + const edges = factory.createEdges(notes).filter((e) => e.type === 'tag'); + + expect(edges).toHaveLength(notes.length * TAG_CAPPED_NEIGHBORS_PER_NOTE); + + const degree = new Map(); + for (const edge of edges) { + degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); + degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); + } + for (const n of notes) { + expect(degree.get(n.id)).toBe(TAG_CAPPED_NEIGHBORS_PER_NOTE * 2); + } + + for (const edge of edges) { + expect(edge.source).not.toBe(edge.target); + } + }); + + it('keeps a full clique at the threshold and caps just above it', () => { + const clique = factory + .createEdges(bigTagNotes(TAG_CLIQUE_MAX_NOTES)) + .filter((e) => e.type === 'tag'); + expect(clique).toHaveLength((TAG_CLIQUE_MAX_NOTES * (TAG_CLIQUE_MAX_NOTES - 1)) / 2); + + const capped = factory + .createEdges(bigTagNotes(TAG_CLIQUE_MAX_NOTES + 1)) + .filter((e) => e.type === 'tag'); + expect(capped).toHaveLength((TAG_CLIQUE_MAX_NOTES + 1) * TAG_CAPPED_NEIGHBORS_PER_NOTE); + }); + + it('produces identical edges regardless of note iteration order', () => { + const forward = factory.createEdges(bigTagNotes(TAG_CLIQUE_MAX_NOTES + 1)); + const reversed = factory.createEdges(bigTagNotes(TAG_CLIQUE_MAX_NOTES + 1).reverse()); + expect(forward).toEqual(reversed); + }); + + it('merges a small-tag pair onto the same edge when it is also connected by a capped tag', () => { + const notes = bigTagNotes(TAG_CLIQUE_MAX_NOTES + 1); + notes[0].tags = ['big', 'small']; + notes[1].tags = ['big', 'small']; + + const edges = factory.createEdges(notes).filter((e) => e.type === 'tag'); + const shared = edges.find((e) => e.source === 'n00' && e.target === 'n01'); + + expect(shared).toBeDefined(); + expect(shared!.tagName).toBe('big, small'); + }); + }); + describe('createSemanticEdges', () => { it('returns empty for no pairs', () => { expect(factory.createSemanticEdges([])).toEqual([]); @@ -128,7 +188,7 @@ describe('EdgeFactory', () => { it('creates a semantic edge for each positive-score pair', () => { const edges = factory.createSemanticEdges([{ source: 'a', target: 'b', score: 0.8 }]); - expect(edges).toEqual([{ source: 'a', target: 'b', type: 'semantic' }]); + expect(edges).toEqual([{ source: 'a', target: 'b', type: 'semantic', score: 0.8 }]); }); it('excludes pairs with a non-positive score', () => { diff --git a/src/services/similarity/EdgeFactory.ts b/src/services/similarity/EdgeFactory.ts index b038c03..6ad4a4c 100644 --- a/src/services/similarity/EdgeFactory.ts +++ b/src/services/similarity/EdgeFactory.ts @@ -2,8 +2,8 @@ import { Note } from '../../data/Types'; import { GraphEdge } from '../graph/types'; import { SimilarityPair } from './SimilarityEngine'; -/** Tags shared by more notes than this are skipped entirely, to avoid a combinatorial blowup of pairs (a clique on n notes is n*(n-1)/2 edges). */ -const MAX_NOTES_PER_TAG = 20; +export const TAG_CLIQUE_MAX_NOTES = 20; +export const TAG_CAPPED_NEIGHBORS_PER_NOTE = 4; export class EdgeFactory { /** @@ -40,32 +40,18 @@ export class EdgeFactory { return Array.from(linkEdgeMap.values()); } - /** - * Builds one edge per pair of notes sharing a tag, merging multiple shared - * tag names onto the same edge. Tags shared by more than 20 notes are - * skipped to avoid a combinatorial blowup of pairs. - */ private createTagEdges(notes: Note[]): GraphEdge[] { const tagToNotes = this.groupNoteIdsByTag(notes); - const tagEdgeMap = new Map(); + const tagEdgeMap = new Map< + string, + { source: string; target: string; tagNames: string[] } + >(); for (const [tagName, noteIds] of tagToNotes) { - if (noteIds.length > MAX_NOTES_PER_TAG) continue; - - for (let i = 0; i < noteIds.length; i++) { - for (let j = i + 1; j < noteIds.length; j++) { - const a = noteIds[i]; - const b = noteIds[j]; - const [source, target] = a < b ? [a, b] : [b, a]; - const pairKey = `${source}::${target}`; - - const existing = tagEdgeMap.get(pairKey); - if (existing) { - existing.tagNames.push(tagName); - } else { - tagEdgeMap.set(pairKey, { source, target, tagNames: [tagName] }); - } - } + if (noteIds.length <= TAG_CLIQUE_MAX_NOTES) { + this.connectClique(tagEdgeMap, noteIds, tagName); + } else { + this.connectCapped(tagEdgeMap, noteIds, tagName); } } @@ -77,6 +63,51 @@ export class EdgeFactory { })); } + private connectClique( + tagEdgeMap: Map, + noteIds: string[], + tagName: string + ): void { + for (let i = 0; i < noteIds.length; i++) { + for (let j = i + 1; j < noteIds.length; j++) { + this.addTagPair(tagEdgeMap, noteIds[i], noteIds[j], tagName); + } + } + } + + private connectCapped( + tagEdgeMap: Map, + noteIds: string[], + tagName: string + ): void { + const sorted = [...noteIds].sort(); + const m = sorted.length; + const k = Math.min(TAG_CAPPED_NEIGHBORS_PER_NOTE, m - 1); + + for (let i = 0; i < m; i++) { + for (let j = 1; j <= k; j++) { + this.addTagPair(tagEdgeMap, sorted[i], sorted[(i + j) % m], tagName); + } + } + } + + private addTagPair( + tagEdgeMap: Map, + a: string, + b: string, + tagName: string + ): void { + const [source, target] = a < b ? [a, b] : [b, a]; + const pairKey = `${source}::${target}`; + + const existing = tagEdgeMap.get(pairKey); + if (existing) { + existing.tagNames.push(tagName); + } else { + tagEdgeMap.set(pairKey, { source, target, tagNames: [tagName] }); + } + } + private groupNoteIdsByTag(notes: Note[]): Map { const tagToNotes = new Map(); for (const note of notes) { @@ -104,6 +135,7 @@ export class EdgeFactory { source: pair.source, target: pair.target, type: 'semantic', + score: pair.score, }); } diff --git a/src/services/sync/IncrementalUpdater.test.ts b/src/services/sync/IncrementalUpdater.test.ts index 9ca9ee0..99b3bfe 100644 --- a/src/services/sync/IncrementalUpdater.test.ts +++ b/src/services/sync/IncrementalUpdater.test.ts @@ -6,6 +6,7 @@ import { NotePreprocessor } from '../../data/NotePreprocessor'; import { EventsRepository } from '../../data/EventsRepository'; import { GraphCacheRepository } from '../../data/Database/GraphCacheRepository'; import { Note } from '../../data/Types'; +import { ResolvedScope } from '../settings/NoteScopeResolver'; jest.mock('../AnalysisController'); jest.mock('../../data/NoteRepository'); @@ -17,7 +18,9 @@ const MockAnalysisController = AnalysisController as jest.MockedClass; const MockPreprocessor = NotePreprocessor as jest.MockedClass; const MockEventsRepository = EventsRepository as jest.MockedClass; -const MockGraphCacheRepository = GraphCacheRepository as jest.MockedClass; +const MockGraphCacheRepository = GraphCacheRepository as jest.MockedClass< + typeof GraphCacheRepository +>; const COALESCE_WINDOW_MS = 1000; @@ -48,6 +51,7 @@ describe('IncrementalUpdater', () => { let onFullReloadNeeded: jest.Mock; let checkAiEnabled: jest.Mock, []>; let onRetriesExhausted: jest.Mock; + let getCurrentScope: jest.Mock; let ai: { getIndexStatus: jest.Mock; getEmbeddings: jest.Mock }; let updater: IncrementalUpdater; @@ -84,6 +88,7 @@ describe('IncrementalUpdater', () => { onFullReloadNeeded = jest.fn().mockResolvedValue(undefined); checkAiEnabled = jest.fn().mockResolvedValue(false); onRetriesExhausted = jest.fn(); + getCurrentScope = jest.fn().mockReturnValue({ folderIds: null, scopeKey: 'all' }); ai = joplin.ai as unknown as { getIndexStatus: jest.Mock; getEmbeddings: jest.Mock }; ai.getIndexStatus.mockResolvedValue({ ready: true, state: 'ready', modelId: 'test-model' }); @@ -104,7 +109,9 @@ describe('IncrementalUpdater', () => { graphCache, COALESCE_WINDOW_MS, checkAiEnabled, - onRetriesExhausted + onRetriesExhausted, + Date.now, + getCurrentScope ); }); @@ -134,7 +141,9 @@ describe('IncrementalUpdater', () => { updater.handleNoteChange({ id: 'a', event: 1 }); await jest.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); - expect(consoleInfoSpy).toHaveBeenCalledWith('Incremental update applied: 1 upserted, 0 removed.'); + expect(consoleInfoSpy).toHaveBeenCalledWith( + 'Incremental update applied: 1 upserted, 0 removed.' + ); consoleInfoSpy.mockRestore(); }); @@ -189,7 +198,9 @@ describe('IncrementalUpdater', () => { await jest.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); expect(onGraphPatch).not.toHaveBeenCalled(); - expect(consoleInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Incremental update applied')); + expect(consoleInfoSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Incremental update applied') + ); consoleInfoSpy.mockRestore(); }); @@ -310,6 +321,36 @@ describe('IncrementalUpdater', () => { expect(analysisController.applyDelta).toHaveBeenCalledWith([], ['a']); }); + it('treats an edited note outside the configured scope as a removal instead of leaking it in', async () => { + getCurrentScope.mockReturnValue({ + folderIds: new Set(['scoped-folder']), + scopeKey: 'current:scoped-folder', + }); + noteRepository.getNote.mockResolvedValue({ ...note('a'), parent_id: 'other-folder' }); + + updater.handleNoteChange({ id: 'a', event: 2 }); + await jest.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); + + expect(preprocessor.processOne).not.toHaveBeenCalled(); + expect(analysisController.applyDelta).toHaveBeenCalledWith([], ['a']); + }); + + it('upserts an edited note that is inside the configured scope', async () => { + getCurrentScope.mockReturnValue({ + folderIds: new Set(['scoped-folder']), + scopeKey: 'current:scoped-folder', + }); + noteRepository.getNote.mockResolvedValue({ ...note('a'), parent_id: 'scoped-folder' }); + + updater.handleNoteChange({ id: 'a', event: 2 }); + await jest.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); + + expect(analysisController.applyDelta).toHaveBeenCalledWith( + [{ ...note('a'), parent_id: 'scoped-folder' }], + [] + ); + }); + it('falls back to a full reload if the debounced flush fails to fetch the changed note', async () => { noteRepository.getNote.mockRejectedValue(new Error('network error')); @@ -321,7 +362,9 @@ describe('IncrementalUpdater', () => { }); it('logs and requeues the delta when the full-reload fallback itself also fails, instead of dropping it silently', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); noteRepository.getNote.mockRejectedValue(new Error('network error')); onFullReloadNeeded.mockRejectedValueOnce(new Error('reload also failed')); @@ -346,7 +389,9 @@ describe('IncrementalUpdater', () => { }); it('does not auto-reschedule after a double failure, but a later sync sweep still picks up the requeued id', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); noteRepository.getNote.mockRejectedValue(new Error('network error')); onFullReloadNeeded.mockRejectedValueOnce(new Error('reload also failed')); @@ -369,7 +414,9 @@ describe('IncrementalUpdater', () => { noteRepository.getNote.mockResolvedValue(note('a')); const enrichedGraphData = { nodes: [], edges: [] }; analysisController.enrichCurrentGraph.mockResolvedValue(enrichedGraphData); - analysisController.getLastDiff.mockReturnValueOnce(fakeDiff).mockReturnValueOnce(fakeDiff); + analysisController.getLastDiff + .mockReturnValueOnce(fakeDiff) + .mockReturnValueOnce(fakeDiff); updater.handleNoteChange({ id: 'a', event: 1 }); await jest.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); @@ -416,7 +463,10 @@ describe('IncrementalUpdater', () => { }); it('sweeps with no cursor on the first-ever call and persists the returned baseline', async () => { - eventsRepository.getNoteEventsSince.mockResolvedValue({ events: [], cursor: 'baseline-1' }); + eventsRepository.getNoteEventsSince.mockResolvedValue({ + events: [], + cursor: 'baseline-1', + }); await updater.handleSyncComplete(); @@ -426,7 +476,10 @@ describe('IncrementalUpdater', () => { it('resumes from the persisted cursor on subsequent calls', async () => { graphCache.loadEventsCursor.mockResolvedValue('cursor-1'); - eventsRepository.getNoteEventsSince.mockResolvedValue({ events: [], cursor: 'cursor-2' }); + eventsRepository.getNoteEventsSince.mockResolvedValue({ + events: [], + cursor: 'cursor-2', + }); await updater.handleSyncComplete(); @@ -482,7 +535,10 @@ describe('IncrementalUpdater', () => { await updater.handleSyncComplete(); - expect(ai.getEmbeddings).toHaveBeenCalledWith({ cursor: 'embeddings-cursor-1', limit: 1000 }); + expect(ai.getEmbeddings).toHaveBeenCalledWith({ + cursor: 'embeddings-cursor-1', + limit: 1000, + }); expect(graphCache.saveEmbeddingsCursor).toHaveBeenCalledWith('embeddings-cursor-1'); expect(analysisController.applyDelta).toHaveBeenCalledWith([note('a')], []); }); @@ -547,7 +603,11 @@ describe('IncrementalUpdater', () => { }); it('falls back to /events upserts for this sync when the embeddings sweep fails, without a full reload', async () => { - ai.getIndexStatus.mockResolvedValue({ ready: false, state: 'preparing', modelId: null }); + ai.getIndexStatus.mockResolvedValue({ + ready: false, + state: 'preparing', + modelId: null, + }); eventsRepository.getNoteEventsSince.mockResolvedValue({ events: [{ noteId: 'a', type: 'updated' }], cursor: 'events-cursor-2', @@ -562,13 +622,65 @@ describe('IncrementalUpdater', () => { }); it('still falls back to a full reload if the /events sweep itself also fails', async () => { - ai.getIndexStatus.mockResolvedValue({ ready: false, state: 'preparing', modelId: null }); + ai.getIndexStatus.mockResolvedValue({ + ready: false, + state: 'preparing', + modelId: null, + }); eventsRepository.getNoteEventsSince.mockRejectedValue(new Error('network error')); await updater.handleSyncComplete(); expect(onFullReloadNeeded).toHaveBeenCalledTimes(1); }); + + it('throttles the embeddings sweep to at most once per 5 minutes, falling back to /events in between', async () => { + let now = 10 * 60 * 1000; + const throttledUpdater = new IncrementalUpdater( + analysisController, + onGraphPatch, + onFullReloadNeeded, + noteRepository, + preprocessor, + eventsRepository, + graphCache, + COALESCE_WINDOW_MS, + checkAiEnabled, + onRetriesExhausted, + () => now, + getCurrentScope + ); + ai.getEmbeddings.mockResolvedValue({ + modelId: 'test-model', + dimension: 2, + chunks: [{ noteId: 'embed-note', vector: [1, 0] }], + nextCursor: undefined, + }); + eventsRepository.getNoteEventsSince.mockResolvedValue({ + events: [{ noteId: 'events-note', type: 'updated' }], + cursor: 'events-cursor-1', + }); + noteRepository.getNote.mockImplementation(async (id) => note(id)); + + await throttledUpdater.handleSyncComplete(); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(1); + expect(analysisController.applyDelta).toHaveBeenLastCalledWith( + [note('embed-note')], + [] + ); + + now += 60 * 1000; + await throttledUpdater.handleSyncComplete(); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(1); + expect(analysisController.applyDelta).toHaveBeenLastCalledWith( + [note('events-note')], + [] + ); + + now += 5 * 60 * 1000; + await throttledUpdater.handleSyncComplete(); + expect(ai.getEmbeddings).toHaveBeenCalledTimes(2); + }); }); describe('flush serialization', () => { @@ -616,7 +728,7 @@ describe('IncrementalUpdater', () => { expect(analysisController.applyDelta).toHaveBeenCalledTimes(2); }); - it('applies a second flush\'s Pass A patch without waiting for an earlier flush\'s slow Pass B enrichment', async () => { + it("applies a second flush's Pass A patch without waiting for an earlier flush's slow Pass B enrichment", async () => { noteRepository.getNote.mockImplementation(async (id) => note(id)); let resolveFirstEnrich: (value: unknown) => void = () => undefined; let enrichCalls = 0; diff --git a/src/services/sync/IncrementalUpdater.ts b/src/services/sync/IncrementalUpdater.ts index e873a31..0a44df9 100644 --- a/src/services/sync/IncrementalUpdater.ts +++ b/src/services/sync/IncrementalUpdater.ts @@ -7,8 +7,15 @@ import { GraphCacheRepository } from '../../data/Database/GraphCacheRepository'; import { AnalysisController } from '../AnalysisController'; import { GraphDiff } from '../graph/GraphDiffer'; import { GraphData } from '../graph/types'; -import { JoplinAiApi, isIndexUsable } from '../embeddings/providers/JoplinNativeProvider'; +import { + JoplinAiApi, + isIndexUsable, + retryWithBackoff, +} from '../embeddings/providers/JoplinNativeProvider'; import { isAiAnalysisEnabled } from '../settings/GraphSettings'; +import { ResolvedScope } from '../settings/NoteScopeResolver'; + +const UNSCOPED: ResolvedScope = { folderIds: null, scopeKey: 'all' }; const ITEM_CHANGE_DELETE = 3; @@ -17,12 +24,18 @@ const EMBEDDINGS_MAX_PAGES = 500; const DEFAULT_COALESCE_WINDOW_MS = 1000; const MAX_CONSECUTIVE_RETRY_SKIPS = 5; +const EMBEDDINGS_SWEEP_MIN_INTERVAL_MS = 5 * 60 * 1000; + +const INDEX_STATUS_MAX_ATTEMPTS = 3; +const INDEX_STATUS_RETRY_DELAY_MS = 1000; + export class IncrementalUpdater { private readonly pendingUpsertIds = new Set(); private readonly pendingRemovedIds = new Set(); private flushTimer: ReturnType | null = null; private flushChain: Promise = Promise.resolve(); private consecutiveRetrySkips = 0; + private lastEmbeddingsSweepAt = 0; public constructor( private readonly analysisController: AnalysisController, @@ -35,6 +48,8 @@ export class IncrementalUpdater { private readonly coalesceWindowMs = DEFAULT_COALESCE_WINDOW_MS, private readonly checkAiEnabled: () => Promise = isAiAnalysisEnabled, private readonly onRetriesExhausted: () => void = () => {}, + private readonly now: () => number = Date.now, + private readonly getCurrentScope: () => ResolvedScope = () => UNSCOPED, private readonly onEnrichmentProgress: (progress: { current: number; total: number }) => void = () => {} ) {} @@ -60,12 +75,21 @@ export class IncrementalUpdater { let embeddingsSweepFailed = false; if (aiEnabled) { - try { - const upsertIds = await this.detectEmbeddingUpserts(); - for (const id of upsertIds) this.scheduleUpsert(id); - } catch (e) { - console.error('Embeddings sweep failed, falling back to /events for this sync:', e); + if (this.now() - this.lastEmbeddingsSweepAt < EMBEDDINGS_SWEEP_MIN_INTERVAL_MS) { + console.info('Embeddings sweep throttled; relying on /events for this sync.'); embeddingsSweepFailed = true; + } else { + try { + const upsertIds = await this.detectEmbeddingUpserts(); + this.lastEmbeddingsSweepAt = this.now(); + for (const id of upsertIds) this.scheduleUpsert(id); + } catch (e) { + console.error( + 'Embeddings sweep failed, falling back to /events for this sync:', + e + ); + embeddingsSweepFailed = true; + } } } @@ -94,7 +118,10 @@ export class IncrementalUpdater { while (pageCount < EMBEDDINGS_MAX_PAGES) { pageCount++; - const page = await api.getEmbeddings({ cursor: currentCursor, limit: EMBEDDINGS_PAGE_SIZE }); + const page = await api.getEmbeddings({ + cursor: currentCursor, + limit: EMBEDDINGS_PAGE_SIZE, + }); for (const chunk of page.chunks) { noteIds.add(chunk.noteId); } @@ -125,7 +152,10 @@ export class IncrementalUpdater { } private async ensureIndexUsable(api: JoplinAiApi): Promise { - const status = await api.getIndexStatus(); + const status = await retryWithBackoff('getIndexStatus() call', () => api.getIndexStatus(), { + maxAttempts: INDEX_STATUS_MAX_ATTEMPTS, + baseDelayMs: INDEX_STATUS_RETRY_DELAY_MS, + }); if (!status || !isIndexUsable(status.state)) { throw new Error( `Joplin AI index is not usable yet (state: ${status?.state ?? 'unknown'}). ` + @@ -227,7 +257,9 @@ export class IncrementalUpdater { this.consecutiveRetrySkips = 0; const removedCount = removedIds.length + discoveredRemovals.length; - console.info(`Incremental update applied: ${upserts.length} upserted, ${removedCount} removed.`); + console.info( + `Incremental update applied: ${upserts.length} upserted, ${removedCount} removed.` + ); const diff = this.analysisController.getLastDiff(); if (diff) { @@ -243,7 +275,10 @@ export class IncrementalUpdater { try { await this.onFullReloadNeeded(); } catch (fallbackError) { - console.error('Full-reload fallback also failed after an incremental flush error:', fallbackError); + console.error( + 'Full-reload fallback also failed after an incremental flush error:', + fallbackError + ); for (const id of upsertIds) this.pendingUpsertIds.add(id); for (const id of removedIds) this.pendingRemovedIds.add(id); } @@ -271,6 +306,11 @@ export class IncrementalUpdater { ): Promise<{ upserts: Note[]; discoveredRemovals: string[] }> { const upserts: Note[] = []; const discoveredRemovals: string[] = []; + if (ids.length === 0) { + return { upserts, discoveredRemovals }; + } + + const { folderIds } = this.getCurrentScope(); for (const id of ids) { const raw = await this.noteRepository.getNote(id); @@ -278,6 +318,10 @@ export class IncrementalUpdater { discoveredRemovals.push(id); continue; } + if (folderIds && !folderIds.has(raw.parent_id)) { + discoveredRemovals.push(id); + continue; + } upserts.push(await this.preprocessor.processOne(raw)); } diff --git a/src/tests/mocks/joplin.ts b/src/tests/mocks/joplin.ts index 8c0e698..8fa7e18 100644 --- a/src/tests/mocks/joplin.ts +++ b/src/tests/mocks/joplin.ts @@ -18,6 +18,8 @@ const joplinWorkspace = { onNoteChange: jest.fn(), onNoteSelectionChange: jest.fn(), onSyncComplete: jest.fn(), + selectedNote: jest.fn(), + selectedFolder: jest.fn(), }; const joplinViewsPanels = { diff --git a/src/ui/components/GraphControls.ts b/src/ui/components/GraphControls.ts index 6a68f54..9a17f58 100644 --- a/src/ui/components/GraphControls.ts +++ b/src/ui/components/GraphControls.ts @@ -4,6 +4,16 @@ const ZoomOutSvg = ` { return ` +
+ + +
diff --git a/src/ui/components/Header.ts b/src/ui/components/Header.ts index 2b207bc..93f0c6d 100644 --- a/src/ui/components/Header.ts +++ b/src/ui/components/Header.ts @@ -6,6 +6,10 @@ const LogoSvg = ``; +const NotebookSvg = ``; + +const ChevronSvg = ``; + const renderHeader = (props: HeaderProps = {}): string => { return `
@@ -14,10 +18,30 @@ const renderHeader = (props: HeaderProps = {}): string => { ${props.title ?? 'Note Graph'}
+
+ + +
+
`; }; -export { renderHeader }; \ No newline at end of file +export { renderHeader }; diff --git a/src/ui/components/Legend.ts b/src/ui/components/Legend.ts index 67478e4..dfb6e72 100644 --- a/src/ui/components/Legend.ts +++ b/src/ui/components/Legend.ts @@ -1,10 +1,12 @@ -const ExportSvg = ``; +const ExportSvg = ``; -const FitSvg = ``; +const FitSvg = ``; -const FocusSvg = ``; +const FocusSvg = ``; -const SearchSvg = ``; +const SearchSvg = ``; + +const GroupSvg = ``; const renderLegend = (): string => { return ` @@ -32,7 +34,11 @@ const renderLegend = (): string => {
+ +
@@ -43,4 +49,4 @@ const renderLegend = (): string => { `; }; -export { renderLegend }; \ No newline at end of file +export { renderLegend }; diff --git a/src/ui/components/StatsBar.ts b/src/ui/components/StatsBar.ts index 0859e7f..ed938f5 100644 --- a/src/ui/components/StatsBar.ts +++ b/src/ui/components/StatsBar.ts @@ -1,5 +1,7 @@ import { renderPipelineProgress } from './PipelineProgress'; +const ConfidenceSvg = ``; + const renderStatsBar = (): string => { return `
@@ -22,6 +24,12 @@ const renderStatsBar = (): string => { 0 semantic edges + ${renderPipelineProgress()}
`; diff --git a/src/ui/graph-view.js b/src/ui/graph-view.js index e5eb87b..f998fb7 100644 --- a/src/ui/graph-view.js +++ b/src/ui/graph-view.js @@ -13,19 +13,21 @@ var FCOSE_OPTIONS = { animationDuration: 800, fit: true, padding: 40, - nodeDimensionsIncludeLabels: false, - uniformNodeDimensions: true, + nodeDimensionsIncludeLabels: true, + uniformNodeDimensions: false, packComponents: true, - nodeSeparation: 140, - nodeRepulsion: function () { return 8000; }, - gravity: 0.12, + nodeSeparation: 200, + nodeRepulsion: function () { + return 20000; + }, + gravity: 0.05, gravityRange: 5.0, idealEdgeLength: 180, edgeElasticity: 0.2, numIter: 3000, tile: true, - tilingPaddingVertical: 25, - tilingPaddingHorizontal: 25, + tilingPaddingVertical: 40, + tilingPaddingHorizontal: 40, step: 'all', }; @@ -36,6 +38,33 @@ var INCREMENTAL_FCOSE_OVERRIDES = { packComponents: false, }; +var LAYOUT_FCOSE = 'fcose'; +var LAYOUT_HIERARCHICAL = 'hierarchical'; +var currentLayoutName = LAYOUT_FCOSE; + +function buildLayoutOptions(incremental, fixedNodeConstraint) { + if (currentLayoutName === LAYOUT_HIERARCHICAL) { + return { + name: 'breadthfirst', + directed: false, + fit: true, + padding: 40, + spacingFactor: 1.6, + avoidOverlap: true, + animate: !incremental, + animationDuration: 500, + }; + } + + var options = Object.assign({}, FCOSE_OPTIONS); + if (incremental) { + Object.assign(options, INCREMENTAL_FCOSE_OVERRIDES, { + fixedNodeConstraint: fixedNodeConstraint || [], + }); + } + return options; +} + function escapeHtml(value) { return value.replace(/&/g, '&').replace(//g, '>'); } @@ -67,6 +96,271 @@ var pipelineProgressLabelEl; var pipelineProgressCancelEl; var hasRenderedOnce = false; var lastSeenVersion = 0; +var categoryFilterEl; +var currentSearchQuery = ''; +var searchBorderTimer = null; +var densitySliderEl; +var densityValueEl; +var densityControlEl; +var currentMinConfidence = 0; +var edgeTypeOff = {}; +var focusBtnEl; +var focusActive = false; +var focusIsAutoFollowing = false; +var pendingFocusNoteId = null; + +function focusNeighborhoodOf(node) { + var hood = node.closedNeighborhood().add(node.neighborhood().nodes().neighborhood()); + return hood.add(hood.ancestors()); +} + +function applyVisibility() { + if (!cy) return; + + var focusHood = null; + if (focusActive) { + var sel = cy.nodes(':selected'); + if (sel.length > 0) focusHood = focusNeighborhoodOf(sel); + } + + cy.nodes().forEach(function (n) { + if (focusHood && !focusHood.has(n)) n.hide(); + else n.show(); + }); + + cy.edges().forEach(function (e) { + var type = e.data('type'); + var filtered = + !!edgeTypeOff[type] || + (type === 'semantic' && + typeof e.data('score') === 'number' && + e.data('score') < currentMinConfidence); + var hiddenByFocus = focusHood ? !focusHood.has(e) : false; + if (filtered || hiddenByFocus) e.hide(); + else e.show(); + }); +} + +function engageFocusMode() { + if (!cy || focusActive) return; + var sel = cy.nodes(':selected'); + if (sel.length === 0) return; + focusActive = true; + if (focusBtnEl) focusBtnEl.classList.add('legend-panel__action-btn--active'); + applyVisibility(); + cy.animate({ fit: { eles: focusNeighborhoodOf(sel), padding: 50 }, duration: 400 }); +} + +function focusNodeBeforeLayout(noteId) { + var node = cy.getElementById(noteId); + if (!node || node.empty()) return false; + cy.elements().unselect(); + node.select(); + focusActive = true; + focusIsAutoFollowing = true; + if (focusBtnEl) focusBtnEl.classList.add('legend-panel__action-btn--active'); + return true; +} + +function fitViewportToFocus() { + if (!cy) return; + if (focusActive) { + var sel = cy.nodes(':selected'); + if (sel.length > 0) { + cy.fit(focusNeighborhoodOf(sel), 40); + return; + } + } + cy.fit(undefined, 40); +} + +function disengageFocusMode() { + if (!cy || !focusActive) return; + focusActive = false; + focusIsAutoFollowing = false; + if (focusBtnEl) focusBtnEl.classList.remove('legend-panel__action-btn--active'); + applyVisibility(); + cy.fit(undefined, 30); +} + +function engageFocusOnNote(noteId) { + if (!cy) return false; + var node = cy.getElementById(noteId); + if (!node || node.empty()) return false; + if (focusActive && !focusIsAutoFollowing) return true; + focusActive = true; + focusIsAutoFollowing = true; + if (focusBtnEl) focusBtnEl.classList.add('legend-panel__action-btn--active'); + cy.elements().unselect(); + node.select(); + applyVisibility(); + cy.animate({ fit: { eles: focusNeighborhoodOf(node), padding: 50 }, duration: 400 }); + return true; +} + +function resolvePendingFocus() { + if (!pendingFocusNoteId || !cy) return; + var noteId = pendingFocusNoteId; + if (engageFocusOnNote(noteId)) { + pendingFocusNoteId = null; + } +} + +var COMMUNITY_PARENT_PREFIX = 'community::'; +var groupByCommunityEnabled = false; + +function communityParentId(community) { + return COMMUNITY_PARENT_PREFIX + community; +} + +function applyCommunityGrouping() { + if (!cy) return; + var realNodes = cy.nodes().filter(function (n) { + return !n.data('isCommunityParent'); + }); + + if (!groupByCommunityEnabled) { + realNodes.forEach(function (n) { + if (n.parent().nonempty()) n.move({ parent: null }); + }); + cy.nodes() + .filter(function (n) { + return n.data('isCommunityParent'); + }) + .remove(); + return; + } + + var communityCounts = {}; + realNodes.forEach(function (n) { + var community = n.data('community') || 0; + communityCounts[community] = (communityCounts[community] || 0) + 1; + }); + + var presentParentIds = {}; + Object.keys(communityCounts).forEach(function (community) { + if (communityCounts[community] <= 1) return; + var parentId = communityParentId(community); + presentParentIds[parentId] = true; + var label = 'Cluster (' + communityCounts[community] + ')'; + var existing = cy.getElementById(parentId); + if (existing && existing.length) { + existing.data('label', label); + } else { + cy.add({ data: { id: parentId, label: label, isCommunityParent: true } }); + } + }); + + cy.nodes() + .filter(function (n) { + return n.data('isCommunityParent') && !presentParentIds[n.id()]; + }) + .remove(); + + realNodes.forEach(function (n) { + var community = n.data('community') || 0; + if (communityCounts[community] <= 1) { + if (n.parent().nonempty()) n.move({ parent: null }); + return; + } + var wantedParentId = communityParentId(community); + if (n.parent().id() !== wantedParentId) { + n.move({ parent: wantedParentId }); + } + }); +} + +function refreshDensityControlVisibility() { + if (!densityControlEl) return; + var hasScore = false; + cy.edges('[type="semantic"]').forEach(function (e) { + if (typeof e.data('score') === 'number') hasScore = true; + }); + densityControlEl.style.display = hasScore ? '' : 'none'; +} + +var UNCATEGORIZED_FILTER_VALUE = '__uncategorized__'; + +function refreshCategoryFilterOptions() { + if (!categoryFilterEl) return; + + var categories = {}; + var hasUncategorized = false; + cy.nodes().forEach(function (n) { + if (n.data('isCommunityParent')) return; + var c = n.data('category'); + if (c) { + categories[c] = true; + } else { + hasUncategorized = true; + } + }); + var names = Object.keys(categories).sort(); + + var previousValue = categoryFilterEl.value; + categoryFilterEl.innerHTML = ''; + var allOpt = document.createElement('option'); + allOpt.value = ''; + allOpt.textContent = 'All categories'; + categoryFilterEl.appendChild(allOpt); + names.forEach(function (name) { + var opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + categoryFilterEl.appendChild(opt); + }); + if (hasUncategorized) { + var uncatOpt = document.createElement('option'); + uncatOpt.value = UNCATEGORIZED_FILTER_VALUE; + uncatOpt.textContent = 'Uncategorized'; + categoryFilterEl.appendChild(uncatOpt); + } + + var stillValid = + previousValue === '' || + names.indexOf(previousValue) !== -1 || + (previousValue === UNCATEGORIZED_FILTER_VALUE && hasUncategorized); + categoryFilterEl.value = stillValid ? previousValue : ''; + categoryFilterEl.style.display = names.length === 0 && !hasUncategorized ? 'none' : ''; + + applyNodeFilters(); +} + +function applyNodeFilters() { + if (!cy) return; + var query = currentSearchQuery; + var category = categoryFilterEl ? categoryFilterEl.value : ''; + + cy.nodes().stop(true, false); + cy.nodes().removeStyle('border-width border-color'); + + if (!query && !category) { + cy.nodes().style('opacity', 1); + return; + } + + var matches = cy.collection(); + cy.nodes().forEach(function (n) { + if (n.data('isCommunityParent')) return; + var matchesSearch = !query || (n.data('label') || '').toLowerCase().indexOf(query) !== -1; + var c = n.data('category'); + var matchesCategory = + !category || (category === UNCATEGORIZED_FILTER_VALUE ? !c : c === category); + var isMatch = matchesSearch && matchesCategory; + n.style('opacity', isMatch ? 1 : 0.15); + if (isMatch) matches = matches.union(n); + }); + + if (query && matches.length > 0) { + matches.style('border-width', 3); + matches.style('border-color', '#ffa500'); + if (searchBorderTimer) clearTimeout(searchBorderTimer); + searchBorderTimer = setTimeout(function () { + matches.removeStyle('border-width border-color'); + }, 800); + cy.animate({ fit: { eles: matches, padding: 50 }, duration: 400 }); + } +} function showStatus(text) { if (statusEl) { @@ -139,13 +433,18 @@ function isDarkTheme() { return lum < 128; } +function applyThemeToChrome(dark) { + document.documentElement.classList.toggle('theme-dark', dark); + if (densitySliderEl) densitySliderEl.style.accentColor = dark ? '#9b6bd5' : '#5b9bd5'; +} + /** Build the Cytoscape stylesheet with theme-aware colours. Tag edges are green dotted, semantic are purple dashed, explicit are dark grey solid. */ function buildStylesheet() { var dark = isDarkTheme(); return [ { - selector: 'node', + selector: 'node[!isCommunityParent]', style: { 'background-color': communityColor, label: 'data(label)', @@ -154,8 +453,11 @@ function buildStylesheet() { 'text-valign': 'top', 'text-halign': 'center', 'text-margin-y': -4, - 'text-wrap': 'ellipsis', - 'text-max-width': '100px', + 'text-wrap': 'wrap', + 'text-max-width': '90px', + 'text-outline-width': 2, + 'text-outline-color': dark ? '#1e1e1e' : '#ffffff', + 'min-zoomed-font-size': 7, width: nodeDiameter, height: nodeDiameter, 'border-width': 1.5, @@ -163,11 +465,34 @@ function buildStylesheet() { }, }, { - selector: 'node:selected', + selector: 'node[!isCommunityParent]:selected', style: { - 'background-color': '#ffa500', + 'outline-style': 'dashed', + 'outline-color': communityColor, + 'outline-width': 3, + 'outline-opacity': 1, + }, + }, + { + selector: 'node[?isCommunityParent]', + style: { + 'background-color': dark ? '#ffffff' : '#000000', + 'background-opacity': dark ? 0.05 : 0.04, 'border-width': 1.5, - 'border-color': '#cc8400', + 'border-style': 'dashed', + 'border-color': dark ? '#ffffff' : '#000000', + 'border-opacity': dark ? 0.28 : 0.2, + shape: 'round-rectangle', + label: 'data(label)', + color: dark ? '#ccc' : '#555', + 'font-size': '10px', + 'font-weight': 600, + 'text-valign': 'top', + 'text-halign': 'center', + 'text-margin-y': -6, + 'text-outline-width': 2, + 'text-outline-color': dark ? '#1e1e1e' : '#ffffff', + padding: '18px', }, }, { @@ -181,9 +506,9 @@ function buildStylesheet() { }, 'line-color': function (ele) { var t = ele.data('type'); - if (t === 'tag') return dark ? '#3d8b5e' : '#4caf7d'; + if (t === 'tag') return '#4caf7d'; if (t === 'semantic') return dark ? '#a48ad9' : '#9b6bd5'; - return dark ? '#999' : '#555'; + return dark ? '#bbbbbb' : '#555'; }, 'curve-style': 'bezier', 'line-style': function (ele) { @@ -198,11 +523,35 @@ function buildStylesheet() { 'target-arrow-color': function (ele) { var t = ele.data('type'); if (t === 'semantic') return dark ? '#a48ad9' : '#9b6bd5'; - return dark ? '#999' : '#555'; + return dark ? '#bbbbbb' : '#555'; }, 'arrow-scale': 0.8, }, }, + { + selector: 'edge.edge-hover', + style: { + 'overlay-color': function (ele) { + var t = ele.data('type'); + if (t === 'tag') return '#4caf7d'; + if (t === 'semantic') return dark ? '#a48ad9' : '#9b6bd5'; + return dark ? '#bbbbbb' : '#555'; + }, + 'overlay-opacity': 0.6, + 'overlay-padding': 2, + 'z-index': 10, + }, + }, + { + selector: 'node.edge-hover-node, node.node-hover', + style: { + 'outline-style': 'solid', + 'outline-color': communityColor, + 'outline-width': 3, + 'outline-opacity': 1, + 'z-index': 10, + }, + }, ]; } @@ -233,6 +582,7 @@ function registerEdgeTooltip(selector, className, resolveText) { var value = resolveText(evt.target); if (!value || !tooltipEl) return; tooltipEl.className = 'graph-tooltip'; + tooltipEl.style.borderLeft = ''; tooltipEl.innerHTML = '
' + escapeHtml(value) + '
'; tooltipEl.classList.add(className); tooltipEl.classList.add('is-visible'); @@ -250,6 +600,31 @@ function registerEdgeTooltip(selector, className, resolveText) { }); } +function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } catch (e) { + console.error('Copy failed:', e); + } + document.body.removeChild(ta); +} + +function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(function () { + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } +} + function recomputeStats() { nodeStats = {}; var explicitCount = 0; @@ -282,12 +657,22 @@ function recomputeStats() { }); var totalTags = Object.keys(tagNames).length; - updateStats(cy.nodes().length, explicitCount, semanticCount, totalTags); + var noteNodeCount = cy.nodes().filter(function (n) { + return !n.data('isCommunityParent'); + }).length; + updateStats(noteNodeCount, explicitCount, semanticCount, totalTags); + refreshCategoryFilterOptions(); + refreshDensityControlVisibility(); + if (groupByCommunityEnabled) { + applyCommunityGrouping(); + } } /** Mirrors LouvainDetector.MIN_NOTES_FOR_LOUVAIN — below this, the graph has too few notes for meaningful structure. */ var NEAR_EMPTY_NOTE_THRESHOLD = 3; +var lastAllNotesVeryShort = false; + function noteCountLabel(count) { return count + (count === 1 ? ' note' : ' notes'); } @@ -297,9 +682,15 @@ function refreshEmptyStateStatus() { if (noteCount === 0) { showStatus('No graph data received'); } else if (noteCount < NEAR_EMPTY_NOTE_THRESHOLD) { - showStatus('Only ' + noteCountLabel(noteCount) + ' found. Add more notes to see a meaningful graph.'); + showStatus( + 'Only ' + + noteCountLabel(noteCount) + + ' found. Add more notes to see a meaningful graph.' + ); } else if (cy.edges().length === 0) { showStatus(noteCountLabel(noteCount) + ', 0 connections'); + } else if (lastAllNotesVeryShort) { + showStatus('Notes are very short - add more content for a more meaningful graph.'); } else { hideStatus(); } @@ -311,6 +702,10 @@ function refreshEmptyStateStatus() { */ function renderGraph(message) { cy.elements().remove(); + focusActive = false; + focusIsAutoFollowing = false; + if (focusBtnEl) focusBtnEl.classList.remove('legend-panel__action-btn--active'); + lastAllNotesVeryShort = !!(message && message.allNotesVeryShort); if (!message || !message.nodes || !message.nodes.length) { showStatus('No graph data received'); @@ -324,7 +719,28 @@ function renderGraph(message) { cy.add(message.edges || []); recomputeStats(); - cy.layout(FCOSE_OPTIONS).run(); + + var focused = false; + if (pendingFocusNoteId && focusNodeBeforeLayout(pendingFocusNoteId)) { + pendingFocusNoteId = null; + focused = true; + } + + var layoutOptions = buildLayoutOptions(false); + if (focused) { + layoutOptions.animate = false; + layoutOptions.fit = false; + } + var layout = cy.elements().layout(layoutOptions); + if (focused) { + layout.one('layoutstop', function () { + applyVisibility(); + fitViewportToFocus(); + }); + } else { + applyVisibility(); + } + layout.run(); refreshEmptyStateStatus(); } @@ -381,18 +797,19 @@ function applyGraphPatch(patch) { recomputeStats(); var fixedNodeConstraint = []; - cy.nodes().forEach(function (n) { - if (!movableIds[n.id()]) { - fixedNodeConstraint.push({ nodeId: n.id(), position: n.position() }); - } - }); - cy.layout( - Object.assign({}, FCOSE_OPTIONS, INCREMENTAL_FCOSE_OVERRIDES, { - fixedNodeConstraint: fixedNodeConstraint, - }) - ).run(); + if (currentLayoutName === LAYOUT_FCOSE) { + cy.nodes().forEach(function (n) { + if (n.data('isCommunityParent')) return; + if (!movableIds[n.id()]) { + fixedNodeConstraint.push({ nodeId: n.id(), position: n.position() }); + } + }); + } + cy.elements(':visible').layout(buildLayoutOptions(true, fixedNodeConstraint)).run(); + applyVisibility(); refreshEmptyStateStatus(); + resolvePendingFocus(); } function definedKeys(obj) { @@ -405,8 +822,12 @@ function dataEqual(existingEle, data) { if (!existingEle || !existingEle.length) return false; var existing = existingEle.data(); var keys = {}; - definedKeys(existing).forEach(function (key) { keys[key] = true; }); - definedKeys(data).forEach(function (key) { keys[key] = true; }); + definedKeys(existing).forEach(function (key) { + keys[key] = true; + }); + definedKeys(data).forEach(function (key) { + keys[key] = true; + }); return Object.keys(keys).every(function (key) { return existing[key] === data[key]; }); @@ -431,6 +852,7 @@ function computeClientPatch(graphData) { var removedNodeIds = []; cy.nodes().forEach(function (n) { + if (n.data('isCommunityParent')) return; if (!newNodeIds[n.id()]) removedNodeIds.push(n.id()); }); var removedEdgeIds = []; @@ -446,10 +868,34 @@ function computeClientPatch(graphData) { }; } +var WHOLESALE_CHANGE_RATIO = 0.5; + +function isWholesaleChange(patch) { + var currentCount = cy.nodes().filter(function (n) { + return !n.data('isCommunityParent'); + }).length; + var removedCount = (patch.removedNodeIds || []).length; + + var newCount = 0; + (patch.upsertedNodes || []).forEach(function (item) { + var data = item.data || item; + var existing = cy.getElementById(data.id); + if (!existing || !existing.length) newCount++; + }); + + if (currentCount > 0 && removedCount >= currentCount * WHOLESALE_CHANGE_RATIO) return true; + var resultingCount = currentCount - removedCount + newCount; + return newCount >= resultingCount * WHOLESALE_CHANGE_RATIO; +} + function handleGraphUpdate(type, message) { var version = message.version || 0; if (hasRenderedOnce && version <= lastSeenVersion) return; + if (message && message.focusNoteId) { + pendingFocusNoteId = message.focusNoteId; + } + if (type === 'graph-patch') { if (!hasRenderedOnce || version !== lastSeenVersion + 1) return; applyGraphPatch(message); @@ -457,7 +903,12 @@ function handleGraphUpdate(type, message) { renderGraph(message); hasRenderedOnce = true; } else { - applyGraphPatch(computeClientPatch(message)); + var patch = computeClientPatch(message); + if (isWholesaleChange(patch)) { + renderGraph(message); + } else { + applyGraphPatch(patch); + } } lastSeenVersion = version; @@ -478,7 +929,8 @@ function updateStats(notes, explicit, semantic, tags) { function createExportMenu(btn) { var menu = document.createElement('div'); menu.className = 'export-menu'; - menu.innerHTML = ''; + menu.innerHTML = + ''; document.body.appendChild(menu); btn.addEventListener('click', function (e) { @@ -488,7 +940,7 @@ function createExportMenu(btn) { if (!open) { var rect = btn.getBoundingClientRect(); menu.style.left = rect.left + 'px'; - menu.style.top = (rect.bottom + 4) + 'px'; + menu.style.top = rect.bottom + 4 + 'px'; } }); @@ -498,7 +950,9 @@ function createExportMenu(btn) { if (!item) return; var format = item.getAttribute('data-format'); menu.style.display = 'none'; - var bg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim() || '#1e1e1e'; + var bg = + getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim() || + '#1e1e1e'; if (format === 'png') { downloadFile(cy.png({ full: true, bg: bg }), 'note-graph.png'); } else if (format === 'svg') { @@ -506,7 +960,9 @@ function createExportMenu(btn) { var svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); downloadFile(URL.createObjectURL(svgBlob), 'note-graph.svg'); } else if (format === 'json') { - var blob = new Blob([JSON.stringify(cy.json().elements, null, 2)], { type: 'application/json' }); + var blob = new Blob([JSON.stringify(cy.json().elements, null, 2)], { + type: 'application/json', + }); downloadFile(URL.createObjectURL(blob), 'note-graph.json'); } }); @@ -539,7 +995,10 @@ function requestData() { handleGraphUpdate('graph-data', response); } if (response && response.progress) { - var label = response.progress.stage === 'enrichment-progress' ? 'Enriching notes' : 'Building graph'; + var label = + response.progress.stage === 'enrichment-progress' + ? 'Enriching notes' + : 'Building graph'; showPipelineProgress(label, response.progress.current, response.progress.total); } else { hidePipelineProgress(); @@ -549,7 +1008,10 @@ function requestData() { console.error('Note Graph poll failed:', e); }) .then(function () { - setTimeout(requestData, hasRenderedOnce ? POLL_INTERVAL_LIVE_MS : POLL_INTERVAL_WAITING_MS); + setTimeout( + requestData, + hasRenderedOnce ? POLL_INTERVAL_LIVE_MS : POLL_INTERVAL_WAITING_MS + ); }); } @@ -570,13 +1032,15 @@ function init() { return; } + applyThemeToChrome(isDarkTheme()); + var header = document.querySelector('.panel-header'); var legend = document.getElementById('legend-panel'); var statsBar = document.getElementById('stats-bar'); var headerH = header ? header.offsetHeight : 0; var legendH = legend ? legend.offsetHeight : 0; var statsH = statsBar ? statsBar.offsetHeight : 0; - container.style.height = (window.innerHeight - headerH - legendH - statsH) + 'px'; + container.style.height = window.innerHeight - headerH - legendH - statsH + 'px'; container.style.minHeight = '350px'; container.style.width = '100%'; @@ -616,8 +1080,8 @@ function init() { wheelSensitivity: 0.3, }); - cy.on('tap', 'node', onNodeTap); - cy.on('dblclick', 'node', onNodeDblClick); + cy.on('tap', 'node[!isCommunityParent]', onNodeTap); + cy.on('dblclick', 'node[!isCommunityParent]', onNodeDblClick); var zoomInBtn = document.getElementById('graph-zoom-in'); var zoomOutBtn = document.getElementById('graph-zoom-out'); @@ -625,7 +1089,10 @@ function init() { zoomInBtn.addEventListener('click', function () { cy.zoom({ level: cy.zoom() * 1.3, - renderedPosition: { x: container.clientWidth / 2, y: container.clientHeight / 2 }, + renderedPosition: { + x: container.clientWidth / 2, + y: container.clientHeight / 2, + }, }); }); } @@ -633,7 +1100,10 @@ function init() { zoomOutBtn.addEventListener('click', function () { cy.zoom({ level: cy.zoom() * 0.7, - renderedPosition: { x: container.clientWidth / 2, y: container.clientHeight / 2 }, + renderedPosition: { + x: container.clientWidth / 2, + y: container.clientHeight / 2, + }, }); }); } @@ -641,41 +1111,75 @@ function init() { registerEdgeTooltip('edge[type="tag"]', 'graph-tooltip--tag', function (edge) { return edge.data('tagName'); }); - registerEdgeTooltip('edge[type="semantic"]', 'graph-tooltip--relationship', function (edge) { - return edge.data('relationshipLabel'); + registerEdgeTooltip( + 'edge[type="semantic"]', + 'graph-tooltip--relationship', + function (edge) { + return edge.data('relationshipLabel'); + } + ); + + cy.on('mouseover', 'edge', function (evt) { + var edge = evt.target; + edge.addClass('edge-hover'); + edge.source().addClass('edge-hover-node'); + edge.target().addClass('edge-hover-node'); + }); + + cy.on('mouseout', 'edge', function (evt) { + var edge = evt.target; + edge.removeClass('edge-hover'); + edge.source().removeClass('edge-hover-node'); + edge.target().removeClass('edge-hover-node'); }); - cy.on('mouseover', 'node', function (evt) { + cy.on('mouseover', 'node[!isCommunityParent]', function (evt) { var node = evt.target; + node.addClass('node-hover'); var label = node.data('label') || '(untitled)'; var id = node.id(); var degree = node.data('degree') || 0; var community = node.data('community') || 0; var category = node.data('category'); var stats = nodeStats && nodeStats[id] ? nodeStats[id] : { linkCount: 0, tagCount: 0 }; - var badge = category ? '
' + escapeHtml(category) + '
' : ''; + var badge = category + ? '
' + escapeHtml(category) + '
' + : ''; tooltipEl.className = 'graph-tooltip'; - tooltipEl.innerHTML = '
' + escapeHtml(label) + '
' - + badge - + '
' - + 'degree ' + degree + '' - + '' - + 'links ' + stats.linkCount + '' - + '
' - + '
' - + 'tags ' + stats.tagCount + '' - + '' - + 'community ' + community + '' - + '
'; + tooltipEl.style.borderLeft = '3px solid ' + communityColor(node); + tooltipEl.innerHTML = + '
' + + escapeHtml(label) + + '
' + + badge + + '
' + + 'degree ' + + degree + + '' + + '' + + 'links ' + + stats.linkCount + + '' + + '
' + + '
' + + 'tags ' + + stats.tagCount + + '' + + '' + + 'community ' + + community + + '' + + '
'; tooltipEl.classList.add('is-visible'); positionTooltip(evt.originalEvent.clientX, evt.originalEvent.clientY, 14); }); - cy.on('mousemove', 'node', function (evt) { + cy.on('mousemove', 'node[!isCommunityParent]', function (evt) { positionTooltip(evt.originalEvent.clientX, evt.originalEvent.clientY, 14); }); - cy.on('mouseout', 'node', function () { + cy.on('mouseout', 'node[!isCommunityParent]', function (evt) { + evt.target.removeClass('node-hover'); if (!tooltipEl) return; tooltipEl.classList.remove('is-visible'); }); @@ -690,23 +1194,34 @@ function init() { var h = header ? header.offsetHeight : 0; var lh = legend ? legend.offsetHeight : 0; var sh = statsBar ? statsBar.offsetHeight : 0; - container.style.height = (window.innerHeight - h - lh - sh) + 'px'; + container.style.height = window.innerHeight - h - lh - sh + 'px'; cy.resize(); cy.fit(undefined, 30); }); observer.observe(container); observer.observe(document.body); - var lastBg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim(); + var lastBg = getComputedStyle(document.body) + .getPropertyValue('--joplin-background-color') + .trim(); var themeObserver = new MutationObserver(function () { - var currentBg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim(); + var currentBg = getComputedStyle(document.body) + .getPropertyValue('--joplin-background-color') + .trim(); if (currentBg !== lastBg) { lastBg = currentBg; cy.style().fromJson(buildStylesheet()).update(); + applyThemeToChrome(isDarkTheme()); } }); - themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class'] }); - themeObserver.observe(document.body, { attributes: true, attributeFilter: ['style', 'class'] }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style', 'class'], + }); + themeObserver.observe(document.body, { + attributes: true, + attributeFilter: ['style', 'class'], + }); var fitBtn = document.getElementById('graph-fit'); if (fitBtn) { @@ -725,63 +1240,203 @@ function init() { edgeToggles[t].addEventListener('click', function () { var edgeType = this.getAttribute('data-edge'); var off = this.classList.toggle('legend-panel__pill--off'); - if (off) { - cy.edges('[type="' + edgeType + '"]').hide(); - } else { - cy.edges('[type="' + edgeType + '"]').show(); - } + edgeTypeOff[edgeType] = off; + applyVisibility(); + }); + } + + densitySliderEl = document.getElementById('graph-density-slider'); + densityValueEl = document.getElementById('graph-density-value'); + densityControlEl = document.getElementById('graph-density-control'); + if (densitySliderEl) { + densitySliderEl.addEventListener('input', function () { + currentMinConfidence = Number(this.value) / 100; + if (densityValueEl) densityValueEl.textContent = this.value + '%'; + applyVisibility(); }); + applyThemeToChrome(isDarkTheme()); } - var searchTimer = null; + var SEARCH_DEBOUNCE_MS = 400; + var searchDebounceTimer = null; + var searchInput = document.getElementById('graph-search'); if (searchInput) { searchInput.addEventListener('input', function () { - var q = this.value.trim().toLowerCase(); - if (searchTimer) clearTimeout(searchTimer); - cy.nodes().style('opacity', 1); - cy.nodes().removeStyle('border-width border-color'); - cy.nodes().stop(true, false); - if (!q) return; - cy.nodes().style('opacity', 0.15); - var matches = cy.nodes().filter(function (n) { - return (n.data('label') || '').toLowerCase().indexOf(q) !== -1; + var value = this.value; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(function () { + currentSearchQuery = value.trim().toLowerCase(); + applyNodeFilters(); + }, SEARCH_DEBOUNCE_MS); + }); + } + + categoryFilterEl = document.getElementById('graph-category-filter'); + if (categoryFilterEl) { + categoryFilterEl.addEventListener('change', applyNodeFilters); + } + + var groupToggleEl = document.getElementById('graph-group-toggle'); + + function setGroupingEnabled(enabled) { + groupByCommunityEnabled = enabled; + if (groupToggleEl) { + groupToggleEl.classList.toggle('legend-panel__action-btn--active', enabled); + groupToggleEl.setAttribute('aria-pressed', String(enabled)); + } + applyCommunityGrouping(); + applyVisibility(); + } + + function updateGroupToggleAvailability() { + if (!groupToggleEl) return; + var hierarchical = currentLayoutName === LAYOUT_HIERARCHICAL; + if (hierarchical && groupByCommunityEnabled) { + setGroupingEnabled(false); + } + groupToggleEl.disabled = hierarchical; + groupToggleEl.title = hierarchical ? 'Not available under hierarchical layout' : ''; + } + + var LAYOUT_DISPLAY_NAMES = { + fcose: 'fCoSE', + hierarchical: 'Hierarchical', + }; + var layoutBtnEl = document.getElementById('graph-layout-btn'); + var layoutMenuEl = document.getElementById('graph-layout-menu'); + var layoutLabelEl = document.getElementById('graph-layout-label'); + + function closeLayoutMenu() { + if (!layoutMenuEl || !layoutBtnEl) return; + layoutMenuEl.hidden = true; + layoutBtnEl.setAttribute('aria-expanded', 'false'); + } + + function setLayout(layoutName) { + currentLayoutName = layoutName; + if (layoutLabelEl) + layoutLabelEl.textContent = LAYOUT_DISPLAY_NAMES[layoutName] || layoutName; + if (layoutMenuEl) { + layoutMenuEl.querySelectorAll('[data-layout]').forEach(function (item) { + item.classList.toggle( + 'graph-pill-menu-item--active', + item.getAttribute('data-layout') === layoutName + ); + }); + } + updateGroupToggleAvailability(); + if (cy.nodes().length > 0) { + cy.elements(':visible').layout(buildLayoutOptions(false)).run(); + } + } + + if (layoutBtnEl && layoutMenuEl) { + layoutBtnEl.addEventListener('click', function (e) { + e.stopPropagation(); + var isHidden = layoutMenuEl.hidden; + layoutMenuEl.hidden = !isHidden; + layoutBtnEl.setAttribute('aria-expanded', String(isHidden)); + }); + + document.addEventListener('click', function (e) { + if ( + !layoutMenuEl.hidden && + !layoutMenuEl.contains(e.target) && + e.target !== layoutBtnEl + ) { + closeLayoutMenu(); + } + }); + + layoutMenuEl.querySelectorAll('[data-layout]').forEach(function (item) { + item.addEventListener('click', function () { + setLayout(item.getAttribute('data-layout')); + closeLayoutMenu(); }); - matches.style('opacity', 1); - if (matches.length > 0) { - matches.style('border-width', 3); - matches.style('border-color', '#ffa500'); - searchTimer = setTimeout(function () { - matches.removeStyle('border-width border-color'); - }, 800); - cy.animate({ fit: { eles: matches, padding: 50 }, duration: 400 }); + }); + + setLayout(currentLayoutName); + } + + if (groupToggleEl) { + groupToggleEl.addEventListener('click', function () { + if (currentLayoutName === LAYOUT_HIERARCHICAL) return; + setGroupingEnabled(!groupByCommunityEnabled); + if (cy.nodes().length > 0) { + cy.elements(':visible').layout(buildLayoutOptions(false)).run(); } }); } - var focusBtn = document.getElementById('graph-focus'); - var focusActive = false; - if (focusBtn) { - focusBtn.addEventListener('click', function () { + focusBtnEl = document.getElementById('graph-focus'); + if (focusBtnEl) { + focusBtnEl.addEventListener('click', function () { if (focusActive) { - focusActive = false; - this.classList.remove('legend-panel__action-btn--active'); - cy.elements().show(); - cy.fit(undefined, 30); - return; + disengageFocusMode(); + } else { + focusIsAutoFollowing = false; + engageFocusMode(); } - var sel = cy.nodes(':selected'); - if (sel.length === 0) return; - focusActive = true; - this.classList.add('legend-panel__action-btn--active'); - cy.elements().hide(); - var hood = sel.closedNeighborhood().add(sel.neighborhood().nodes().neighborhood()); - hood.show(); - sel.show(); - cy.animate({ fit: { eles: hood, padding: 50 }, duration: 400 }); }); } + var contextMenuEl = document.createElement('div'); + contextMenuEl.className = 'graph-context-menu'; + contextMenuEl.hidden = true; + contextMenuEl.innerHTML = + '' + + ''; + document.body.appendChild(contextMenuEl); + var contextMenuNodeId = null; + + function openContextMenu(node, x, y) { + contextMenuNodeId = node.id(); + contextMenuEl.style.left = x + 'px'; + contextMenuEl.style.top = y + 'px'; + contextMenuEl.hidden = false; + } + + function closeContextMenu() { + contextMenuEl.hidden = true; + contextMenuNodeId = null; + } + + container.addEventListener('contextmenu', function (e) { + e.preventDefault(); + }); + + cy.on('cxttap', 'node[!isCommunityParent]', function (evt) { + evt.originalEvent.preventDefault(); + openContextMenu(evt.target, evt.originalEvent.clientX, evt.originalEvent.clientY); + }); + + document.addEventListener('click', function (e) { + if (!contextMenuEl.hidden && !contextMenuEl.contains(e.target)) { + closeContextMenu(); + } + }); + + contextMenuEl.addEventListener('click', function (e) { + var item = e.target.closest('.graph-context-menu__item'); + if (!item) return; + var node = cy.getElementById(contextMenuNodeId); + closeContextMenu(); + if (!node || node.empty()) return; + if (item.getAttribute('data-action') === 'focus') { + focusIsAutoFollowing = false; + cy.elements().unselect(); + node.select(); + if (focusActive) { + focusActive = false; + if (focusBtnEl) focusBtnEl.classList.remove('legend-panel__action-btn--active'); + } + engageFocusMode(); + } else if (item.getAttribute('data-action') === 'copy-id') { + copyText(node.id()); + } + }); + showStatus('Graph engine ready: waiting for data...'); pollForData(); @@ -806,6 +1461,16 @@ function init() { var label = message.stage === 'enrichment-progress' ? 'Enriching notes' : 'Building graph'; showPipelineProgress(label, message.current, message.total); } + if (message && message.type === 'focus-note') { + if (message.noteId) { + if (!engageFocusOnNote(message.noteId)) { + pendingFocusNoteId = message.noteId; + } + } else { + pendingFocusNoteId = null; + disengageFocusMode(); + } + } }); } } catch (e) { diff --git a/src/ui/setup.js b/src/ui/setup.js index 50d9743..9aebb3a 100644 --- a/src/ui/setup.js +++ b/src/ui/setup.js @@ -7,10 +7,161 @@ }); }; + const bindScopePicker = () => { + const btn = document.getElementById('graph-scope-btn'); + const menu = document.getElementById('graph-scope-menu'); + const label = document.getElementById('graph-scope-label'); + const selectToggleBtn = document.getElementById('graph-scope-select-toggle'); + const selectPanel = document.getElementById('graph-scope-select-panel'); + const notebookListEl = document.getElementById('graph-scope-notebook-list'); + const applyBtn = document.getElementById('graph-scope-apply'); + if (!btn || !menu || !label || typeof webviewApi === 'undefined') return; + + let folders = null; + let selectedIds = new Set(); + + const closeSelectPanel = () => { + if (!selectPanel || !selectToggleBtn) return; + selectPanel.hidden = true; + selectToggleBtn.setAttribute('aria-expanded', 'false'); + }; + + const closeMenu = () => { + menu.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + closeSelectPanel(); + }; + + const openMenu = () => { + menu.hidden = false; + btn.setAttribute('aria-expanded', 'true'); + }; + + const openSelectPanel = () => { + if (!selectPanel || !selectToggleBtn) return; + selectPanel.hidden = false; + selectToggleBtn.setAttribute('aria-expanded', 'true'); + if (!folders) loadFolders(); + }; + + const updateLabel = (mode, ids) => { + if (mode === 'current') { + label.textContent = 'Current notebook'; + } else if (mode === 'selected') { + label.textContent = ids.length + ? ids.length + ' selected' + : 'Select notebooks'; + } else { + label.textContent = 'All notebooks'; + } + }; + + const renderNotebookList = () => { + notebookListEl.innerHTML = ''; + (folders || []).forEach((folder) => { + const row = document.createElement('label'); + row.className = 'panel-header__scope-checkbox-row'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = folder.id; + checkbox.checked = selectedIds.has(folder.id); + checkbox.addEventListener('change', function () { + if (this.checked) { + selectedIds.add(folder.id); + } else { + selectedIds.delete(folder.id); + } + }); + const span = document.createElement('span'); + span.textContent = folder.title; + row.appendChild(checkbox); + row.appendChild(span); + notebookListEl.appendChild(row); + }); + }; + + function loadFolders() { + webviewApi + .postMessage({ type: 'request-folders' }) + .then((response) => { + folders = (response && response.folders) || []; + renderNotebookList(); + }) + .catch((e) => { + console.error('Note Graph: failed to load notebooks:', e); + }); + } + + const applyScope = (mode) => { + const ids = mode === 'selected' ? Array.from(selectedIds) : []; + webviewApi + .postMessage({ type: 'set-scope', mode: mode, selectedIds: ids }) + .catch((e) => { + console.error('Note Graph: failed to set scope:', e); + }); + updateLabel(mode, ids); + closeMenu(); + }; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (menu.hidden) { + openMenu(); + } else { + closeMenu(); + } + }); + + document.addEventListener('click', (e) => { + if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) { + closeMenu(); + } + }); + + menu.querySelectorAll('[data-scope-mode]').forEach((item) => { + item.addEventListener('click', () => { + applyScope(item.getAttribute('data-scope-mode')); + }); + }); + + if (selectToggleBtn && selectPanel) { + selectToggleBtn.addEventListener('click', () => { + if (selectPanel.hidden) { + openSelectPanel(); + } else { + closeSelectPanel(); + } + }); + } + + if (applyBtn) { + applyBtn.addEventListener('click', () => { + applyScope('selected'); + }); + } + + webviewApi + .postMessage({ type: 'get-scope-state' }) + .then((response) => { + if (!response) return; + const mode = response.mode || 'all'; + selectedIds = new Set(response.selectedNotebookIds || []); + updateLabel(mode, Array.from(selectedIds)); + }) + .catch((e) => { + console.error('Note Graph: failed to load scope state:', e); + }); + }; + + const init = () => { + bindClose(); + bindScopePicker(); + }; + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', bindClose); + document.addEventListener('DOMContentLoaded', init); return; } - bindClose(); + init(); })(); diff --git a/src/ui/styles/panel.css b/src/ui/styles/panel.css index 35b2ba4..0cfd396 100644 --- a/src/ui/styles/panel.css +++ b/src/ui/styles/panel.css @@ -1,3 +1,38 @@ +:root { + color-scheme: light; + --ng-bg: var(--joplin-background-color, #ffffff); + --ng-color: var(--joplin-color, #333333); + --ng-color-faded: var(--joplin-color-faded, #888888); + --ng-accent: #5b9bd5; + --ng-accent-hover: #4c8bc4; + --ng-accent-contrast: #ffffff; + --ng-hairline: rgba(128, 128, 128, 0.16); + --ng-divider: rgba(128, 128, 128, 0.25); + --ng-hover: rgba(128, 128, 128, 0.1); + --ng-active: rgba(128, 128, 128, 0.16); + --ng-subtle: rgba(128, 128, 128, 0.06); + --ng-input: rgba(128, 128, 128, 0.05); + --ng-tag: #4caf7d; + --ng-semantic: #9b6bd5; + --ng-explicit: #777777; +} + +html.theme-dark { + color-scheme: dark; + --ng-accent: #9b6bd5; + --ng-accent-hover: #b28fe0; + --ng-accent-contrast: #ffffff; + --ng-hairline: rgba(255, 255, 255, 0.12); + --ng-divider: rgba(255, 255, 255, 0.18); + --ng-hover: rgba(255, 255, 255, 0.08); + --ng-active: rgba(255, 255, 255, 0.14); + --ng-subtle: rgba(255, 255, 255, 0.05); + --ng-input: rgba(255, 255, 255, 0.07); + --ng-tag: #4caf7d; + --ng-semantic: #a48ad9; + --ng-explicit: #bbbbbb; +} + html, body { margin: 0; @@ -6,8 +41,8 @@ body { } body { - background-color: var(--joplin-background-color); - color: var(--joplin-color); + background-color: var(--ng-bg); + color: var(--ng-color); } .panel-root { @@ -18,6 +53,27 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +.panel-root button, +.panel-root select, +.panel-root input { + outline: none; +} + +.panel-root button:focus, +.panel-root select:focus, +.panel-root input:focus { + outline: none; + box-shadow: none; +} + +.panel-root button:focus-visible, +.panel-root select:focus-visible, +.panel-root input:focus-visible { + outline: 2px solid var(--ng-accent); + outline-offset: 1px; + box-shadow: none; +} + /* Header */ .panel-header { @@ -28,8 +84,8 @@ body { width: 100%; box-sizing: border-box; flex-shrink: 0; - background: var(--joplin-background-color); - border-bottom: 1px solid rgba(128, 128, 128, 0.12); + background: var(--ng-bg); + border-bottom: 1px solid var(--ng-hairline); } .panel-header__brand { @@ -48,7 +104,7 @@ body { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; - color: var(--joplin-color); + color: var(--ng-color); } .panel-header__actions { @@ -62,7 +118,7 @@ body { background-color: transparent; border: none; border-radius: 6px; - color: var(--joplin-color); + color: var(--ng-color); cursor: pointer; padding: 4px; opacity: 0.65; @@ -74,7 +130,7 @@ body { .panel-header__icon-btn:hover:not([disabled]) { opacity: 1; - background: rgba(128, 128, 128, 0.12); + background: var(--ng-hover); } .panel-header__icon-btn[disabled] { @@ -88,18 +144,179 @@ body { display: block; } +.panel-header__scope { + position: relative; +} + +.panel-header__scope-btn { + display: flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--ng-accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--ng-accent) 28%, transparent); + border-radius: 8px; + color: var(--ng-accent); + cursor: pointer; + padding: 5px 12px; + font-size: 11.5px; + font-weight: 600; + font-family: inherit; + transition: background 0.15s, border-color 0.15s; +} + +.panel-header__scope-btn:hover { + background: color-mix(in srgb, var(--ng-accent) 20%, transparent); + border-color: color-mix(in srgb, var(--ng-accent) 45%, transparent); +} + +.panel-header__scope-btn[aria-expanded='true'] { + background: color-mix(in srgb, var(--ng-accent) 22%, transparent); +} + +.panel-header__scope-btn svg { + flex-shrink: 0; +} + +.panel-header__divider { + width: 1px; + height: 18px; + background: var(--ng-divider); + margin: 0 6px; + flex-shrink: 0; +} + +.panel-header__scope-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 230px; + max-width: 290px; + background: var(--ng-bg); + border: 1px solid var(--ng-hairline); + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 16px 32px -12px rgba(0, 0, 0, 0.32); + padding: 6px; + z-index: 20; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +.panel-header__scope-menu[hidden] { + display: none; +} + +.panel-header__scope-menu-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-radius: 8px; + color: var(--ng-color); + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + transition: background 0.12s, color 0.12s; +} + +.panel-header__scope-menu-item:hover { + background: color-mix(in srgb, var(--ng-accent) 12%, transparent); + color: var(--ng-accent); +} + +.graph-menu-divider { + height: 1px; + background: var(--ng-hairline); + margin: 3px 2px; +} + +.panel-header__scope-menu-item--expandable { + display: flex; + align-items: center; + justify-content: space-between; +} + +.panel-header__scope-menu-item--expandable svg { + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.15s; +} + +.panel-header__scope-menu-item--expandable[aria-expanded='true'] svg { + transform: rotate(180deg); +} + +.panel-header__scope-select-panel { + padding: 2px 2px 0; +} + +.panel-header__scope-select-panel[hidden] { + display: none; +} + +.panel-header__scope-notebook-list { + max-height: 170px; + overflow-y: auto; + padding: 2px; +} + +.panel-header__scope-checkbox-row { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--ng-color); + transition: background 0.12s; +} + +.panel-header__scope-checkbox-row:hover { + background: var(--ng-hover); +} + +.panel-header__scope-checkbox-row input { + margin: 0; + flex-shrink: 0; + width: 14px; + height: 14px; + accent-color: var(--ng-accent); +} + +.panel-header__scope-apply-btn { + width: calc(100% - 4px); + margin: 8px 2px 2px; + background: var(--ng-accent); + border: none; + border-radius: 8px; + color: var(--ng-accent-contrast); + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; + font-family: inherit; + transition: background 0.15s; +} + +.panel-header__scope-apply-btn:hover { + background: var(--ng-accent-hover); +} + /* Legend */ .legend-panel { - padding: 10px 16px; + padding: 8px 14px; width: 100%; box-sizing: border-box; flex-shrink: 0; - background: var(--joplin-background-color3, rgba(128, 128, 128, 0.03)); - border-bottom: 1px solid rgba(128, 128, 128, 0.12); + background: var(--joplin-background-color3, var(--ng-subtle)); + border-bottom: 1px solid var(--ng-hairline); display: flex; flex-direction: column; - gap: 8px; + gap: 6px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } @@ -108,14 +325,14 @@ body { flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; width: 100%; } .legend-panel__group { display: flex; align-items: center; - gap: 8px; + gap: 6px; flex: 1 1 auto; } @@ -123,7 +340,7 @@ body { font-size: 10px; font-weight: 600; letter-spacing: 0.05em; - color: var(--joplin-color-faded, #888); + color: var(--ng-color-faded); text-transform: uppercase; margin-right: 4px; } @@ -131,18 +348,18 @@ body { .legend-panel__pill { display: flex; align-items: center; - gap: 6px; - padding: 4px 10px; + gap: 5px; + padding: 3px 8px; border-radius: 8px; - background: rgba(128, 128, 128, 0.06); - border: 1px solid rgba(128, 128, 128, 0.10); + background: var(--ng-subtle); + border: 1px solid var(--ng-hairline); cursor: pointer; font-family: inherit; transition: opacity 0.15s, background 0.15s, border-color 0.15s; } .legend-panel__pill:hover { - background: rgba(128, 128, 128, 0.12); + background: var(--ng-hover); } .legend-panel__pill--off { @@ -154,39 +371,39 @@ body { } .legend-panel__pill-label { - font-size: 11px; + font-size: 10.5px; font-weight: 500; } .legend-panel__swatch { display: inline-block; - width: 14px; + width: 12px; height: 0; flex-shrink: 0; } .legend-panel__swatch--explicit { - border-top: 2px solid #777; + border-top: 2px solid var(--ng-explicit); } .legend-panel__swatch--semantic { - border-top: 2px dashed #9b6bd5; + border-top: 2px dashed var(--ng-semantic); } .legend-panel__swatch--tags { - border-top: 2px dotted #4caf7d; + border-top: 2px dotted var(--ng-tag); } .legend-panel__pill--explicit .legend-panel__pill-label { - color: #777; + color: var(--ng-explicit); } .legend-panel__pill--semantic .legend-panel__pill-label { - color: #9b6bd5; + color: var(--ng-semantic); } .legend-panel__pill--tags .legend-panel__pill-label { - color: #4caf7d; + color: var(--ng-tag); } /* Search */ @@ -194,18 +411,18 @@ body { .legend-panel__search { display: flex; align-items: center; - gap: 6px; - background: rgba(128, 128, 128, 0.05); - border: 1px solid rgba(128, 128, 128, 0.12); + gap: 5px; + background: var(--ng-input); + border: 1px solid var(--ng-hairline); border-radius: 6px; - padding: 3px 8px; + padding: 2px 7px; flex-shrink: 0; transition: border-color 0.15s, background 0.15s; } .legend-panel__search:focus-within { - border-color: rgba(128, 128, 128, 0.30); - background: rgba(128, 128, 128, 0.08); + border-color: var(--ng-active); + background: var(--ng-hover); } .legend-panel__search-icon { @@ -215,22 +432,22 @@ body { } .legend-panel__search-icon svg { - width: 14px; - height: 14px; + width: 13px; + height: 13px; } .legend-panel__search-input { border: none; background: transparent; - color: var(--joplin-color); - font-size: 11px; + color: var(--ng-color); + font-size: 10.5px; font-family: inherit; outline: none; - width: 120px; + width: 100px; } .legend-panel__search-input::placeholder { - color: var(--joplin-color-faded, #888); + color: var(--ng-color-faded); } /* Controls */ @@ -252,28 +469,68 @@ body { .legend-panel__action-btn { display: flex; align-items: center; - gap: 5px; + gap: 4px; background: transparent; border: none; - border-radius: 6px; - color: var(--joplin-color-faded, #888); + border-radius: 7px; + height: 22px; + box-sizing: border-box; + color: var(--ng-color-faded); cursor: pointer; - padding: 3px 7px; + padding: 0 7px; flex-shrink: 0; - font-size: 11px; + font-size: 10.5px; font-weight: 500; transition: color 0.15s, background 0.15s; } -.legend-panel__action-btn:hover { - color: var(--joplin-color); - background: rgba(128, 128, 128, 0.10); +.legend-panel__action-btn:hover:not([disabled]) { + color: var(--ng-color); + background: var(--ng-hover); +} + +.legend-panel__action-btn[disabled] { + opacity: 0.35; + cursor: default; } .legend-panel__action-btn svg { flex-shrink: 0; - width: 14px; - height: 14px; + width: 13px; + height: 13px; +} + +.legend-panel__select { + background: transparent; + border: none; + border-radius: 7px; + height: 22px; + box-sizing: border-box; + color: var(--ng-color); + cursor: pointer; + font-family: inherit; + font-size: 10.5px; + font-weight: 500; + padding: 0 4px 0 7px; + width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.legend-panel__select:hover { + background: var(--ng-hover); +} + +.legend-panel__select option { + background-color: var(--ng-bg); + color: var(--ng-color); +} + +html.theme-dark .legend-panel__select { + color-scheme: dark; + background-color: var(--ng-bg); + color: var(--ng-color); } /* Stats bar */ @@ -287,10 +544,10 @@ body { width: 100%; box-sizing: border-box; flex-shrink: 0; - background: rgba(91, 155, 213, 0.07); - border-bottom: 1px solid rgba(128, 128, 128, 0.10); + background: color-mix(in srgb, var(--ng-accent) 7%, transparent); + border-bottom: 1px solid var(--ng-hairline); font-size: 11px; - color: var(--joplin-color-faded, #888); + color: var(--ng-color-faded); } .stats-bar__stat { @@ -301,21 +558,54 @@ body { .stats-bar__count { font-weight: 600; - color: var(--joplin-color); + color: var(--ng-color); font-variant-numeric: tabular-nums; } .stats-bar__label { - color: var(--joplin-color-faded, #888); + color: var(--ng-color-faded); } .stats-bar__sep { width: 1px; height: 10px; - background: rgba(128, 128, 128, 0.25); + background: var(--ng-divider); + flex-shrink: 0; +} + +.stats-bar__density { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; flex-shrink: 0; } +.stats-bar__density-icon { + display: flex; + align-items: center; + color: var(--ng-color-faded); + flex-shrink: 0; +} + +.stats-bar__slider { + width: 64px; + accent-color: var(--ng-accent); + cursor: pointer; +} + +.stats-bar__density-value { + font-size: 10px; + font-weight: 700; + color: var(--ng-accent); + background: color-mix(in srgb, var(--ng-accent) 14%, transparent); + border-radius: 999px; + padding: 2px 7px; + min-width: 14px; + text-align: center; + font-variant-numeric: tabular-nums; +} + .pipeline-progress { display: inline-flex; align-items: center; @@ -329,8 +619,8 @@ body { height: 9px; flex-shrink: 0; border-radius: 50%; - border: 1.5px solid rgba(91, 155, 213, 0.25); - border-top-color: #5b9bd5; + border: 1.5px solid color-mix(in srgb, var(--ng-accent) 25%, transparent); + border-top-color: var(--ng-accent); animation: pipeline-progress-spin 0.7s linear infinite; } @@ -345,7 +635,7 @@ body { height: 5px; flex-shrink: 0; border-radius: 3px; - background: rgba(91, 155, 213, 0.18); + background: color-mix(in srgb, var(--ng-accent) 18%, transparent); overflow: hidden; } @@ -353,14 +643,14 @@ body { display: block; height: 100%; width: 0%; - background: #5b9bd5; + background: var(--ng-accent); border-radius: 3px; transition: width 0.25s ease-out; } .pipeline-progress__label { flex-shrink: 0; - color: var(--joplin-color, #333); + color: var(--ng-color); font-weight: 600; white-space: nowrap; } @@ -370,7 +660,7 @@ body { background-color: transparent; border: none; border-radius: 6px; - color: var(--joplin-color); + color: var(--ng-color); cursor: pointer; padding: 3px; display: flex; @@ -382,7 +672,7 @@ body { .pipeline-progress__cancel-btn:hover:not([disabled]) { opacity: 1; - background: rgba(128, 128, 128, 0.12); + background: var(--ng-hover); } .pipeline-progress__cancel-btn svg { @@ -410,7 +700,7 @@ body { top: 50%; left: 50%; transform: translate(-50%, -50%); - color: var(--joplin-color-faded, #888); + color: var(--ng-color-faded); font-size: 13px; z-index: 1; max-width: 320px; @@ -425,9 +715,9 @@ body { visibility: hidden; transform: translateY(3px) scale(0.97); transition: opacity 0.12s ease, transform 0.12s ease; - background: var(--joplin-background-color, #1e1e1e); - background: color-mix(in srgb, var(--joplin-background-color, #1e1e1e) 90%, transparent); - color: var(--joplin-color, #ddd); + background: var(--ng-bg); + background: color-mix(in srgb, var(--ng-bg) 90%, transparent); + color: var(--ng-color); padding: 8px 12px; border-radius: 10px; font-size: 12px; @@ -435,8 +725,7 @@ body { z-index: 1000; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 16px 32px -12px rgba(0, 0, 0, 0.32); font-family: -apple-system, BlinkMacSystemFont, sans-serif; - border: 1px solid rgba(128, 128, 128, 0.22); - border-color: color-mix(in srgb, var(--joplin-color, #888) 14%, transparent); + border: 1px solid var(--ng-hairline); line-height: 1.35; max-width: 220px; white-space: normal; @@ -453,7 +742,7 @@ body { .graph-tooltip__title { font-weight: 600; font-size: 12.5px; - color: var(--joplin-color, #ddd); + color: var(--ng-color); letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; @@ -472,12 +761,9 @@ body { font-weight: 600; padding: 2px 8px; border-radius: 6px; - background: rgba(155, 107, 213, 0.14); - background: color-mix(in srgb, #9b6bd5 14%, transparent); - border: 1px solid rgba(155, 107, 213, 0.32); - border-color: color-mix(in srgb, #9b6bd5 32%, transparent); - color: #9b6bd5; - color: color-mix(in srgb, #9b6bd5 78%, var(--joplin-color, #ddd)); + background: color-mix(in srgb, var(--ng-semantic) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--ng-semantic) 32%, transparent); + color: color-mix(in srgb, var(--ng-semantic) 78%, var(--ng-color)); margin-bottom: 6px; } @@ -487,7 +773,7 @@ body { align-items: center; gap: 6px; font-size: 11px; - color: var(--joplin-color-faded, #aaa); + color: var(--ng-color-faded); } .graph-tooltip__stats + .graph-tooltip__stats { @@ -502,14 +788,14 @@ body { } .graph-tooltip__stat strong { - color: var(--joplin-color, #ddd); + color: var(--ng-color); font-weight: 600; } .graph-tooltip__sep { width: 1px; height: 9px; - background: rgba(128, 128, 128, 0.25); + background: var(--ng-divider); flex-shrink: 0; } @@ -537,15 +823,15 @@ body { .graph-tooltip__value { font-size: 12px; - color: var(--joplin-color, #ddd); + color: var(--ng-color); } .graph-tooltip--tag { - border-left-color: #4caf7d; + border-left-color: var(--ng-tag); } .graph-tooltip--relationship { - border-left-color: #9b6bd5; + border-left-color: var(--ng-semantic); } /* Zoom controls */ @@ -566,10 +852,10 @@ body { justify-content: center; width: 28px; height: 28px; - background: var(--joplin-background-color, #fff); - border: 1px solid rgba(128, 128, 128, 0.35); + background: var(--ng-bg); + border: 1px solid var(--ng-active); border-radius: 5px; - color: var(--joplin-color-faded, #666); + color: var(--ng-color-faded); cursor: pointer; padding: 0; box-shadow: none; @@ -577,9 +863,9 @@ body { } .graph-zoom__btn:hover { - color: var(--joplin-color); - border-color: rgba(128, 128, 128, 0.55); - background: rgba(128, 128, 128, 0.06); + color: var(--ng-color); + border-color: var(--ng-divider); + background: var(--ng-hover); } .graph-zoom__btn svg { @@ -588,11 +874,82 @@ body { display: block; } +.graph-controls-bottom-left { + position: absolute; + bottom: 14px; + left: 14px; + z-index: 10; +} + +.graph-pill-btn { + display: flex; + align-items: center; + background: var(--ng-bg); + border: 1px solid var(--ng-active); + border-radius: 8px; + color: var(--ng-color); + cursor: pointer; + padding: 5px 10px; + font-size: 11px; + font-weight: 600; + font-family: inherit; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + transition: border-color 0.15s, background 0.15s; +} + +.graph-pill-btn:hover { + border-color: var(--ng-divider); + background: var(--ng-hover); +} + +.graph-pill-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + min-width: 108px; + background: var(--ng-bg); + border: 1px solid var(--ng-hairline); + border-radius: 10px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 16px 32px -12px rgba(0, 0, 0, 0.32); + padding: 4px; + z-index: 10; +} + +.graph-pill-menu[hidden] { + display: none; +} + +.graph-pill-menu-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-radius: 6px; + color: var(--ng-color); + cursor: pointer; + padding: 5px 7px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + transition: background 0.12s, color 0.12s; +} + +.graph-pill-menu-item:hover { + background: color-mix(in srgb, var(--ng-accent) 12%, transparent); + color: var(--ng-accent); +} + +.graph-pill-menu-item--active { + color: var(--ng-accent); + font-weight: 600; +} + /* Active action button */ .legend-panel__action-btn--active { - color: var(--joplin-color); - background: rgba(128, 128, 128, 0.14); + color: var(--ng-color); + background: var(--ng-active); } /* Export menu */ @@ -600,8 +957,8 @@ body { .export-menu { display: none; position: fixed; - background: var(--joplin-background-color); - border: 1px solid rgba(128, 128, 128, 0.15); + background: var(--ng-bg); + border: 1px solid var(--ng-hairline); border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); z-index: 1000; @@ -619,7 +976,7 @@ body { border: none; border-radius: 5px; background: transparent; - color: var(--joplin-color); + color: var(--ng-color); font-size: 12px; font-family: inherit; cursor: pointer; @@ -627,9 +984,49 @@ body { } .export-menu__item:hover { - background: rgba(128, 128, 128, 0.10); + background: var(--ng-hover); } .export-menu__item svg { flex-shrink: 0; -} \ No newline at end of file +} + +/* Right-click context menu */ + +.graph-context-menu { + display: block; + position: fixed; + background: var(--ng-bg); + border: 1px solid var(--ng-hairline); + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 16px 32px -12px rgba(0, 0, 0, 0.32); + padding: 4px; + z-index: 1000; + min-width: 120px; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +.graph-context-menu[hidden] { + display: none; +} + +.graph-context-menu__item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-radius: 6px; + color: var(--ng-color); + cursor: pointer; + padding: 6px 10px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + transition: background 0.12s; +} + +.graph-context-menu__item:hover { + background: color-mix(in srgb, var(--ng-accent) 12%, transparent); + color: var(--ng-accent); +} diff --git a/src/ui/webview.test.ts b/src/ui/webview.test.ts index faeb47a..e64f68b 100644 --- a/src/ui/webview.test.ts +++ b/src/ui/webview.test.ts @@ -12,17 +12,32 @@ describe('webview', () => { let mockPanelsCreate: jest.Mock; let mockOnMessage: jest.Mock; let mockPostMessage: jest.Mock; + let mockPanelsShow: jest.Mock; let mockPanelsVisible: jest.Mock; let onNoData: jest.Mock; let onCancel: jest.Mock; - let onMessageHandler: (message: { type?: string; version?: number }) => Promise; + let onRequestFolders: jest.Mock; + let onGetScopeState: jest.Mock; + let onSetScope: jest.Mock; + let onMessageHandler: (message: { + type?: string; + version?: number; + mode?: string; + selectedIds?: string[]; + }) => Promise; beforeEach(async () => { jest.resetModules(); let freshJoplin: { views: { - panels: { create: jest.Mock; onMessage: jest.Mock; postMessage: jest.Mock; visible: jest.Mock }; + panels: { + create: jest.Mock; + onMessage: jest.Mock; + postMessage: jest.Mock; + show: jest.Mock; + visible: jest.Mock; + }; }; }; jest.isolateModules(() => { @@ -35,6 +50,7 @@ describe('webview', () => { mockPanelsCreate = freshJoplin!.views.panels.create; mockOnMessage = freshJoplin!.views.panels.onMessage; mockPostMessage = freshJoplin!.views.panels.postMessage; + mockPanelsShow = freshJoplin!.views.panels.show; mockPanelsVisible = freshJoplin!.views.panels.visible; mockPanelsCreate.mockResolvedValue('panel-handle'); @@ -46,7 +62,16 @@ describe('webview', () => { onNoData = jest.fn(); onCancel = jest.fn(); - await webview.initializeAiNoteGraphPanel(onNoData, onCancel); + onRequestFolders = jest.fn().mockResolvedValue([]); + onGetScopeState = jest.fn().mockResolvedValue({ mode: 'all', selectedNotebookIds: [] }); + onSetScope = jest.fn().mockResolvedValue(undefined); + await webview.initializeAiNoteGraphPanel( + onNoData, + onCancel, + onRequestFolders, + onGetScopeState, + onSetScope + ); }); it('calls onCancel and acknowledges a cancel-analysis message', async () => { @@ -91,7 +116,14 @@ describe('webview', () => { const response = await onMessageHandler({ type: 'request-data', version: 0 }); - expect(response).toEqual({ type: 'graph-data', nodes: [], edges: [], version: 1, progress: null }); + expect(response).toEqual({ + type: 'graph-data', + nodes: [], + edges: [], + version: 1, + progress: null, + focusNoteId: null, + }); }); it('replies no-change instead of re-sending the graph when the requester is already current', async () => { @@ -102,6 +134,31 @@ describe('webview', () => { expect(response).toEqual({ type: 'no-change', progress: null }); }); + it('delivers a queued focus note with the next graph response, then clears it', async () => { + await webview.postGraphData({ nodes: [], edges: [] }); + await webview.postFocusNote('note-1'); + + const first = await onMessageHandler({ type: 'request-data', version: 0 }); + const second = await onMessageHandler({ type: 'request-data', version: 0 }); + + expect(first).toEqual({ + type: 'graph-data', + nodes: [], + edges: [], + version: 1, + progress: null, + focusNoteId: 'note-1', + }); + expect(second).toEqual({ + type: 'graph-data', + nodes: [], + edges: [], + version: 1, + progress: null, + focusNoteId: null, + }); + }); + it('surfaces embedding progress on the next poll response, regardless of graph version', async () => { await webview.postGraphData({ nodes: [], edges: [] }); await webview.postProgress(3, 10); @@ -177,6 +234,87 @@ describe('webview', () => { await webview.postGraphData({ nodes: [], edges: [] }); mockPostMessage.mockRejectedValueOnce(new Error('panel gone')); - await expect(webview.postGraphPatch(emptyDiff, { nodes: [], edges: [] })).rejects.toThrow('panel gone'); + await expect(webview.postGraphPatch(emptyDiff, { nodes: [], edges: [] })).rejects.toThrow( + 'panel gone' + ); + }); + + describe('showAiNoteGraphPanel', () => { + it('shows the panel', async () => { + await webview.showAiNoteGraphPanel(); + + expect(mockPanelsShow).toHaveBeenCalledWith('panel-handle'); + }); + }); + + describe('postFocusNote', () => { + it('posts a focus-note message with the given note id', async () => { + await webview.postFocusNote('note-1'); + + expect(mockPostMessage).toHaveBeenCalledWith('panel-handle', { + type: 'focus-note', + noteId: 'note-1', + }); + }); + + it('posts a focus-note message with a null id to clear focus', async () => { + await webview.postFocusNote(null); + + expect(mockPostMessage).toHaveBeenCalledWith('panel-handle', { + type: 'focus-note', + noteId: null, + }); + }); + }); + + describe('isNoteGraphPanelVisible', () => { + it('reflects the panel visibility check', async () => { + mockPanelsVisible.mockResolvedValue(true); + expect(await webview.isNoteGraphPanelVisible()).toBe(true); + + mockPanelsVisible.mockResolvedValue(false); + expect(await webview.isNoteGraphPanelVisible()).toBe(false); + }); + }); + + describe('scope messages', () => { + it('returns the folder list from request-folders', async () => { + onRequestFolders.mockResolvedValue([{ id: 'id-1', title: 'Work' }]); + + const response = await onMessageHandler({ type: 'request-folders' }); + + expect(response).toEqual({ + type: 'folders', + folders: [{ id: 'id-1', title: 'Work' }], + }); + }); + + it('returns the current scope state from get-scope-state', async () => { + onGetScopeState.mockResolvedValue({ + mode: 'selected', + selectedNotebookIds: ['id-1'], + }); + + const response = await onMessageHandler({ type: 'get-scope-state' }); + + expect(response).toEqual({ mode: 'selected', selectedNotebookIds: ['id-1'] }); + }); + + it('applies a set-scope message via the callback', async () => { + const response = await onMessageHandler({ + type: 'set-scope', + mode: 'selected', + selectedIds: ['id-1', 'id-2'], + }); + + expect(onSetScope).toHaveBeenCalledWith('selected', ['id-1', 'id-2']); + expect(response).toEqual({ done: true }); + }); + + it('defaults selectedIds to an empty array when omitted', async () => { + await onMessageHandler({ type: 'set-scope', mode: 'all' }); + + expect(onSetScope).toHaveBeenCalledWith('all', []); + }); }); }); diff --git a/src/ui/webview.ts b/src/ui/webview.ts index da084f7..7ec350e 100644 --- a/src/ui/webview.ts +++ b/src/ui/webview.ts @@ -4,6 +4,16 @@ import { renderPanelHtml } from './App'; import { GraphData } from '../services/graph/types'; import { GraphDiff } from '../services/graph/GraphDiffer'; +export interface ScopeState { + mode: 'all' | 'current' | 'selected'; + selectedNotebookIds: string[]; +} + +export interface NotebookOption { + id: string; + title: string; +} + const PANEL_ID = 'aiNoteGraphPanel'; const PANEL_HTML = renderPanelHtml(); const PANEL_SCRIPTS = ['./ui/styles/panel.css', './ui/setup.js', './ui/graph-view.js']; @@ -18,13 +28,27 @@ let panelHandle: ViewHandle; let currentGraphData: GraphData | null = null; let currentVersion = 0; let currentProgress: ProgressState | null = null; +let queuedFocusNoteId: string | null = null; -const createPanel = async (onNoData: () => void, onCancel: () => void): Promise => { +const createPanel = async ( + onNoData: () => void, + onCancel: () => void, + onRequestFolders: () => Promise, + onGetScopeState: () => Promise, + onSetScope: (mode: ScopeState['mode'], selectedNotebookIds: string[]) => Promise +): Promise => { const handle = await joplin.views.panels.create(PANEL_ID); await joplin.views.panels.setHtml(handle, PANEL_HTML); await joplin.views.panels.onMessage( handle, - async (message: { type?: string; nodeId?: string; nodeLabel?: string; version?: number }) => { + async (message: { + type?: string; + nodeId?: string; + nodeLabel?: string; + version?: number; + mode?: ScopeState['mode']; + selectedIds?: string[]; + }) => { if (message?.type === 'close-note-graph') { await joplin.views.panels.hide(handle); return { done: true }; @@ -43,11 +67,14 @@ const createPanel = async (onNoData: () => void, onCancel: () => void): Promise< if (message.version === currentVersion) { return { type: 'no-change', progress: currentProgress }; } + const focusNoteId = queuedFocusNoteId; + queuedFocusNoteId = null; return { type: 'graph-data', ...currentGraphData, version: currentVersion, progress: currentProgress, + focusNoteId, }; } if (message?.type === 'node-clicked' && message?.nodeId) { @@ -58,6 +85,16 @@ const createPanel = async (onNoData: () => void, onCancel: () => void): Promise< } return { done: true }; } + if (message?.type === 'request-folders') { + return { type: 'folders', folders: await onRequestFolders() }; + } + if (message?.type === 'get-scope-state') { + return await onGetScopeState(); + } + if (message?.type === 'set-scope' && message.mode) { + await onSetScope(message.mode, message.selectedIds ?? []); + return { done: true }; + } } ); @@ -81,12 +118,21 @@ const getPanel = (): ViewHandle => { */ export const initializeAiNoteGraphPanel = async ( onNoData: () => void, - onCancel: () => void + onCancel: () => void, + onRequestFolders: () => Promise, + onGetScopeState: () => Promise, + onSetScope: (mode: ScopeState['mode'], selectedNotebookIds: string[]) => Promise ): Promise => { if (panelHandle) { return; } - panelHandle = await createPanel(onNoData, onCancel); + panelHandle = await createPanel( + onNoData, + onCancel, + onRequestFolders, + onGetScopeState, + onSetScope + ); }; /** @@ -97,6 +143,17 @@ export const showAiNoteGraphPanel = async (): Promise => { await joplin.views.panels.show(handle); }; +export const postFocusNote = async (noteId: string | null): Promise => { + queuedFocusNoteId = noteId; + if (!panelHandle) return; + await joplin.views.panels.postMessage(panelHandle, { type: 'focus-note', noteId }); +}; + +export const isNoteGraphPanelVisible = async (): Promise => { + if (!panelHandle) return false; + return joplin.views.panels.visible(panelHandle); +}; + /** * Stores graph data and pushes it to the panel if already shown. * On first call the panel requests the data on load; subsequent calls push proactively.