From 2927ea26d7bb5658b853fb26cd589cce0d0f0fb4 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 1 Jun 2026 19:28:53 +1000 Subject: [PATCH] add runtime api query support helpers --- docs/javascript-api.md | 18 +++ docs/releases/unreleased.md | 2 +- src/api/TaskNotesAPI.ts | 177 ++++++++++++++++++++++++ src/api/runtime-api.ts | 111 +++++++++++++++ tests/unit/api/tasknotes-api-v1.test.ts | 98 +++++++++++++ 5 files changed, 405 insertions(+), 1 deletion(-) diff --git a/docs/javascript-api.md b/docs/javascript-api.md index 03097830..28370728 100644 --- a/docs/javascript-api.md +++ b/docs/javascript-api.md @@ -44,6 +44,7 @@ Current capabilities: - `events.list` - `time.read` - `time.write` +- `time.summary` - `pomodoro.read` - `pomodoro.write` - `pomodoro.events` @@ -51,6 +52,10 @@ Current capabilities: - `recurring.events` - `settings.snapshot` - `nlp.parse` +- `query.tasks` +- `query.filter-options` +- `stats.tasks` +- `system.health` ## Namespaces @@ -262,9 +267,22 @@ for (const subtask of relationships.subtasks) { | `api.time.start(path, options?, context?)` | Starts time tracking and returns the updated task. | | `api.time.stop(path, context?)` | Stops the active time entry and returns the updated task. | | `api.time.active()` | Returns active time entries with task, path, entry, and entry index. | +| `api.time.summary(options?)` | Returns aggregate time totals, top tasks, projects, and tags. | +| `api.time.task(path)` | Returns one task's time summary and normalized time entries. | | `api.time.append(path, entry, context?)` | Appends a time entry. | | `api.time.deleteEntry(path, entryIndex, context?)` | Deletes a time entry through TaskNotes' service. | +## Query, Stats, And System + +The query, stats, and system namespaces expose HTTP/MCP-style support data without requiring companion plugins to reach into TaskNotes internals. + +| Method | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------- | +| `api.query.tasks(query?)` | Returns tasks plus total, filtered count, and group membership for an optional `FilterQuery`. | +| `api.query.filterOptions()` | Returns available statuses, priorities, contexts, projects, tags, folders, and user properties. | +| `api.stats.tasks(query?)` | Returns task counts, status/priority counts, archive/completion counts, and time-tracking totals. | +| `api.system.health()` | Returns runtime status, API version, capabilities, vault identity, and task count. | + ## Pomodoro | Method | Description | diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 35ed1b8b..8460dc75 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,7 +34,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added -- Added a versioned TaskNotes JavaScript runtime API for companion plugins, with namespaced model validation, catalogs, task, time-tracking, Pomodoro, recurring-task, settings, NLP, event, and extension-registry surfaces. Runtime API mutations carry source and correlation metadata so companion plugins can debug and coordinate workflow runs. See [JavaScript API](https://tasknotes.dev/javascript-api/). +- Added a versioned TaskNotes JavaScript runtime API for companion plugins, with namespaced model validation, catalogs, query/support helpers, task, time-tracking, Pomodoro, recurring-task, settings, NLP, event, and extension-registry surfaces. Runtime API mutations carry source and correlation metadata so companion plugins can debug and coordinate workflow runs. See [JavaScript API](https://tasknotes.dev/javascript-api/). - Added documentation for the companion-plugin model and the TaskNotes Workflows companion plugin. See [Companion Plugins](https://tasknotes.dev/companion-plugins/) and [TaskNotes Workflows](https://tasknotes.dev/companion-plugins/tasknotes-workflows/). - (#288, #345, #361, #523, #573, #703, #925, #929, #1115, #1137, #1260, #1303, #1324, #1394, #1445, #1509, #1735, #1736, #1743, #1780, #1874, #1951, #1974) Added support for TaskNotes spec 0.2.0 materialized occurrences, including recurrence parent/date fields, generated mdbase schema roles, parent reconciliation when occurrence notes are completed, occurrence notes that inherit parent planning metadata without copying history, occurrence note controls in task, calendar, and edit-modal completion menus, and visible occurrence identity on task cards. This gives recurring tasks a concrete occurrence-note path for per-instance state, related notes, subtasks, templates, scheduling changes, completion history, and calendar behavior without forcing every recurring task to create files. See [Recurring Tasks](https://tasknotes.dev/features/recurring-tasks/#materialized-occurrence-notes) and [Property Types Reference](https://tasknotes.dev/settings/property-types-reference/#materialized-occurrence-properties). Thanks to @LuxBetancourt, @luciolebrillante, @jhedlund, @cathywu, @Lorite, @EllenGYY, @JcMinarro, @gsssr, @3zra47, @Leonard-44, @ak-42, @RumiaKitinari, @atos2212-blip, @kmaustral, @eugenedefox, @notDavid, @zitongcharliedeng, and @Jomo94 for the related recurrence, occurrence, completion, and calendar requests. - (#1951) Added Calendar support for recurring tasks stretched between scheduled and due dates when the existing stretch option is enabled. Date-only ranges stay as all-day spans, and timed ranges render once per day in the range. Thanks to @atos2212-blip for the request. diff --git a/src/api/TaskNotesAPI.ts b/src/api/TaskNotesAPI.ts index 5e76966c..2963e9fb 100644 --- a/src/api/TaskNotesAPI.ts +++ b/src/api/TaskNotesAPI.ts @@ -31,6 +31,7 @@ import { import type { TaskNotesSettings } from "../types/settings"; import { ensureFolderExists } from "../utils/helpers"; import { parseLinkToPath } from "../utils/linkUtils"; +import { computeTaskTimeData, computeTimeSummary } from "../utils/timeTrackingUtils"; import { TASKNOTES_RUNTIME_API_CAPABILITIES, TASKNOTES_RUNTIME_EVENT_DEFINITIONS, @@ -45,7 +46,13 @@ import { type TaskNotesRuntimeFieldDefinition, type TaskNotesRuntimeFilterOperatorDefinition, type TaskNotesRuntimeFilterPropertyDefinition, + type TaskNotesRuntimeHealth, type TaskNotesRuntimeRelationshipDefinition, + type TaskNotesRuntimeTaskQueryResult, + type TaskNotesRuntimeTaskStats, + type TaskNotesRuntimeTaskTimeData, + type TaskNotesRuntimeTimeSummary, + type TaskNotesRuntimeTimeSummaryOptions, type TaskNotesApiChanges, type TaskNotesApiEvent, type TaskNotesApiEventHandler, @@ -84,6 +91,11 @@ interface RegisteredRuntimeExtension { token: symbol; } +type VaultAdapterWithPath = { + basePath?: string; + path?: string; +}; + const RESERVED_RUNTIME_EXTENSION_NAMESPACES = new Set([ "apiversion", "capabilities", @@ -530,6 +542,8 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { ) => this.startTime(path, options, context), stop: (path: string, context?: TaskNotesMutationContext) => this.stopTime(path, context), active: () => this.getActiveTimeEntries(), + summary: (options?: TaskNotesRuntimeTimeSummaryOptions) => this.getTimeSummary(options), + task: (path: string) => this.getTaskTimeData(path), append: (path: string, entry: TimeEntry, context?: TaskNotesMutationContext) => this.appendTimeEntry(path, entry, context), deleteEntry: (path: string, entryIndex: number, context?: TaskNotesMutationContext) => @@ -574,6 +588,19 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { parse: (text: string) => this.parseNaturalLanguage(text), }; + readonly query = { + tasks: (query?: FilterQuery) => this.queryTasks(query), + filterOptions: () => this.getFilterOptions(), + }; + + readonly stats = { + tasks: (query?: FilterQuery) => this.getTaskStats(query), + }; + + readonly system = { + health: () => this.getHealth(), + }; + readonly extensions = { register: (extension: TaskNotesRuntimeExtension) => this.registerExtension(extension), @@ -692,6 +719,151 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { })); } + private async queryTasks(query?: FilterQuery): Promise { + const allTasks = await this.plugin.cacheManager.getAllTasks(); + if (!query) { + return { + tasks: allTasks.map(copyTaskInfo), + total: allTasks.length, + filtered: allTasks.length, + groups: { all: allTasks.map((task) => task.path) }, + }; + } + + const groupedTasks = await this.plugin.filterService.getGroupedTasks(query); + const tasks: TaskInfo[] = []; + const groups: Record = {}; + for (const [group, groupTasks] of groupedTasks.entries()) { + tasks.push(...groupTasks); + groups[group] = groupTasks.map((task) => task.path); + } + + return { + tasks: tasks.map(copyTaskInfo), + total: allTasks.length, + filtered: tasks.length, + groups, + }; + } + + private async getFilterOptions() { + return this.plugin.filterService.getFilterOptions(); + } + + private async getTaskStats(query?: FilterQuery): Promise { + const tasks = query + ? (await this.queryTasks(query)).tasks + : await this.plugin.cacheManager.getAllTasks(); + const stats = this.plugin.taskStatsService?.getStats(tasks) ?? this.computeTaskStats(tasks); + return { + total: stats.total, + statusCounts: { ...stats.statusCounts }, + priorityCounts: { ...stats.priorityCounts }, + completed: stats.completed, + active: stats.active, + overdue: stats.overdue, + archived: stats.archived, + withTimeEntries: stats.withTimeEntries, + totalTrackedMinutes: stats.totalTrackedMinutes, + totalTrackedHours: stats.totalTrackedHours, + }; + } + + private async getTimeSummary( + options: TaskNotesRuntimeTimeSummaryOptions = {} + ): Promise { + const allTasks = await this.plugin.cacheManager.getAllTasks(); + return computeTimeSummary( + allTasks, + { + period: options.period ?? "today", + fromDate: coerceDateOption(options.from), + toDate: coerceDateOption(options.to), + includeTags: options.includeTags ?? true, + }, + (status) => this.plugin.statusManager.isCompletedStatus(status) + ); + } + + private async getTaskTimeData(path: string): Promise { + const task = await this.requireTask(path); + return computeTaskTimeData(task, (candidate) => + this.plugin.getActiveTimeSession(candidate) + ); + } + + private async getHealth(): Promise { + const tasks = await this.plugin.cacheManager.getAllTasks(); + return { + status: "ok", + timestamp: new Date().toISOString(), + apiVersion: this.apiVersion, + capabilities: this.capabilities, + vault: this.getVaultInfo(), + tasks: { + total: tasks.length, + }, + }; + } + + private getVaultInfo() { + const adapter = this.plugin.app.vault.adapter as VaultAdapterWithPath; + let vaultPath: string | null = null; + try { + if (typeof adapter.basePath === "string") { + vaultPath = adapter.basePath; + } else if (typeof adapter.path === "string") { + vaultPath = adapter.path; + } + } catch { + vaultPath = null; + } + + return { + name: this.plugin.app.vault.getName(), + path: vaultPath, + }; + } + + private computeTaskStats(tasks: TaskInfo[]): TaskNotesRuntimeTaskStats { + const statusCounts: Record = {}; + const priorityCounts: Record = {}; + let completed = 0; + let active = 0; + let overdue = 0; + let archived = 0; + let withTimeEntries = 0; + let totalTrackedMinutes = 0; + const today = new Date().toISOString().split("T")[0] ?? ""; + + for (const task of tasks) { + statusCounts[task.status] = (statusCounts[task.status] ?? 0) + 1; + priorityCounts[task.priority] = (priorityCounts[task.priority] ?? 0) + 1; + const isCompleted = this.plugin.statusManager.isCompletedStatus(task.status); + if (isCompleted) completed++; + if (task.archived) archived++; + if (!isCompleted && !task.archived) active++; + if (task.due && task.due < today && !isCompleted && !task.archived) overdue++; + if (task.timeEntries?.length) { + withTimeEntries++; + totalTrackedMinutes += task.totalTrackedTime ?? 0; + } + } + + return { + total: tasks.length, + statusCounts, + priorityCounts, + completed, + active, + overdue, + archived, + withTimeEntries, + totalTrackedMinutes, + totalTrackedHours: Math.round((totalTrackedMinutes / 60) * 100) / 100, + }; + } + async getTask(path: string): Promise { const task = await this.plugin.cacheManager.getTaskInfo(this.normalizeTaskPath(path)); return task ? copyTaskInfo(task) : null; @@ -1718,6 +1890,11 @@ function userFieldTypeToRuntimeValueType( } } +function coerceDateOption(value: string | Date | null | undefined): Date | null { + if (!value) return null; + return value instanceof Date ? value : new Date(value); +} + function buildTaskChanges(before?: TaskInfo, after?: TaskInfo): TaskNotesApiChanges { const changes: TaskNotesApiChanges = {}; const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); diff --git a/src/api/runtime-api.ts b/src/api/runtime-api.ts index dfa6413c..26e1760b 100644 --- a/src/api/runtime-api.ts +++ b/src/api/runtime-api.ts @@ -14,6 +14,7 @@ import type { import type { ParsedTaskData } from "../services/NaturalLanguageParser"; import type { FilterQuery, + FilterOptions, PomodoroHistoryStats, PomodoroSessionHistory, PomodoroState, @@ -52,6 +53,7 @@ export const TASKNOTES_RUNTIME_API_CAPABILITIES = [ "events.list", "time.read", "time.write", + "time.summary", "pomodoro.read", "pomodoro.write", "pomodoro.events", @@ -59,6 +61,10 @@ export const TASKNOTES_RUNTIME_API_CAPABILITIES = [ "recurring.events", "settings.snapshot", "nlp.parse", + "query.tasks", + "query.filter-options", + "stats.tasks", + "system.health", ] as const; export type TaskNotesRuntimeApiVersion = typeof TASKNOTES_RUNTIME_API_VERSION; @@ -292,6 +298,57 @@ export interface StartTimeEntryOptions { description?: string; } +export interface TaskNotesRuntimeTimeSummaryOptions { + period?: "today" | "week" | "month" | "all" | "custom" | string; + from?: string | Date | null; + to?: string | Date | null; + includeTags?: boolean; +} + +export interface TaskNotesRuntimeTimeSummary { + period: string; + dateRange: { from: string; to: string }; + summary: { + totalMinutes: number; + totalHours: number; + tasksWithTime: number; + activeTasks: number; + completedTasks: number; + }; + topTasks: Array<{ task: string; title: string; minutes: number }>; + topProjects: Array<{ project: string; minutes: number }>; + topTags?: Array<{ tag: string; minutes: number }>; +} + +export interface TaskNotesRuntimeTaskTimeData { + task: { + id: string; + title: string; + status: string; + priority: string; + }; + summary: { + totalMinutes: number; + totalHours: number; + totalSessions: number; + completedSessions: number; + activeSessions: number; + averageSessionMinutes: number; + }; + activeSession: { + startTime: string; + description?: string; + elapsedMinutes: number; + } | null; + timeEntries: Array<{ + startTime: string; + endTime: string | null; + description: string | null; + duration: number; + isActive: boolean; + }>; +} + export interface PomodoroStartOptions { taskPath?: string; duration?: number; @@ -543,6 +600,8 @@ export interface TaskNotesRuntimeTimeApi { ): Promise; stop(path: string, context?: TaskNotesMutationContext): Promise; active(): Promise; + summary(options?: TaskNotesRuntimeTimeSummaryOptions): Promise; + task(path: string): Promise; append(path: string, entry: TimeEntry, context?: TaskNotesMutationContext): Promise; deleteEntry( path: string, @@ -604,6 +663,55 @@ export interface TaskNotesRuntimeExtensionsApi { capabilities(): readonly string[]; } +export interface TaskNotesRuntimeTaskQueryResult { + tasks: TaskInfo[]; + total: number; + filtered: number; + groups: Record; +} + +export interface TaskNotesRuntimeQueryApi { + tasks(query?: FilterQuery): Promise; + filterOptions(): Promise; +} + +export interface TaskNotesRuntimeTaskStats { + total: number; + statusCounts: Record; + priorityCounts: Record; + completed: number; + active: number; + overdue: number; + archived: number; + withTimeEntries: number; + totalTrackedMinutes: number; + totalTrackedHours: number; +} + +export interface TaskNotesRuntimeStatsApi { + tasks(query?: FilterQuery): Promise; +} + +export interface TaskNotesRuntimeVaultInfo { + name: string; + path: string | null; +} + +export interface TaskNotesRuntimeHealth { + status: "ok"; + timestamp: string; + apiVersion: TaskNotesRuntimeApiVersion; + capabilities: readonly TaskNotesRuntimeApiCapability[]; + vault: TaskNotesRuntimeVaultInfo; + tasks: { + total: number; + }; +} + +export interface TaskNotesRuntimeSystemApi { + health(): Promise; +} + export interface TaskNotesRuntimeApiV1 { readonly apiVersion: TaskNotesRuntimeApiVersion; readonly capabilities: readonly TaskNotesRuntimeApiCapability[]; @@ -619,6 +727,9 @@ export interface TaskNotesRuntimeApiV1 { readonly events: TaskNotesRuntimeEventsApi; readonly settings: TaskNotesRuntimeSettingsApi; readonly nlp: TaskNotesRuntimeNlpApi; + readonly query: TaskNotesRuntimeQueryApi; + readonly stats: TaskNotesRuntimeStatsApi; + readonly system: TaskNotesRuntimeSystemApi; readonly extensions: TaskNotesRuntimeExtensionsApi; parseNaturalLanguage(text: string): ParsedTaskData; diff --git a/tests/unit/api/tasknotes-api-v1.test.ts b/tests/unit/api/tasknotes-api-v1.test.ts index 713968a4..a467c28d 100644 --- a/tests/unit/api/tasknotes-api-v1.test.ts +++ b/tests/unit/api/tasknotes-api-v1.test.ts @@ -55,6 +55,7 @@ interface TestPluginContext { }; filterService: { getGroupedTasks: jest.Mock>, [FilterQuery]>; + getFilterOptions: jest.Mock, []>; }; cacheManager: { getTaskInfo: jest.Mock, [string]>; @@ -76,6 +77,9 @@ interface TestPluginContext { getStatsForDate: jest.Mock, [Date]>; getTodayStats: jest.Mock, []>; }; + taskStatsService: { + getStats: jest.Mock; + }; } function createEmitter(): TestEmitter { @@ -237,6 +241,14 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu const filterService: TestPluginContext["filterService"] = { getGroupedTasks: jest.fn(async () => new Map([["default", Array.from(tasks.values())]])), + getFilterOptions: jest.fn(async () => ({ + statuses: [], + priorities: [], + contexts: [], + projects: [], + tags: [], + folders: [], + })), }; const fileManager: TestPluginContext["fileManager"] = { @@ -311,8 +323,30 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu })), }; + const taskStatsService: TestPluginContext["taskStatsService"] = { + getStats: jest.fn((statsTasks: TaskInfo[]) => ({ + total: statsTasks.length, + statusCounts: { open: statsTasks.filter((task) => task.status === "open").length }, + priorityCounts: { + normal: statsTasks.filter((task) => task.priority === "normal").length, + }, + completed: statsTasks.filter((task) => task.status === "done").length, + active: statsTasks.filter((task) => task.status !== "done" && !task.archived).length, + overdue: 0, + archived: statsTasks.filter((task) => task.archived).length, + withTimeEntries: statsTasks.filter((task) => task.timeEntries?.length).length, + totalTrackedMinutes: statsTasks.reduce( + (total, task) => total + (task.totalTrackedTime ?? 0), + 0 + ), + totalTrackedHours: 0, + })), + }; + const vault = { + getName: jest.fn(() => "Test Vault"), adapter: { + basePath: "/tmp/test-vault", exists: jest.fn(async (path: string) => folders.has(path) || files.has(path)), }, createFolder: jest.fn(async (path: string) => { @@ -330,6 +364,7 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu cacheManager, emitter, filterService, + taskStatsService, settings: { defaultTaskStatus: "open", defaultTaskPriority: "normal", @@ -405,6 +440,9 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu }, taskService, pomodoroService, + getActiveTimeSession: jest.fn( + (task: TaskInfo) => (task.timeEntries ?? []).find((entry) => !entry.endTime) ?? null + ), } as unknown as TaskNotesPlugin; return { @@ -418,6 +456,7 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu cacheManager, fileManager, pomodoroService, + taskStatsService, }; } @@ -433,6 +472,8 @@ describe("TaskNotesApiV1", () => { expect(api.capabilities).toContain("extensions.register"); expect(api.capabilities).toContain("relationships.read"); expect(api.capabilities).toContain("model.validate"); + expect(api.capabilities).toContain("query.tasks"); + expect(api.capabilities).toContain("system.health"); expect(api.hasCapability("tasks.events")).toBe(true); expect(api.hasCapability("missing.capability")).toBe(false); expect(typeof api.model.config).toBe("function"); @@ -622,6 +663,63 @@ describe("TaskNotesApiV1", () => { expect(filterService.getGroupedTasks).toHaveBeenCalledWith(query); }); + it("exposes query, stats, time summary, task time data, and health helpers", async () => { + const task = createTask({ + timeEntries: [ + { + startTime: "2026-05-31T09:00:00.000Z", + endTime: "2026-05-31T09:30:00.000Z", + }, + ], + totalTrackedTime: 30, + }); + const { plugin, filterService, taskStatsService } = createPluginContext([task]); + const api = new TaskNotesAPI(plugin); + const query = { + type: "group", + id: "root", + conjunction: "and", + children: [], + } satisfies FilterQuery; + + await expect(api.query.tasks(query)).resolves.toEqual( + expect.objectContaining({ + total: 1, + filtered: 1, + tasks: [expect.objectContaining({ path: task.path })], + groups: { default: [task.path] }, + }) + ); + await expect(api.query.filterOptions()).resolves.toEqual( + expect.objectContaining({ statuses: [] }) + ); + await expect(api.stats.tasks()).resolves.toEqual( + expect.objectContaining({ total: 1, withTimeEntries: 1 }) + ); + await expect(api.time.summary({ period: "all" })).resolves.toEqual( + expect.objectContaining({ + summary: expect.objectContaining({ totalMinutes: 30 }), + }) + ); + await expect(api.time.task(task.path)).resolves.toEqual( + expect.objectContaining({ + task: expect.objectContaining({ id: task.path }), + summary: expect.objectContaining({ totalMinutes: 30 }), + }) + ); + await expect(api.system.health()).resolves.toEqual( + expect.objectContaining({ + status: "ok", + apiVersion: 1, + vault: { name: "Test Vault", path: "/tmp/test-vault" }, + tasks: { total: 1 }, + }) + ); + + expect(filterService.getFilterOptions).toHaveBeenCalled(); + expect(taskStatsService.getStats).toHaveBeenCalled(); + }); + it("resolves task relationships through projects and dependencies", async () => { const parent = createTask({ title: "Parent",