From aca0067b9c355022e4edd19e04ea8bea32089a65 Mon Sep 17 00:00:00 2001 From: Yugal Kaushik Date: Thu, 9 Jul 2026 19:03:36 +0530 Subject: [PATCH 1/5] ANG-007: Markdown preprocessing & embedding pipeline --- api/Global.d.ts | 6 +- api/Joplin.d.ts | 15 +- api/JoplinAi.d.ts | 80 ++++++++ api/JoplinClipboard.d.ts | 18 +- api/JoplinContentScripts.d.ts | 4 +- api/JoplinData.d.ts | 5 +- api/JoplinFs.d.ts | 22 ++ api/JoplinImaging.d.ts | 10 +- api/JoplinSettings.d.ts | 2 +- api/JoplinViews.d.ts | 4 +- api/JoplinViewsDialogs.d.ts | 6 +- api/JoplinViewsEditor.d.ts | 7 +- api/JoplinViewsMenuItems.d.ts | 3 +- api/JoplinViewsMenus.d.ts | 3 +- api/JoplinViewsPanels.d.ts | 7 +- api/JoplinViewsToolbarButtons.d.ts | 3 +- api/JoplinWindow.d.ts | 9 +- api/JoplinWorkspace.d.ts | 7 +- api/noteListType.d.ts | 6 +- api/noteListType.ts | 10 +- api/types.ts | 155 +++++++++++--- package-lock.json | 14 +- package.json | 7 +- src/index.ts | 14 +- src/services/embeddings/Orchestrator.test.ts | 102 +++++++++ src/services/embeddings/Orchestrator.ts | 72 +++++++ .../embeddings/ProviderResolver.test.ts | 47 +++++ src/services/embeddings/ProviderResolver.ts | 26 +++ .../Providers/JoplinNativeProvider.ts | 116 +++++++++++ src/services/embeddings/Types.ts | 31 +++ src/services/graph/GraphBuilder.ts | 5 - src/tests/mocks/joplin.ts | 12 +- src/ui/graph-view.js | 20 +- src/ui/setup.js | 8 +- src/ui/styles/panel.css | 194 ++++++++++++++++++ src/ui/webview.ts | 11 - tsconfig.json | 3 +- 37 files changed, 967 insertions(+), 97 deletions(-) create mode 100644 api/JoplinAi.d.ts create mode 100644 api/JoplinFs.d.ts create mode 100644 src/services/embeddings/Orchestrator.test.ts create mode 100644 src/services/embeddings/Orchestrator.ts create mode 100644 src/services/embeddings/ProviderResolver.test.ts create mode 100644 src/services/embeddings/ProviderResolver.ts create mode 100644 src/services/embeddings/Providers/JoplinNativeProvider.ts create mode 100644 src/services/embeddings/Types.ts diff --git a/api/Global.d.ts b/api/Global.d.ts index 686ccf1..56bb162 100644 --- a/api/Global.d.ts +++ b/api/Global.d.ts @@ -1,5 +1,7 @@ import Plugin from '../Plugin'; import Joplin from './Joplin'; +import BasePlatformImplementation from '../BasePlatformImplementation'; +import type { Store } from 'redux'; /** * @ignore */ @@ -8,7 +10,7 @@ import Joplin from './Joplin'; */ export default class Global { private joplin_; - constructor(implementation: any, plugin: Plugin, store: any); + constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: Store); get joplin(): Joplin; - get process(): any; + get process(): NodeJS.Process; } diff --git a/api/Joplin.d.ts b/api/Joplin.d.ts index 15c672c..ca1617e 100644 --- a/api/Joplin.d.ts +++ b/api/Joplin.d.ts @@ -12,6 +12,9 @@ import JoplinClipboard from './JoplinClipboard'; import JoplinWindow from './JoplinWindow'; import BasePlatformImplementation from '../BasePlatformImplementation'; import JoplinImaging from './JoplinImaging'; +import JoplinFs from './JoplinFs'; +import JoplinAi from './JoplinAi'; +import type { Store } from 'redux'; /** * This is the main entry point to the Joplin API. You can access various services using the provided accessors. * @@ -28,6 +31,7 @@ export default class Joplin { private data_; private plugins_; private imaging_; + private fs_; private workspace_; private filters_; private commands_; @@ -37,11 +41,13 @@ export default class Joplin { private contentScripts_; private clipboard_; private window_; + private ai_; private implementation_; - constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any); + constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: Store); get data(): JoplinData; get clipboard(): JoplinClipboard; get imaging(): JoplinImaging; + get fs(): JoplinFs; get window(): JoplinWindow; get plugins(): JoplinPlugins; get workspace(): JoplinWorkspace; @@ -57,6 +63,13 @@ export default class Joplin { get views(): JoplinViews; get interop(): JoplinInterop; get settings(): JoplinSettings; + /** + * Access to AI features: chat completions and semantic search over the + * local embeddings index. See {@link JoplinAi}. + * + * desktop + */ + get ai(): JoplinAi; /** * It is not possible to bundle native packages with a plugin, because they * need to work cross-platforms. Instead access to certain useful native diff --git a/api/JoplinAi.d.ts b/api/JoplinAi.d.ts new file mode 100644 index 0000000..9e611b2 --- /dev/null +++ b/api/JoplinAi.d.ts @@ -0,0 +1,80 @@ +import { ChatMessage, ChatOptions, SearchOptions, SearchResult } from './types'; +/** + * Provides access to AI models configured by the user. The active provider + * (Joplin Cloud AI, OpenAI-compatible, or Anthropic) and the model are picked + * by the user in the Joplin settings — plugins inherit whichever is active. + * + * AI is disabled by default. The user must enable it in the settings, and + * separately grant permission to use a remote (cloud-hosted) provider before + * any plugin call will succeed. + * + * If the user is signed into Joplin Cloud, AI works zero-config — they only + * need to flip the master toggle on. + * + * desktop + */ +export default class JoplinAi { + /** + * Sends a chat completion request to the active AI provider and returns the + * assistant's text response. + * + * The active provider and model are controlled by the user in Settings → + * AI. Plugins should not assume any particular provider or model. + * + * This call throws when: + * + * - AI features are disabled (`AI features are disabled`). + * - The active provider is remote and the user has not allowed remote + * providers (`Remote AI access is not allowed`). + * - The provider is misconfigured, e.g. missing API key or model name + * (`*provider* has no API key configured`). + * - The provider returns an HTTP error (the message includes the status + * and any detail returned by the provider). + * + * Plugins should catch these errors and present a user-friendly message + * pointing the user at the Joplin settings. + * + * @example + * ```typescript + * const reply = await joplin.ai.chat([ + * { role: 'system', content: 'You are a concise assistant.' }, + * { role: 'user', content: 'Summarise this note: ...' }, + * ]); + * console.log(reply); + * ``` + */ + chat(messages: ChatMessage[], options?: ChatOptions): Promise; + /** + * Runs a semantic search against the locally-indexed embeddings and + * returns matching chunks ranked by similarity. + * + * The `query` is either plain text (which gets embedded internally) or + * `{ noteId }`, which reuses the note's already-indexed chunks as the + * query — useful for "find related notes" / tag suggestion / semantic + * graph use cases without spending another embedding pass. + * + * The `scope` restricts the search: `'all'` (default), `'note'`, + * `'folder'` (by folder id), or `'tag'` (by tag id). + * Trashed and conflict notes are excluded from results. + * + * The `relevance` preset controls how strict the match is: + * `'strict' | 'normal' | 'loose'`. Joplin owns the mapping from preset + * to model-specific (k, minScore) — plugins write against the preset + * and stay compatible when the bundled model changes. + * + * Throws when AI features are disabled or no embedding provider is + * active (e.g. ONNX failed to load on this platform). + * + * @example + * ```typescript + * const results = await joplin.ai.search({ + * query: { text: 'pizza dough hydration' }, + * relevance: 'normal', + * }); + * for (const r of results) { + * console.log(r.score, r.noteId, r.chunkText.slice(0, 80)); + * } + * ``` + */ + search(options: SearchOptions): Promise; +} diff --git a/api/JoplinClipboard.d.ts b/api/JoplinClipboard.d.ts index 6d2baa8..60c4c1c 100644 --- a/api/JoplinClipboard.d.ts +++ b/api/JoplinClipboard.d.ts @@ -1,8 +1,23 @@ import { ClipboardContent } from './types'; +interface ElectronClipboardLike { + readText(): string; + writeText(text: string): void; + readHTML(): string; + writeHTML(html: string): void; + readImage(): { + toDataURL(): string; + } | null; + writeImage(image: unknown): void; + availableFormats(): string[]; + write(data: Record): void; +} +interface ElectronNativeImageLike { + createFromDataURL(dataUrl: string): unknown; +} export default class JoplinClipboard { private electronClipboard_; private electronNativeImage_; - constructor(electronClipboard: any, electronNativeImage: any); + constructor(electronClipboard: ElectronClipboardLike, electronNativeImage: ElectronNativeImageLike); readText(): Promise; writeText(text: string): Promise; /** desktop */ @@ -43,3 +58,4 @@ export default class JoplinClipboard { */ write(content: ClipboardContent): Promise; } +export {}; diff --git a/api/JoplinContentScripts.d.ts b/api/JoplinContentScripts.d.ts index adf4e8d..d391ce0 100644 --- a/api/JoplinContentScripts.d.ts +++ b/api/JoplinContentScripts.d.ts @@ -1,4 +1,4 @@ -import Plugin from '../Plugin'; +import Plugin, { MessageListenerCallback } from '../Plugin'; import { ContentScriptType } from './types'; export default class JoplinContentScripts { private plugin; @@ -37,5 +37,5 @@ export default class JoplinContentScripts { * [postMessage * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) */ - onMessage(contentScriptId: string, callback: any): Promise; + onMessage(contentScriptId: string, callback: MessageListenerCallback): Promise; } diff --git a/api/JoplinData.d.ts b/api/JoplinData.d.ts index 026150a..e76db36 100644 --- a/api/JoplinData.d.ts +++ b/api/JoplinData.d.ts @@ -1,4 +1,5 @@ import { ModelType } from '../../../BaseModel'; +import { RequestFile } from '../../rest/Api'; import Plugin from '../Plugin'; import { Path } from './types'; /** @@ -45,8 +46,8 @@ export default class JoplinData { private serializeApiBody; private pathToString; get(path: Path, query?: any): Promise; - post(path: Path, query?: any, body?: any, files?: any[]): Promise; - put(path: Path, query?: any, body?: any, files?: any[]): Promise; + post(path: Path, query?: any, body?: any, files?: RequestFile[]): Promise; + put(path: Path, query?: any, body?: any, files?: RequestFile[]): Promise; delete(path: Path, query?: any): Promise; itemType(itemId: string): Promise; resourcePath(resourceId: string): Promise; diff --git a/api/JoplinFs.d.ts b/api/JoplinFs.d.ts new file mode 100644 index 0000000..238d5da --- /dev/null +++ b/api/JoplinFs.d.ts @@ -0,0 +1,22 @@ +export interface ArchiveEntry { + entryName: string; + name: string; +} +/** + * Provides file system utilities for plugins. + * + * desktop + */ +export default class JoplinFs { + /** + * Extracts an archive to the specified directory. Currently only ZIP files + * are supported. + * + * desktop + * + * @param sourcePath Path to the archive file to extract + * @param destinationPath Path to the directory where the contents should be extracted + * @returns List of entries extracted from the archive + */ + archiveExtract(sourcePath: string, destinationPath: string): Promise; +} diff --git a/api/JoplinImaging.d.ts b/api/JoplinImaging.d.ts index 8d878b5..7c5d469 100644 --- a/api/JoplinImaging.d.ts +++ b/api/JoplinImaging.d.ts @@ -1,3 +1,4 @@ +import { ResourceEntity } from '../../database/types'; import { Rectangle } from './types'; export interface CreateFromBufferOptions { width?: number; @@ -65,7 +66,10 @@ export default class JoplinImaging { createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise; getPdfInfoFromPath(path: string): Promise; getPdfInfoFromResource(resourceId: string): Promise; - getSize(handle: Handle): Promise; + getSize(handle: Handle): Promise<{ + width: number; + height: number; + }>; resize(handle: Handle, options?: ResizeOptions): Promise; crop(handle: Handle, rectangle: Rectangle): Promise; toPngFile(handle: Handle, filePath: string): Promise; @@ -78,12 +82,12 @@ export default class JoplinImaging { * Creates a new Joplin resource from the image data. The image will be * first converted to a JPEG. */ - toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise; + toJpgResource(handle: Handle, resourceProps: Partial, quality?: number): Promise; /** * Creates a new Joplin resource from the image data. The image will be * first converted to a PNG. */ - toPngResource(handle: Handle, resourceProps: any): Promise; + toPngResource(handle: Handle, resourceProps: Partial): Promise; /** * Image data is not automatically deleted by Joplin so make sure you call * this method on the handle once you are done. diff --git a/api/JoplinSettings.d.ts b/api/JoplinSettings.d.ts index 13cdca2..a3aa324 100644 --- a/api/JoplinSettings.d.ts +++ b/api/JoplinSettings.d.ts @@ -40,7 +40,7 @@ export default class JoplinSettings { /** * Gets setting values (only applies to setting you registered from your plugin) */ - values(keys: string[] | string): Promise>; + values(keys: string[] | string): Promise>; /** * Gets a setting value (only applies to setting you registered from your plugin). * diff --git a/api/JoplinViews.d.ts b/api/JoplinViews.d.ts index 364e82a..237286b 100644 --- a/api/JoplinViews.d.ts +++ b/api/JoplinViews.d.ts @@ -1,4 +1,6 @@ +import { JoplinViews as JoplinViewsImplementation } from '../BasePlatformImplementation'; import Plugin from '../Plugin'; +import { PluginStore } from '../ViewController'; import JoplinViewsDialogs from './JoplinViewsDialogs'; import JoplinViewsMenuItems from './JoplinViewsMenuItems'; import JoplinViewsMenus from './JoplinViewsMenus'; @@ -32,7 +34,7 @@ export default class JoplinViews { private editors_; private noteList_; private implementation_; - constructor(implementation: any, plugin: Plugin, store: any); + constructor(implementation: JoplinViewsImplementation, plugin: Plugin, store: PluginStore); get dialogs(): JoplinViewsDialogs; get panels(): JoplinViewsPanels; get editors(): JoplinViewsEditors; diff --git a/api/JoplinViewsDialogs.d.ts b/api/JoplinViewsDialogs.d.ts index 55db518..a331545 100644 --- a/api/JoplinViewsDialogs.d.ts +++ b/api/JoplinViewsDialogs.d.ts @@ -1,5 +1,7 @@ import Plugin from '../Plugin'; import { ButtonSpec, ViewHandle, DialogResult, Toast } from './types'; +import { JoplinViewsDialogs as JoplinViewsDialogsImplementation, ShowOpenDialogOptions } from '../BasePlatformImplementation'; +import { PluginStore } from '../ViewController'; /** * Allows creating and managing dialogs. A dialog is modal window that * contains a webview and a row of buttons. You can update the @@ -33,7 +35,7 @@ export default class JoplinViewsDialogs { private store; private plugin; private implementation_; - constructor(implementation: any, plugin: Plugin, store: any); + constructor(implementation: JoplinViewsDialogsImplementation, plugin: Plugin, store: PluginStore); private controller; /** * Creates a new dialog @@ -54,7 +56,7 @@ export default class JoplinViewsDialogs { * * desktop */ - showOpenDialog(options: any): Promise; + showOpenDialog(options: ShowOpenDialogOptions): Promise; /** * Sets the dialog HTML content */ diff --git a/api/JoplinViewsEditor.d.ts b/api/JoplinViewsEditor.d.ts index 512c596..72bd953 100644 --- a/api/JoplinViewsEditor.d.ts +++ b/api/JoplinViewsEditor.d.ts @@ -1,4 +1,5 @@ -import Plugin from '../Plugin'; +import Plugin, { MessageListenerCallback } from '../Plugin'; +import { PluginStore } from '../ViewController'; import { ActivationCheckCallback, ViewHandle, UpdateCallback, EditorPluginCallbacks } from './types'; interface SaveNoteOptions { /** @@ -55,7 +56,7 @@ export default class JoplinViewsEditors { private plugin; private activationCheckHandlers_; private unhandledActivationCheck_; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); private controller; /** * Registers a new editor plugin. Joplin will call the provided callback to create new editor views @@ -79,7 +80,7 @@ export default class JoplinViewsEditors { /** * See [[JoplinViewPanels]] */ - onMessage(handle: ViewHandle, callback: Function): Promise; + onMessage(handle: ViewHandle, callback: MessageListenerCallback): Promise; /** * Saves the content of the editor, without calling `onUpdate` for editors in the same window. */ diff --git a/api/JoplinViewsMenuItems.d.ts b/api/JoplinViewsMenuItems.d.ts index 5e236b1..d8b46f5 100644 --- a/api/JoplinViewsMenuItems.d.ts +++ b/api/JoplinViewsMenuItems.d.ts @@ -1,5 +1,6 @@ import { CreateMenuItemOptions, MenuItemLocation } from './types'; import Plugin from '../Plugin'; +import { PluginStore } from '../ViewController'; /** * Allows creating and managing menu items. * @@ -10,7 +11,7 @@ import Plugin from '../Plugin'; export default class JoplinViewsMenuItems { private store; private plugin; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); /** * Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter. */ diff --git a/api/JoplinViewsMenus.d.ts b/api/JoplinViewsMenus.d.ts index 474830d..67f6d6d 100644 --- a/api/JoplinViewsMenus.d.ts +++ b/api/JoplinViewsMenus.d.ts @@ -1,5 +1,6 @@ import { MenuItem, MenuItemLocation } from './types'; import Plugin from '../Plugin'; +import { PluginStore } from '../ViewController'; /** * Allows creating menus. * @@ -10,7 +11,7 @@ import Plugin from '../Plugin'; export default class JoplinViewsMenus { private store; private plugin; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); private registerCommandAccelerators; /** * Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the diff --git a/api/JoplinViewsPanels.d.ts b/api/JoplinViewsPanels.d.ts index 881dbb0..73259da 100644 --- a/api/JoplinViewsPanels.d.ts +++ b/api/JoplinViewsPanels.d.ts @@ -1,4 +1,5 @@ -import Plugin from '../Plugin'; +import Plugin, { MessageListenerCallback } from '../Plugin'; +import { PluginStore } from '../ViewController'; import { ViewHandle } from './types'; /** * Allows creating and managing view panels. View panels allow displaying any HTML @@ -17,7 +18,7 @@ import { ViewHandle } from './types'; export default class JoplinViewsPanels { private store; private plugin; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); private controller; /** * Creates a new panel @@ -50,7 +51,7 @@ export default class JoplinViewsPanels { * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. * */ - onMessage(handle: ViewHandle, callback: Function): Promise; + onMessage(handle: ViewHandle, callback: MessageListenerCallback): Promise; /** * Sends a message to the webview. * diff --git a/api/JoplinViewsToolbarButtons.d.ts b/api/JoplinViewsToolbarButtons.d.ts index ba17c83..c1d12c2 100644 --- a/api/JoplinViewsToolbarButtons.d.ts +++ b/api/JoplinViewsToolbarButtons.d.ts @@ -1,5 +1,6 @@ import { ToolbarButtonLocation } from './types'; import Plugin from '../Plugin'; +import { PluginStore } from '../ViewController'; /** * Allows creating and managing toolbar buttons. * @@ -8,7 +9,7 @@ import Plugin from '../Plugin'; export default class JoplinViewsToolbarButtons { private store; private plugin; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); /** * Creates a new toolbar button and associate it with the given command. */ diff --git a/api/JoplinWindow.d.ts b/api/JoplinWindow.d.ts index 4cbdc64..ed9b3cb 100644 --- a/api/JoplinWindow.d.ts +++ b/api/JoplinWindow.d.ts @@ -1,7 +1,13 @@ import Plugin from '../Plugin'; +type DispatchStore = { + dispatch: (action: { + type: string; + [k: string]: unknown; + }) => void; +}; export default class JoplinWindow { private store_; - constructor(_plugin: Plugin, store: any); + constructor(_plugin: Plugin, store: DispatchStore); /** * Loads a chrome CSS file. It will apply to the window UI elements, except * for the note viewer. It is the same as the "Custom stylesheet for @@ -21,3 +27,4 @@ export default class JoplinWindow { */ loadNoteCssFile(filePath: string): Promise; } +export {}; diff --git a/api/JoplinWorkspace.d.ts b/api/JoplinWorkspace.d.ts index 9799f6f..e89f7e6 100644 --- a/api/JoplinWorkspace.d.ts +++ b/api/JoplinWorkspace.d.ts @@ -1,5 +1,6 @@ import Plugin from '../Plugin'; -import { FolderEntity } from '../../database/types'; +import { PluginStore } from '../ViewController'; +import { FolderEntity, NoteEntity } from '../../database/types'; import { Disposable, EditContextMenuFilterObject, FilterHandler } from './types'; declare enum ItemChangeEventType { Create = 1, @@ -40,7 +41,7 @@ type ResourceChangeHandler = WorkspaceEventHandler; export default class JoplinWorkspace { private store; private plugin; - constructor(plugin: Plugin, store: any); + constructor(plugin: Plugin, store: PluginStore); /** * Called when a new note or notes are selected. */ @@ -83,7 +84,7 @@ export default class JoplinWorkspace { * * On desktop, this returns the selected note in the focused window. */ - selectedNote(): Promise; + selectedNote(): Promise; /** * Gets the currently selected folder. In some cases, for example during * search or when viewing a tag, no folder is actually selected in the user diff --git a/api/noteListType.d.ts b/api/noteListType.d.ts index 2fc14ee..7862a63 100644 --- a/api/noteListType.d.ts +++ b/api/noteListType.d.ts @@ -1,5 +1,5 @@ import { Size } from './types'; -type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; +type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.extracted_resource_ids' | 'note.id' | 'note.is_conflict' | 'note.is_locked' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; export declare enum ItemFlow { TopToBottom = "topToBottom", LeftToRight = "leftToRight" @@ -30,9 +30,9 @@ export type OnClickHandler = (event: OnClickEvent) => Promise; * The `item.*` properties are specific to the rendered item. The most important being * `item.selected`, which you can use to display the selected note in a different way. */ -export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml'; +export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.checkboxes' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml'; export type ListRendererItemValueTemplates = Record; -export declare const columnNames: readonly ["note.folder.title", "note.is_todo", "note.latitude", "note.longitude", "note.source_url", "note.tags", "note.title", "note.todo_completed", "note.todo_due", "note.user_created_time", "note.user_updated_time"]; +export declare const columnNames: readonly ["note.checkboxes", "note.folder.title", "note.is_todo", "note.latitude", "note.longitude", "note.source_url", "note.tags", "note.title", "note.todo_completed", "note.todo_due", "note.user_created_time", "note.user_updated_time"]; export type ColumnName = typeof columnNames[number]; export interface ListRenderer { /** diff --git a/api/noteListType.ts b/api/noteListType.ts index ad00453..cf09294 100644 --- a/api/noteListType.ts +++ b/api/noteListType.ts @@ -3,7 +3,7 @@ import { Size } from './types'; // AUTO-GENERATED by generate-database-type -type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; +type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.extracted_resource_ids' | 'note.id' | 'note.is_conflict' | 'note.is_locked' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; // AUTO-GENERATED by generate-database-type export enum ItemFlow { @@ -11,12 +11,12 @@ export enum ItemFlow { LeftToRight = 'leftToRight', } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin-API output map; values are heterogeneous (HTML strings, formatted numbers, booleans) and indexed dynamically per-plugin export type RenderNoteView = Record; export interface OnChangeEvent { elementId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: value depends on the input element type value: any; noteId: string; } @@ -25,7 +25,7 @@ export interface OnClickEvent { elementId: string; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin-API callback; props is a per-renderer subset of the note shape declared via itemProps, indexed dynamically export type OnRenderNoteHandler = (props: any)=> Promise; export type OnChangeHandler = (event: OnChangeEvent)=> Promise; export type OnClickHandler = (event: OnClickEvent)=> Promise; @@ -50,6 +50,7 @@ export type ListRendererDependency = 'item.selected' | 'item.size.height' | 'item.size.width' | + 'note.checkboxes' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | @@ -59,6 +60,7 @@ export type ListRendererDependency = export type ListRendererItemValueTemplates = Record; export const columnNames = [ + 'note.checkboxes', 'note.folder.title', 'note.is_todo', 'note.latitude', diff --git a/api/types.ts b/api/types.ts index 3911a38..58c1614 100644 --- a/api/types.ts +++ b/api/types.ts @@ -26,7 +26,7 @@ export interface Command { /** * Code to be ran when the command is executed. It may return a result. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin commands accept arbitrary args and return arbitrary results; this is part of the public plugin API execute(...args: any[]): Promise; /** @@ -116,13 +116,13 @@ export interface ExportModule { /** * Called when an item needs to be processed. An "item" can be any Joplin object, such as a note, a folder, a notebook, etc. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: item type depends on itemType (NoteEntity, FolderEntity, ResourceEntity, etc.); plugin authors discriminate at use site onProcessItem(context: ExportContext, itemType: number, item: any): Promise; /** * Called when a resource file needs to be exported. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See onProcessItem; resource here is a ResourceEntity but the plugin API keeps it loosely typed onProcessResource(context: ExportContext, resource: any, filePath: string): Promise; /** @@ -186,13 +186,13 @@ export interface ExportContext { /** * You can attach your own custom data using this property - it will then be passed to each event handler, allowing you to keep state from one event to the next. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state userData?: any; } export interface ImportContext { sourcePath: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: import options are arbitrary per-importer options: any; warnings: string[]; } @@ -202,7 +202,7 @@ export interface ImportContext { // ================================================================= export interface Script { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: event payload shape depends on the host context onStart?(event: any): Promise; } @@ -308,7 +308,7 @@ export interface MenuItem { * Arguments that should be passed to the command. They will be as rest * parameters. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command args depend on the command commandArgs?: any[]; /** @@ -362,13 +362,13 @@ export type ViewHandle = string; export interface EditorCommand { name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command value depends on the command value?: any; } export interface DialogResult { id: ButtonId; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: form data shape depends on the dialog formData?: any; } @@ -434,8 +434,30 @@ export interface EditorPluginCallbacks { export type VisibleHandler = ()=> Promise; +/** + * Identifies the type of element that was right-clicked in the editor context menu. + */ +export enum ContextMenuItemType { + None = '', + Image = 'image', + Resource = 'resource', + Text = 'text', + Link = 'link', + NoteLink = 'noteLink', +} + export interface EditContextMenuFilterObject { items: MenuItem[]; + /** + * Context about what was right-clicked. Plugins should use this instead of + * checking the editor cursor position, as the cursor may not reflect the + * actual click location. + */ + context?: { + resourceId?: string; + itemType?: ContextMenuItemType; + textToCopy?: string; + }; } export interface EditorActivationCheckFilterObject { @@ -497,7 +519,7 @@ export enum SettingStorage { // Redefine a simplified interface to mask internal details // and to remove function calls as they would have to be async. export interface SettingItem { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Setting values are heterogeneous per setting (string/number/bool/Record/Array); plugin authors narrow at use site value: any; type: SettingItemType; @@ -534,8 +556,7 @@ export interface SettingItem { * This property is required when `isEnum` is `true`. In which case, it * should contain a map of value => label. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - options?: Record; + options?: Record; /** * Reserved property. Not used at the moment. @@ -616,7 +637,7 @@ export interface ClipboardContent { // Content Script types // ================================================================= -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: messages between content scripts and plugins are arbitrary serialisable data export type PostMessageHandler = (message: any)=> Promise; /** @@ -640,38 +661,38 @@ export interface ContentScriptContext { } export interface ContentScriptModuleLoadedEvent { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state userData?: any; } export interface ContentScriptModule { onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin entry point returns a plugin-specific module (markdown-it plugin, CodeMirror plugin, etc.); shape varies per content script type plugin: ()=> any; assets?: ()=> void; } export interface MarkdownItContentScriptModule extends Omit { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- markdown-it and options are external library types not imported here; plugin authors annotate concretely plugin: (markdownIt: any, options: any)=> any; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror command callbacks accept and return arbitrary values; matches CM6 Command type type EditorCommandCallback = (...args: any[])=> any; export interface CodeMirrorControl { /** Points to a CodeMirror 6 EditorView instance. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- CM6 EditorView is an external library type not imported here editor: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- CM6 module namespace; types come from the external library cm6: any; /** `extension` should be a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension). */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- CM6 Extension type comes from the external library addExtension(extension: any|any[]): void; supportsCommand(name: string): boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See EditorCommandCallback execCommand(name: string, ...args: any[]): any; registerCommand(name: string, callback: EditorCommandCallback): void; @@ -685,13 +706,13 @@ export interface CodeMirrorControl { * * Using `autocompletion({ override: [ ... ]})` causes errors when done by multiple plugins. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- CM6 CompletionSource and Extension types come from the external library completionSource(completionSource: any): any; /** * Creates an extension that enables or disables [`languageData`-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override). */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See completionSource above enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any }; /** @@ -938,3 +959,91 @@ export enum ContentScriptType { */ CodeMirrorPlugin = 'codeMirrorPlugin', } + +// ================================================================= +// AI API types +// ================================================================= + +/** + * Role of a chat message. `system` messages set the assistant's behaviour, + * `user` messages come from the end user, and `assistant` messages are model + * responses fed back as conversation history. + */ +export type ChatMessageRole = 'system' | 'user' | 'assistant'; + +/** + * A single message in a chat conversation. + */ +export interface ChatMessage { + role: ChatMessageRole; + content: string; +} + +/** + * Optional parameters for a chat call. The active model and provider are + * controlled by the user in the Joplin settings — plugins cannot pick a model. + */ +export interface ChatOptions { + /** Sampling temperature, typically between 0 and 1. Provider default if omitted. */ + temperature?: number; + /** Maximum number of tokens to generate. Provider default if omitted. */ + maxTokens?: number; +} + +/** + * Relevance preset for semantic search. Maps internally to model-specific + * `(k, minScore)` tuning — the preset is the public contract so plugins keep + * working when the bundled embedding model changes. + */ +export type SearchRelevance = 'strict' | 'normal' | 'loose'; + +/** + * Where to look for matches. + * + * - `all`: every indexed note (default). + * - `note`: a single note (rarely useful directly — mainly an internal + * building block). + * - `folder`: all notes in the given folder (a "notebook" in the UI). + * - `tag`: all notes tagged with the given tag. + * + * Trashed and conflict notes are always excluded. + */ +export type SearchScope = + | { type: 'all' } + | { type: 'note'; noteId: string } + | { type: 'folder'; folderId: string } + | { type: 'tag'; tagId: string }; + +/** + * What to search for: free text (embedded internally), or an existing note + * whose stored chunks are reused as the query — useful for "related notes", + * tag suggestions, and graph-style use cases without a second embedding pass. + */ +export type SearchQuery = + | { text: string } + | { noteId: string }; + +/** + * Parameters for {@link JoplinAi.search}. + */ +export interface SearchOptions { + query: SearchQuery; + scope?: SearchScope; + relevance?: SearchRelevance; +} + +/** + * A single hit from {@link JoplinAi.search}. + */ +export interface SearchResult { + noteId: string; + chunkIndex: number; + chunkText: string; + /** + * Cosine similarity in `[0, 1]`. Higher means more similar. Plugins should + * use this for ranking but not as an absolute threshold — that's what the + * `relevance` preset is for. + */ + score: number; +} + diff --git a/package-lock.json b/package-lock.json index b6b66d4..f2a3a72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "license": "MIT", "dependencies": { "cytoscape": "^3.34.0", - "cytoscape-fcose": "^2.2.0" + "cytoscape-fcose": "^2.2.0", + "cytoscape-svg": "^0.4.0" }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^18.19.130", + "@types/node": "^18.7.13", "chalk": "^4.1.0", "copy-webpack-plugin": "^11.0.0", "fs-extra": "^10.1.0", @@ -2188,6 +2189,15 @@ "cytoscape": "^3.2.0" } }, + "node_modules/cytoscape-svg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cytoscape-svg/-/cytoscape-svg-0.4.0.tgz", + "integrity": "sha512-omqIzfPd1Vy9mk6lHTiR2wTbjxELxb9GXSQ2pE6W+GwAe/6/yvOUQ2h5ApFf2QhCBnpMwLkCTq5DZXxBCgUpDw==", + "license": "GNU GPLv3", + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 2331564..a9893d3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ ], "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^18.19.130", + "@types/node": "^18.7.13", "chalk": "^4.1.0", "copy-webpack-plugin": "^11.0.0", "fs-extra": "^10.1.0", @@ -38,6 +38,7 @@ }, "dependencies": { "cytoscape": "^3.34.0", - "cytoscape-fcose": "^2.2.0" + "cytoscape-fcose": "^2.2.0", + "cytoscape-svg": "^0.4.0" } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index af8d9da..d6a99c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,16 @@ import { GraphBuilder } from './services/graph/GraphBuilder'; const SHOW_NOTE_GRAPH_COMMAND = 'showNoteGraph'; const SHOW_NOTE_GRAPH_MENU_ITEM = 'showNoteGraphMenuItem'; +const SETTINGS_SECTION = 'noteGraphSection'; + +const registerSettings = async (): Promise => { + await joplin.settings.registerSection(SETTINGS_SECTION, { + label: 'Note Graph', + iconName: 'fas fa-project-diagram', + description: 'Uses Joplin\'s built-in AI to discover connections between your notes. Enable AI in Settings → AI.', + }); +}; -/** - * 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 => { const noteRepository = new NoteRepository(); const { notes } = await noteRepository.getAllNotes(); @@ -54,6 +59,7 @@ const registerMenuItems = async (): Promise => { joplin.plugins.register({ onStart: async function () { console.info('Note Graph plugin started.'); + await registerSettings(); await initializeAiNoteGraphPanel(); await registerCommands(); await registerMenuItems(); diff --git a/src/services/embeddings/Orchestrator.test.ts b/src/services/embeddings/Orchestrator.test.ts new file mode 100644 index 0000000..5e0587e --- /dev/null +++ b/src/services/embeddings/Orchestrator.test.ts @@ -0,0 +1,102 @@ +import { EmbeddingOrchestrator } from './Orchestrator'; +import { Note } from '../../data/Types'; + +function makeNote(id: string, title: string, body: string): Note { + return { + id, + parent_id: 'p1', + title, + body, + created_time: 0, + updated_time: 0, + }; +} + +describe('EmbeddingOrchestrator', () => { + let orchestrator: EmbeddingOrchestrator; + + beforeEach(() => { + orchestrator = new EmbeddingOrchestrator(); + }); + + describe('embedNotes', () => { + it('returns empty result for empty notes array', async () => { + const result = await orchestrator.embedNotes([]); + expect(result.embeddedNotes).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it('returns error when no provider is set', async () => { + const result = await orchestrator.embedNotes([makeNote('n1', 'T1', 'Body')]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain('No provider'); + }); + + it('maps fetched vectors to embedded notes', async () => { + const mockVectors = new Map(); + mockVectors.set('n1', [0.1, 0.2, 0.3]); + mockVectors.set('n2', [0.4, 0.5, 0.6]); + + orchestrator.setProvider({ + id: 'joplin-native', + modelName: 'test-model', + fetchVectorsByNoteIds: jest.fn().mockResolvedValue(mockVectors), + }); + + const notes = [makeNote('n1', 'Title 1', 'Body 1'), makeNote('n2', 'Title 2', 'Body 2')]; + const result = await orchestrator.embedNotes(notes); + + expect(result.embeddedNotes).toHaveLength(2); + expect(result.errors).toHaveLength(0); + expect(result.embeddedNotes[0].embedding).toEqual([0.1, 0.2, 0.3]); + expect(result.embeddedNotes[1].embedding).toEqual([0.4, 0.5, 0.6]); + }); + + it('reports errors for notes not found in index', async () => { + const mockVectors = new Map(); + mockVectors.set('n1', [0.1, 0.2, 0.3]); + + orchestrator.setProvider({ + id: 'joplin-native', + modelName: 'test-model', + fetchVectorsByNoteIds: jest.fn().mockResolvedValue(mockVectors), + }); + + const result = await orchestrator.embedNotes([ + makeNote('n1', 'T1', 'B1'), + makeNote('n2', 'T2', 'B2'), + ]); + + expect(result.embeddedNotes).toHaveLength(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].noteId).toBe('n2'); + }); + + it('returns empty when cancelled', async () => { + orchestrator.setProvider({ + id: 'joplin-native', + modelName: 'test-model', + fetchVectorsByNoteIds: jest.fn().mockImplementation(async () => { + orchestrator.cancel(); + return new Map(); + }), + }); + + const result = await orchestrator.embedNotes([makeNote('n1', 'T1', 'B1')]); + expect(result.embeddedNotes).toEqual([]); + }); + + it('catches provider errors and marks all notes', async () => { + orchestrator.setProvider({ + id: 'joplin-native', + modelName: 'test-model', + fetchVectorsByNoteIds: jest.fn().mockRejectedValue(new Error('API failure')), + }); + + const result = await orchestrator.embedNotes([makeNote('n1', 'T1', 'B1')]); + expect(result.embeddedNotes).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toBe('API failure'); + }); + }); +}); diff --git a/src/services/embeddings/Orchestrator.ts b/src/services/embeddings/Orchestrator.ts new file mode 100644 index 0000000..64332e3 --- /dev/null +++ b/src/services/embeddings/Orchestrator.ts @@ -0,0 +1,72 @@ +import { Note } from '../../data/Types'; +import { + EmbeddingProvider, + EmbeddedNote, + EmbeddingResult, + BatchProgress, +} from './Types'; + +export class EmbeddingOrchestrator { + private provider: EmbeddingProvider | null = null; + private cancelled: boolean = false; + private onProgress: ((progress: BatchProgress) => void) | null = null; + + public setProvider(provider: EmbeddingProvider): void { + this.provider = provider; + } + + public setOnProgress(callback: (progress: BatchProgress) => void): void { + this.onProgress = callback; + } + + public cancel(): void { + this.cancelled = true; + } + + public async embedNotes(notes: Note[]): Promise { + const embeddedNotes: EmbeddedNote[] = []; + const errors: Array<{ noteId: string; error: string }> = []; + + if (!notes || notes.length === 0) { + return { embeddedNotes, errors }; + } + + if (!this.provider) { + return { embeddedNotes, errors: notes.map(function (n) { return { noteId: n.id, error: 'No provider configured' }; }) }; + } + + try { + this.reportProgress(0, notes.length, 'embedding'); + + const noteIds = notes.map(function (n) { return n.id; }); + const vectorsByNoteId = await this.provider.fetchVectorsByNoteIds(noteIds); + + for (let i = 0; i < notes.length; i++) { + if (this.cancelled) break; + const note = notes[i]; + const vector = vectorsByNoteId.get(note.id); + if (vector) { + embeddedNotes.push({ note: note, embedding: vector }); + } else { + errors.push({ noteId: note.id, error: 'Note not yet indexed by Joplin AI. Wait for indexing to complete.' }); + } + this.reportProgress(i + 1, notes.length, 'embedding'); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + for (let n = 0; n < notes.length; n++) { + if (!embeddedNotes.some(function (en) { return en.note.id === notes[n].id; })) { + errors.push({ noteId: notes[n].id, error: msg }); + } + } + } + + return { embeddedNotes, errors }; + } + + private reportProgress(current: number, total: number, phase: BatchProgress['phase']): void { + if (this.onProgress) { + this.onProgress({ current, total, phase }); + } + } +} diff --git a/src/services/embeddings/ProviderResolver.test.ts b/src/services/embeddings/ProviderResolver.test.ts new file mode 100644 index 0000000..9e81a93 --- /dev/null +++ b/src/services/embeddings/ProviderResolver.test.ts @@ -0,0 +1,47 @@ +import { ProviderResolver } from './ProviderResolver'; +import joplin from 'api'; + +describe('ProviderResolver', () => { + describe('resolve', () => { + it('returns JoplinNativeProvider', () => { + const provider = ProviderResolver.resolve(); + expect(provider).toBeDefined(); + expect(provider.id).toBe('joplin-native'); + expect(provider.modelName).toBe('joplin-native'); + }); + }); + + describe('getDefaultConfig', () => { + it('returns joplin-native as default', () => { + const config = ProviderResolver.getDefaultConfig(); + expect(config.id).toBe('joplin-native'); + }); + }); + + describe('resolveWithValidation', () => { + it('throws when joplin.ai is unavailable', async () => { + (joplin as any).ai = undefined; + await expect(ProviderResolver.resolveWithValidation()).rejects.toThrow( + 'joplin.ai is not available' + ); + }); + + it('throws when index is not ready', async () => { + (joplin as any).ai = { + getIndexStatus: jest.fn().mockResolvedValue({ ready: false, state: 'disabled' }), + }; + await expect(ProviderResolver.resolveWithValidation()).rejects.toThrow( + 'Joplin AI index is not ready' + ); + }); + + it('returns provider when index is ready', async () => { + (joplin as any).ai = { + getIndexStatus: jest.fn().mockResolvedValue({ ready: true, modelId: 'test-model' }), + }; + const provider = await ProviderResolver.resolveWithValidation(); + expect(provider.id).toBe('joplin-native'); + expect(provider.modelName).toBe('test-model'); + }); + }); +}); diff --git a/src/services/embeddings/ProviderResolver.ts b/src/services/embeddings/ProviderResolver.ts new file mode 100644 index 0000000..24ae35d --- /dev/null +++ b/src/services/embeddings/ProviderResolver.ts @@ -0,0 +1,26 @@ +import joplin from 'api'; +import { EmbeddingProvider, ProviderConfig } from './Types'; +import { JoplinNativeProvider } from './Providers/JoplinNativeProvider'; + +export class ProviderResolver { + + public static resolve(): EmbeddingProvider { + return new JoplinNativeProvider(); + } + + public static async resolveWithValidation(): Promise { + const joplinAi = joplin.ai as any; + if (!joplinAi || typeof joplinAi.getIndexStatus !== 'function') { + throw new Error('joplin.ai is not available. Enable AI in Settings → AI. Requires Joplin v3.7+.'); + } + const status = await joplinAi.getIndexStatus(); + if (!status || !status.ready) { + throw new Error('Joplin AI index is not ready. Enable AI and the embedding index in Settings → AI.'); + } + return new JoplinNativeProvider(status.modelId ?? 'joplin-native', 0); + } + + public static getDefaultConfig(): ProviderConfig { + return { id: 'joplin-native' }; + } +} diff --git a/src/services/embeddings/Providers/JoplinNativeProvider.ts b/src/services/embeddings/Providers/JoplinNativeProvider.ts new file mode 100644 index 0000000..90e15b3 --- /dev/null +++ b/src/services/embeddings/Providers/JoplinNativeProvider.ts @@ -0,0 +1,116 @@ +import joplin from 'api'; +import { EmbeddingProvider, ProviderId } from '../Types'; + +export class JoplinNativeProvider implements EmbeddingProvider { + public readonly id: ProviderId = 'joplin-native'; + + private _modelName: string; + private _dimension: number; + private cachedVectors: Map | null = null; + private fetchedModelId: string | null = null; + + public constructor(modelName: string = 'joplin-native', dimension: number = 0) { + this._modelName = modelName; + this._dimension = dimension; + } + + public get modelName(): string { + return this._modelName; + } + + public async fetchVectorsByNoteIds(noteIds: string[]): Promise> { + const joplinAi = joplin.ai as any; + if (!joplinAi || typeof joplinAi.getEmbeddings !== 'function') { + throw new Error('joplin.ai.getEmbeddings is not available. Enable AI in Settings → AI.'); + } + + const status = await joplinAi.getIndexStatus(); + if (!status || !status.ready) { + throw new Error('Joplin AI index is not ready. Wait for indexing to complete or enable AI in Settings → AI.'); + } + + const statusModelId = status.modelId ?? null; + this.fetchedModelId = statusModelId; + this._modelName = statusModelId ?? 'joplin-native'; + + const allChunks: { noteId: string; chunkIndex: number; chunkText: string; vector: number[] }[] = []; + let cursor: string | undefined; + let modelChangeRetries = 0; + const MAX_MODEL_CHANGE_RETRIES = 3; + + do { + const page = await joplinAi.getEmbeddings({ + noteIds: noteIds.length > 0 ? noteIds : undefined, + cursor: cursor, + limit: 1000, + }); + + if (this.fetchedModelId && page.modelId !== this.fetchedModelId) { + modelChangeRetries++; + if (modelChangeRetries > MAX_MODEL_CHANGE_RETRIES) { + throw new Error('Model changed too many times during pagination.'); + } + allChunks.length = 0; + cursor = undefined; + this.fetchedModelId = page.modelId; + this._modelName = page.modelId; + continue; + } + + if (this._dimension === 0 && page.dimension > 0) { + this._dimension = page.dimension; + } + + allChunks.push(...page.chunks); + cursor = page.nextCursor; + } while (cursor); + + const grouped = new Map(); + for (const chunk of allChunks) { + if (!grouped.has(chunk.noteId)) { + grouped.set(chunk.noteId, []); + } + grouped.get(chunk.noteId)!.push(chunk.vector); + } + + const result = new Map(); + for (const [noteId, vectors] of grouped) { + if (vectors.length === 0) continue; + + const dim = vectors[0].length; + const pooled = new Array(dim).fill(0); + for (const vec of vectors) { + for (let i = 0; i < dim; i++) { + pooled[i] += vec[i]; + } + } + for (let i = 0; i < dim; i++) { + pooled[i] /= vectors.length; + } + + let norm = 0; + for (let i = 0; i < dim; i++) { + norm += pooled[i] * pooled[i]; + } + norm = Math.sqrt(norm); + if (norm > 0) { + for (let i = 0; i < dim; i++) { + pooled[i] /= norm; + } + } + + result.set(noteId, pooled); + } + + this.cachedVectors = result; + return result; + } + + public getCachedVectors(): Map | null { + return this.cachedVectors; + } + + public getFetchedModelId(): string | null { + return this.fetchedModelId; + } +} diff --git a/src/services/embeddings/Types.ts b/src/services/embeddings/Types.ts new file mode 100644 index 0000000..bb98018 --- /dev/null +++ b/src/services/embeddings/Types.ts @@ -0,0 +1,31 @@ +import { Note } from '../../data/Types'; + +export type ProviderId = 'joplin-native'; + +export interface EmbeddingProvider { + readonly id: ProviderId; + readonly modelName: string; + fetchVectorsByNoteIds(noteIds: string[]): Promise>; + getCachedVectors?(): Map | null; + getFetchedModelId?(): string | null; +} + +export interface ProviderConfig { + id: ProviderId; +} + +export interface EmbeddedNote { + note: Note; + embedding: number[]; +} + +export interface BatchProgress { + current: number; + total: number; + phase: 'preprocessing' | 'embedding'; +} + +export interface EmbeddingResult { + embeddedNotes: EmbeddedNote[]; + errors: Array<{ noteId: string; error: string }>; +} diff --git a/src/services/graph/GraphBuilder.ts b/src/services/graph/GraphBuilder.ts index 1921cfa..7c8c991 100644 --- a/src/services/graph/GraphBuilder.ts +++ b/src/services/graph/GraphBuilder.ts @@ -9,11 +9,6 @@ export class GraphBuilder { this.edgeFactory = edgeFactory; } - /** - * Builds a graph from enriched notes, creating nodes and edges from links and shared tags. - * @param notes - notes with `links` and `tags` already populated. - * @returns graph data ready for rendering (nodes and edges). - */ public build(notes: Note[]): GraphData { const degreeMap = new Map(); for (const note of notes) { diff --git a/src/tests/mocks/joplin.ts b/src/tests/mocks/joplin.ts index 40ce356..0b6e5d4 100644 --- a/src/tests/mocks/joplin.ts +++ b/src/tests/mocks/joplin.ts @@ -1,9 +1,15 @@ -const data = { - get: jest.fn(), +const joplinAi = { + getIndexStatus: jest.fn(), + getEmbeddings: jest.fn(), + search: jest.fn(), + chat: jest.fn(), }; const joplin = { - data, + data: { + get: jest.fn(), + }, + ai: joplinAi, }; export default joplin; diff --git a/src/ui/graph-view.js b/src/ui/graph-view.js index f5ddfa7..ac6e14c 100644 --- a/src/ui/graph-view.js +++ b/src/ui/graph-view.js @@ -1,7 +1,9 @@ import cytoscape from 'cytoscape'; import fcose from 'cytoscape-fcose'; +import svg from 'cytoscape-svg'; cytoscape.use(fcose); +cytoscape.use(svg); var FCOSE_OPTIONS = { name: 'fcose', @@ -202,12 +204,7 @@ function renderGraph(message) { cy.layout(FCOSE_OPTIONS).run(); - var edgeCount = (message.edges || []).length; - if (edgeCount === 0) { - showStatus(message.nodes.length + ' notes, 0 connections'); - } else { - hideStatus(); - } + hideStatus(); } /** Write counts into the stats bar elements (stat-notes, stat-explicit, stat-semantic, stat-tags). */ @@ -225,7 +222,7 @@ 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) { @@ -248,6 +245,10 @@ function createExportMenu(btn) { 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') { + var svgString = cy.svg({ full: true, bg: bg }); + 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' }); downloadFile(URL.createObjectURL(blob), 'note-graph.json'); @@ -506,7 +507,10 @@ function init() { if (typeof webviewApi !== 'undefined') { webviewApi.onMessage(function (message) { if (message && message.type === 'graph-data') { - clearInterval(pollTimer); + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } renderGraph(message); } if (message && message.type === 'fit-to-screen') { diff --git a/src/ui/setup.js b/src/ui/setup.js index 50d9743..fec1b4b 100644 --- a/src/ui/setup.js +++ b/src/ui/setup.js @@ -7,10 +7,14 @@ }); }; + const bindAll = () => { + bindClose(); + }; + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', bindClose); + document.addEventListener('DOMContentLoaded', bindAll); return; } - bindClose(); + bindAll(); })(); diff --git a/src/ui/styles/panel.css b/src/ui/styles/panel.css index 89cc1c3..0e7b351 100644 --- a/src/ui/styles/panel.css +++ b/src/ui/styles/panel.css @@ -476,4 +476,198 @@ body { .export-menu__item svg { flex-shrink: 0; +} + +/* Settings modal */ + +.settings-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 2000; + align-items: center; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +.settings-overlay--visible { + display: flex; +} + +.settings-modal { + background: var(--joplin-background-color); + border-radius: 12px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22); + width: 520px; + max-height: 82vh; + overflow-y: auto; + padding: 0; +} + +.settings-modal__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: rgba(128, 128, 128, 0.05); + border-bottom: 1px solid rgba(128, 128, 128, 0.12); +} + +.settings-modal__title { + font-size: 15px; + font-weight: 700; + color: var(--joplin-color); + letter-spacing: -0.01em; +} + +.settings-modal__close { + background: transparent; + border: none; + border-radius: 5px; + color: var(--joplin-color-faded, #999); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} + +.settings-modal__close:hover { + color: var(--joplin-color); + background: rgba(128, 128, 128, 0.10); +} + +.settings-modal__close svg { + width: 15px; + height: 15px; +} + +.settings-modal__body { + padding: 8px 20px 2px; +} + +.settings-modal__group { + padding: 10px 0 0; +} + +.settings-modal__group:first-child { + padding-top: 0; +} + +.settings-modal__group-head { + font-size: 10px; + font-weight: 600; + color: rgba(128, 128, 128, 0.50); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 6px; +} + +.settings-modal__group-divider { + height: 1px; + background: rgba(128, 128, 128, 0.16); + margin-bottom: 10px; +} + +.settings-modal__group-subtitle { + font-size: 9px; + font-weight: 400; + color: rgba(128, 128, 128, 0.50); + text-transform: none; + letter-spacing: 0.02em; + margin-left: 4px; +} + +.settings-modal__row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 8px 0; +} + +.settings-modal__row + .settings-modal__row { + border-top: 1px solid rgba(128, 128, 128, 0.06); +} + +.settings-modal__row-info { + flex: 1 1 auto; + min-width: 0; +} + +.settings-modal__row-label { + font-size: 11px; + font-weight: 600; + color: var(--joplin-color); +} + +.settings-modal__row-desc { + font-size: 10px; + color: var(--joplin-color-faded, #999); + margin-top: 3px; + line-height: 1.3; +} + +.settings-modal__input, +.settings-modal__select { + background: var(--joplin-background-color3, rgba(128, 128, 128, 0.04)); + border: 1px solid rgba(128, 128, 128, 0.15); + border-radius: 5px; + color: var(--joplin-color); + font-size: 11px; + font-family: inherit; + padding: 5px 8px; + outline: none; + flex-shrink: 0; + width: 195px; + box-sizing: border-box; +} + +.settings-modal__input::placeholder { + color: var(--joplin-color-faded, #888); +} + +.settings-modal__input:focus, +.settings-modal__select:focus { + border-color: rgba(59, 130, 246, 0.35); + background: var(--joplin-background-color3, rgba(128, 128, 128, 0.06)); +} + +.settings-modal__actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 10px 20px 12px; + border-top: 1px solid rgba(128, 128, 128, 0.10); +} + +.settings-modal__btn { + padding: 6px 14px; + border-radius: 5px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, color 0.15s; + border: none; +} + +.settings-modal__btn--primary { + background: #3b82f6; + color: #fff; +} + +.settings-modal__btn--primary:hover { + background: #2563eb; +} + +.settings-modal__btn--secondary { + background: transparent; + color: var(--joplin-color-faded); +} + +.settings-modal__btn--secondary:hover { + color: var(--joplin-color); } \ No newline at end of file diff --git a/src/ui/webview.ts b/src/ui/webview.ts index 25b72a3..fc735c7 100644 --- a/src/ui/webview.ts +++ b/src/ui/webview.ts @@ -52,9 +52,6 @@ const getPanel = (): ViewHandle => { return panelHandle; }; -/** - * Initializes the note graph panel. Safe to call multiple times (no-op after first). - */ export const initializeAiNoteGraphPanel = async (): Promise => { if (panelHandle) { return; @@ -62,19 +59,11 @@ export const initializeAiNoteGraphPanel = async (): Promise => { panelHandle = await createPanel(); }; -/** - * Shows the note graph panel in the Joplin UI. - */ export const showAiNoteGraphPanel = async (): Promise => { const handle = getPanel(); await joplin.views.panels.show(handle); }; -/** - * 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. - * @param graphData - the graph nodes and edges to display. - */ export const postGraphData = async (graphData: GraphData): Promise => { const hadData = currentGraphData !== null; currentGraphData = graphData; diff --git a/tsconfig.json b/tsconfig.json index 2120989..a04ae91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,6 @@ "baseUrl": ".", "ignoreDeprecations": "6.0", "types": ["jest", "node"] - } + }, + "include": ["src"] } From 7872ce27d20f853db7870a4af8d777e710582707 Mon Sep 17 00:00:00 2001 From: Yugal Kaushik Date: Thu, 9 Jul 2026 19:16:39 +0530 Subject: [PATCH 2/5] ANG-007: fixed ai suggestions --- src/services/embeddings/Orchestrator.ts | 1 + src/services/embeddings/Providers/JoplinNativeProvider.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/services/embeddings/Orchestrator.ts b/src/services/embeddings/Orchestrator.ts index 64332e3..33be4f4 100644 --- a/src/services/embeddings/Orchestrator.ts +++ b/src/services/embeddings/Orchestrator.ts @@ -13,6 +13,7 @@ export class EmbeddingOrchestrator { public setProvider(provider: EmbeddingProvider): void { this.provider = provider; + this.cancelled = false; } public setOnProgress(callback: (progress: BatchProgress) => void): void { diff --git a/src/services/embeddings/Providers/JoplinNativeProvider.ts b/src/services/embeddings/Providers/JoplinNativeProvider.ts index 90e15b3..e26e880 100644 --- a/src/services/embeddings/Providers/JoplinNativeProvider.ts +++ b/src/services/embeddings/Providers/JoplinNativeProvider.ts @@ -23,6 +23,9 @@ export class JoplinNativeProvider implements EmbeddingProvider { if (!joplinAi || typeof joplinAi.getEmbeddings !== 'function') { throw new Error('joplin.ai.getEmbeddings is not available. Enable AI in Settings → AI.'); } + if (typeof joplinAi.getIndexStatus !== 'function') { + throw new Error('joplin.ai.getIndexStatus is not available. Enable AI in Settings → AI.'); + } const status = await joplinAi.getIndexStatus(); if (!status || !status.ready) { @@ -52,8 +55,8 @@ export class JoplinNativeProvider implements EmbeddingProvider { } allChunks.length = 0; cursor = undefined; - this.fetchedModelId = page.modelId; - this._modelName = page.modelId; + this.fetchedModelId = page.modelId ?? null; + this._modelName = page.modelId ?? this._modelName; continue; } From 3df50259e4a76b6fb4c4c6dedebb1bc5b631e96d Mon Sep 17 00:00:00 2001 From: Yugal Kaushik Date: Thu, 9 Jul 2026 19:44:52 +0530 Subject: [PATCH 3/5] ANG-007: bug fix, added doc strings & few more tests --- src/services/embeddings/Orchestrator.test.ts | 29 +++++ src/services/embeddings/Orchestrator.ts | 4 + .../embeddings/ProviderResolver.test.ts | 8 ++ src/services/embeddings/ProviderResolver.ts | 4 + .../Providers/JoplinNativeProvider.test.ts | 120 ++++++++++++++++++ .../Providers/JoplinNativeProvider.ts | 84 ++++++++---- src/services/similarity/EdgeFactory.test.ts | 23 ++++ 7 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 src/services/embeddings/Providers/JoplinNativeProvider.test.ts diff --git a/src/services/embeddings/Orchestrator.test.ts b/src/services/embeddings/Orchestrator.test.ts index 5e0587e..a8652b0 100644 --- a/src/services/embeddings/Orchestrator.test.ts +++ b/src/services/embeddings/Orchestrator.test.ts @@ -1,4 +1,5 @@ import { EmbeddingOrchestrator } from './Orchestrator'; +import { BatchProgress } from './Types'; import { Note } from '../../data/Types'; function makeNote(id: string, title: string, body: string): Note { @@ -72,6 +73,34 @@ describe('EmbeddingOrchestrator', () => { expect(result.errors[0].noteId).toBe('n2'); }); + it('emits progress updates while embedding', async () => { + const progressUpdates: BatchProgress[] = []; + orchestrator.setOnProgress((progress) => { + progressUpdates.push(progress); + }); + + const mockVectors = new Map(); + mockVectors.set('n1', [0.1, 0.2, 0.3]); + mockVectors.set('n2', [0.4, 0.5, 0.6]); + + orchestrator.setProvider({ + id: 'joplin-native', + modelName: 'test-model', + fetchVectorsByNoteIds: jest.fn().mockResolvedValue(mockVectors), + }); + + await orchestrator.embedNotes([ + makeNote('n1', 'T1', 'B1'), + makeNote('n2', 'T2', 'B2'), + ]); + + expect(progressUpdates).toEqual([ + { current: 0, total: 2, phase: 'embedding' }, + { current: 1, total: 2, phase: 'embedding' }, + { current: 2, total: 2, phase: 'embedding' }, + ]); + }); + it('returns empty when cancelled', async () => { orchestrator.setProvider({ id: 'joplin-native', diff --git a/src/services/embeddings/Orchestrator.ts b/src/services/embeddings/Orchestrator.ts index 33be4f4..64e5661 100644 --- a/src/services/embeddings/Orchestrator.ts +++ b/src/services/embeddings/Orchestrator.ts @@ -24,6 +24,10 @@ export class EmbeddingOrchestrator { this.cancelled = true; } + /** + * Embeds the provided notes, preserving note order and reporting missing + * embeddings as per-note errors. + */ public async embedNotes(notes: Note[]): Promise { const embeddedNotes: EmbeddedNote[] = []; const errors: Array<{ noteId: string; error: string }> = []; diff --git a/src/services/embeddings/ProviderResolver.test.ts b/src/services/embeddings/ProviderResolver.test.ts index 9e81a93..242af28 100644 --- a/src/services/embeddings/ProviderResolver.test.ts +++ b/src/services/embeddings/ProviderResolver.test.ts @@ -43,5 +43,13 @@ describe('ProviderResolver', () => { expect(provider.id).toBe('joplin-native'); expect(provider.modelName).toBe('test-model'); }); + + it('uses the default native model when index status omits modelId', async () => { + (joplin as any).ai = { + getIndexStatus: jest.fn().mockResolvedValue({ ready: true }), + }; + const provider = await ProviderResolver.resolveWithValidation(); + expect(provider.modelName).toBe('joplin-native'); + }); }); }); diff --git a/src/services/embeddings/ProviderResolver.ts b/src/services/embeddings/ProviderResolver.ts index 24ae35d..76e39a6 100644 --- a/src/services/embeddings/ProviderResolver.ts +++ b/src/services/embeddings/ProviderResolver.ts @@ -8,6 +8,10 @@ export class ProviderResolver { return new JoplinNativeProvider(); } + /** + * Resolves the native embedding provider after verifying that Joplin AI is + * available and its embedding index is ready. + */ public static async resolveWithValidation(): Promise { const joplinAi = joplin.ai as any; if (!joplinAi || typeof joplinAi.getIndexStatus !== 'function') { diff --git a/src/services/embeddings/Providers/JoplinNativeProvider.test.ts b/src/services/embeddings/Providers/JoplinNativeProvider.test.ts new file mode 100644 index 0000000..63130a2 --- /dev/null +++ b/src/services/embeddings/Providers/JoplinNativeProvider.test.ts @@ -0,0 +1,120 @@ +import joplin from 'api'; +import { JoplinNativeProvider } from './JoplinNativeProvider'; + +describe('JoplinNativeProvider', () => { + it('returns an empty map without touching AI when no note ids are requested', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockResolvedValue({ ready: true, modelId: 'test-model' }); + ai.getEmbeddings.mockResolvedValue({ + modelId: 'test-model', + dimension: 3, + chunks: [], + }); + + const vectors = await provider.fetchVectorsByNoteIds([]); + + expect(vectors.size).toBe(0); + expect(ai.getIndexStatus).not.toHaveBeenCalled(); + expect(ai.getEmbeddings).not.toHaveBeenCalled(); + }); + + it('clears stale cached state before starting a new fetch', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockResolvedValue({ ready: true, modelId: 'fresh-model' }); + ai.getEmbeddings.mockResolvedValue({ + modelId: 'fresh-model', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [1, 0] }], + nextCursor: undefined, + }); + + await provider.fetchVectorsByNoteIds(['n1']); + expect(provider.getFetchedModelId()).toBe('fresh-model'); + expect(provider.getCachedVectors()).toEqual(new Map([['n1', [1, 0]]])); + + ai.getEmbeddings.mockRejectedValue(new Error('network error')); + + await expect(provider.fetchVectorsByNoteIds(['n2'])).rejects.toThrow('network error'); + expect(provider.getFetchedModelId()).toBeNull(); + expect(provider.getCachedVectors()).toBeNull(); + }); + + it('pools vectors across pages and normalizes the result', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockResolvedValue({ ready: true, modelId: 'test-model' }); + ai.getEmbeddings + .mockResolvedValueOnce({ + modelId: 'test-model', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [3, 0] }], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + modelId: 'test-model', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [0, 4] }], + nextCursor: undefined, + }); + + const vectors = await provider.fetchVectorsByNoteIds(['n1']); + const vector = vectors.get('n1'); + + expect(ai.getEmbeddings).toHaveBeenCalledTimes(2); + expect(vector).toBeDefined(); + expect(vector![0]).toBeCloseTo(0.6, 5); + expect(vector![1]).toBeCloseTo(0.8, 5); + expect(provider.getFetchedModelId()).toBe('test-model'); + }); + + it('restarts pagination when the model changes mid-fetch', async () => { + const provider = new JoplinNativeProvider(); + const ai = joplin.ai as unknown as { + getIndexStatus: jest.Mock; + getEmbeddings: jest.Mock; + }; + + ai.getIndexStatus.mockResolvedValue({ ready: true, modelId: 'model-a' }); + ai.getEmbeddings + .mockResolvedValueOnce({ + modelId: 'model-a', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [1, 0] }], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + modelId: 'model-b', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [0, 1] }], + nextCursor: undefined, + }) + .mockResolvedValueOnce({ + modelId: 'model-b', + dimension: 2, + chunks: [{ noteId: 'n1', vector: [0, 1] }], + nextCursor: undefined, + }); + + const vectors = await provider.fetchVectorsByNoteIds(['n1']); + const vector = vectors.get('n1'); + + expect(ai.getEmbeddings).toHaveBeenCalledTimes(3); + expect(vector).toEqual([0, 1]); + expect(provider.getFetchedModelId()).toBe('model-b'); + expect(provider.modelName).toBe('model-b'); + }); +}); diff --git a/src/services/embeddings/Providers/JoplinNativeProvider.ts b/src/services/embeddings/Providers/JoplinNativeProvider.ts index e26e880..b928c14 100644 --- a/src/services/embeddings/Providers/JoplinNativeProvider.ts +++ b/src/services/embeddings/Providers/JoplinNativeProvider.ts @@ -1,6 +1,20 @@ import joplin from 'api'; import { EmbeddingProvider, ProviderId } from '../Types'; +interface JoplinAiApi { + getIndexStatus: () => Promise<{ ready: boolean; modelId?: string | null }>; + getEmbeddings: (params: { + noteIds?: string[]; + cursor?: string; + limit: number; + }) => Promise<{ + modelId?: string | null; + dimension: number; + chunks: Array<{ noteId: string; vector: number[] }>; + nextCursor?: string; + }>; +} + export class JoplinNativeProvider implements EmbeddingProvider { public readonly id: ProviderId = 'joplin-native'; @@ -18,8 +32,19 @@ export class JoplinNativeProvider implements EmbeddingProvider { return this._modelName; } + /** + * Fetches embeddings for the requested note IDs and pools repeated chunks + * into one normalized vector per note. + */ public async fetchVectorsByNoteIds(noteIds: string[]): Promise> { - const joplinAi = joplin.ai as any; + if (noteIds.length === 0) { + return new Map(); + } + + this.cachedVectors = null; + this.fetchedModelId = null; + + const joplinAi = joplin.ai as unknown as JoplinAiApi | undefined; if (!joplinAi || typeof joplinAi.getEmbeddings !== 'function') { throw new Error('joplin.ai.getEmbeddings is not available. Enable AI in Settings → AI.'); } @@ -32,50 +57,61 @@ export class JoplinNativeProvider implements EmbeddingProvider { throw new Error('Joplin AI index is not ready. Wait for indexing to complete or enable AI in Settings → AI.'); } - const statusModelId = status.modelId ?? null; - this.fetchedModelId = statusModelId; - this._modelName = statusModelId ?? 'joplin-native'; + let trackedModelId: string | null = status.modelId ?? null; + this._modelName = trackedModelId ?? 'joplin-native'; - const allChunks: { noteId: string; chunkIndex: number; chunkText: string; vector: number[] }[] = []; + const grouped = new Map(); let cursor: string | undefined; let modelChangeRetries = 0; const MAX_MODEL_CHANGE_RETRIES = 3; - do { + while (true) { const page = await joplinAi.getEmbeddings({ - noteIds: noteIds.length > 0 ? noteIds : undefined, + noteIds: noteIds, cursor: cursor, limit: 1000, }); - if (this.fetchedModelId && page.modelId !== this.fetchedModelId) { - modelChangeRetries++; - if (modelChangeRetries > MAX_MODEL_CHANGE_RETRIES) { - throw new Error('Model changed too many times during pagination.'); + const pageModelId = page.modelId ?? null; + + if (pageModelId) { + if (!trackedModelId) { + trackedModelId = pageModelId; + this._modelName = pageModelId; + } else if (pageModelId !== trackedModelId) { + modelChangeRetries++; + if (modelChangeRetries > MAX_MODEL_CHANGE_RETRIES) { + throw new Error('Model changed too many times during pagination.'); + } + trackedModelId = pageModelId; + this._modelName = pageModelId; + grouped.clear(); + cursor = undefined; + continue; } - allChunks.length = 0; - cursor = undefined; - this.fetchedModelId = page.modelId ?? null; - this._modelName = page.modelId ?? this._modelName; - continue; } if (this._dimension === 0 && page.dimension > 0) { this._dimension = page.dimension; } - allChunks.push(...page.chunks); - cursor = page.nextCursor; - } while (cursor); + for (const chunk of page.chunks) { + const list = grouped.get(chunk.noteId); + if (list) { + list.push(chunk.vector); + } else { + grouped.set(chunk.noteId, [chunk.vector]); + } + } - const grouped = new Map(); - for (const chunk of allChunks) { - if (!grouped.has(chunk.noteId)) { - grouped.set(chunk.noteId, []); + cursor = page.nextCursor; + if (!cursor) { + break; } - grouped.get(chunk.noteId)!.push(chunk.vector); } + this.fetchedModelId = trackedModelId; + const result = new Map(); for (const [noteId, vectors] of grouped) { if (vectors.length === 0) continue; diff --git a/src/services/similarity/EdgeFactory.test.ts b/src/services/similarity/EdgeFactory.test.ts index 90c1924..e8498df 100644 --- a/src/services/similarity/EdgeFactory.test.ts +++ b/src/services/similarity/EdgeFactory.test.ts @@ -34,6 +34,21 @@ describe('EdgeFactory', () => { expect(factory.createEdges([note('a', 'A'), note('b', 'B')])).toEqual([]); }); + it('handles missing links and tags arrays', () => { + const edges = factory.createEdges([ + { + id: 'a', + parent_id: 'p1', + title: 'A', + body: '', + created_time: 0, + updated_time: 1, + } as Note, + ]); + + expect(edges).toEqual([]); + }); + it('ignores resource links that are not note IDs', () => { const notes = [ note('a', 'A', ['resource123']), @@ -101,4 +116,12 @@ describe('EdgeFactory', () => { it('ignores self-referencing links', () => { expect(factory.createEdges([note('a', 'A', ['a'])])).toEqual([]); }); + + it('skips tags that connect too many notes', () => { + const notes = Array.from({ length: 21 }, (_, i) => + note(`n${i}`, `N${i}`, [], ['common']) + ); + + expect(factory.createEdges(notes)).toEqual([]); + }); }); From 731889fe075adfa6fa301c6b3799afe15d870955 Mon Sep 17 00:00:00 2001 From: Yugal Kaushik Date: Thu, 9 Jul 2026 22:05:02 +0530 Subject: [PATCH 4/5] ANG-007:add docstrings --- src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d6a99c0..98024b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,10 @@ const registerSettings = async (): Promise => { description: 'Uses Joplin\'s built-in AI to discover connections between your notes. Enable AI in Settings → AI.', }); }; - +/** + * 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 => { const noteRepository = new NoteRepository(); const { notes } = await noteRepository.getAllNotes(); From e43e39095123cd1989ce40aa9b924d556c1b9f00 Mon Sep 17 00:00:00 2001 From: Yugal Kaushik Date: Thu, 9 Jul 2026 22:14:08 +0530 Subject: [PATCH 5/5] ANG-007:remove settings section --- src/index.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 98024b8..af8d9da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,15 +8,7 @@ import { GraphBuilder } from './services/graph/GraphBuilder'; const SHOW_NOTE_GRAPH_COMMAND = 'showNoteGraph'; const SHOW_NOTE_GRAPH_MENU_ITEM = 'showNoteGraphMenuItem'; -const SETTINGS_SECTION = 'noteGraphSection'; -const registerSettings = async (): Promise => { - await joplin.settings.registerSection(SETTINGS_SECTION, { - label: 'Note Graph', - iconName: 'fas fa-project-diagram', - description: 'Uses Joplin\'s built-in AI to discover connections between your notes. Enable AI in Settings → AI.', - }); -}; /** * Loads all notes from the Joplin API and enriches them with links and tags. * @returns enriched notes ready for graph building. @@ -62,7 +54,6 @@ const registerMenuItems = async (): Promise => { joplin.plugins.register({ onStart: async function () { console.info('Note Graph plugin started.'); - await registerSettings(); await initializeAiNoteGraphPanel(); await registerCommands(); await registerMenuItems();