add canonical runtime query api

This commit is contained in:
callumalpass 2026-06-01 19:59:37 +10:00
parent f0ad9d4fda
commit 302b7e22d3
5 changed files with 1607 additions and 132 deletions

View file

@ -53,6 +53,8 @@ Current capabilities:
- `settings.snapshot`
- `nlp.parse`
- `query.tasks`
- `query.validate`
- `query.explain`
- `query.filter-options`
- `stats.tasks`
- `system.health`
@ -188,8 +190,8 @@ const operators = api.catalog.filterOperators();
| `api.catalog.userFields()` | Returns configured user-defined field mappings. |
| `api.catalog.fields()` | Returns core, computed, and user field metadata with value type, writability, and frontmatter key when available. |
| `api.catalog.writableFields()` | Returns only fields that runtime task mutations may write. |
| `api.catalog.filterProperties()` | Returns TaskNotes filter properties and their supported operators. |
| `api.catalog.filterOperators()` | Returns filter operator metadata. |
| `api.catalog.filterProperties()` | Returns canonical query fields, aliases, value types, and supported operators. |
| `api.catalog.filterOperators()` | Returns canonical query operators, labels, value requirements, and accepted aliases. |
| `api.catalog.relationships()` | Returns relationship categories supported by `api.relationships`. |
| `api.catalog.dependencyRelTypes()` | Returns dependency relationship types accepted by `api.tasks.addDependency`. |
| `api.catalog.events()` | Returns the same runtime event catalogue as `api.events.list()`. |
@ -198,26 +200,26 @@ const operators = api.catalog.filterOperators();
All paths are vault-relative Markdown file paths.
| Method | Description |
| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `api.tasks.get(path)` | Returns a task by path, or `null` when no task is cached at that path. |
| `api.tasks.list(query?)` | Returns all tasks, or tasks matching a TaskNotes `FilterQuery`. |
| `api.tasks.create(taskData, context?)` | Creates a task using the normal TaskNotes creation service. |
| `api.tasks.update(path, patch, context?)` | Updates one or more task fields using the normal TaskNotes update service. |
| `api.tasks.delete(path, context?)` | Deletes the task file through TaskNotes' delete service. |
| `api.tasks.complete(path, options?, context?)` | Marks a task complete. |
| `api.tasks.uncomplete(path, options?, context?)` | Moves a completed task back to a non-completed status. |
| `api.tasks.setStatus(path, status, context?)` | Sets task status. |
| `api.tasks.setPriority(path, priority, context?)` | Sets task priority. |
| `api.tasks.setDue(path, date, context?)` / `clearDue(path, context?)` | Sets or clears due date. |
| `api.tasks.setScheduled(path, date, context?)` / `clearScheduled(path, context?)` | Sets or clears scheduled date. |
| `api.tasks.archive(path, archived, context?)` | Archives or unarchives a task, including archive-folder movement when configured. |
| `api.tasks.move(path, targetFolder, context?)` | Moves the task note and refuses to overwrite an existing file. |
| `api.tasks.addTag/removeTag` | Mutates task tags. |
| `api.tasks.addProject/removeProject` | Mutates project links. |
| `api.tasks.addContext/removeContext` | Mutates contexts. |
| `api.tasks.setReminders/addReminder/removeReminder` | Mutates reminders. |
| `api.tasks.addDependency/removeDependency` | Mutates blocking dependencies stored in `blockedBy`. |
| Method | Description |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `api.tasks.get(path)` | Returns a task by path, or `null` when no task is cached at that path. |
| `api.tasks.list(query?)` | Returns all tasks, or tasks matching a runtime task query. Prefer `api.query.tasks()` when count and grouping metadata matters. |
| `api.tasks.create(taskData, context?)` | Creates a task using the normal TaskNotes creation service. |
| `api.tasks.update(path, patch, context?)` | Updates one or more task fields using the normal TaskNotes update service. |
| `api.tasks.delete(path, context?)` | Deletes the task file through TaskNotes' delete service. |
| `api.tasks.complete(path, options?, context?)` | Marks a task complete. |
| `api.tasks.uncomplete(path, options?, context?)` | Moves a completed task back to a non-completed status. |
| `api.tasks.setStatus(path, status, context?)` | Sets task status. |
| `api.tasks.setPriority(path, priority, context?)` | Sets task priority. |
| `api.tasks.setDue(path, date, context?)` / `clearDue(path, context?)` | Sets or clears due date. |
| `api.tasks.setScheduled(path, date, context?)` / `clearScheduled(path, context?)` | Sets or clears scheduled date. |
| `api.tasks.archive(path, archived, context?)` | Archives or unarchives a task, including archive-folder movement when configured. |
| `api.tasks.move(path, targetFolder, context?)` | Moves the task note and refuses to overwrite an existing file. |
| `api.tasks.addTag/removeTag` | Mutates task tags. |
| `api.tasks.addProject/removeProject` | Mutates project links. |
| `api.tasks.addContext/removeContext` | Mutates contexts. |
| `api.tasks.setReminders/addReminder/removeReminder` | Mutates reminders. |
| `api.tasks.addDependency/removeDependency` | Mutates blocking dependencies stored in `blockedBy`. |
Example:
@ -276,14 +278,51 @@ for (const subtask of relationships.subtasks) {
## 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.
The query, stats, and system namespaces expose HTTP/MCP-style support data without requiring companion plugins to reach into TaskNotes internals. Runtime queries use a stable DTO rather than TaskNotes' internal view state.
| 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. |
```javascript
const result = await api.query.tasks({
where: {
all: [
{ field: "task.status", op: "eq", value: "active" },
{ field: "task.due", op: "lte", value: { fn: "today" } },
],
},
sort: [{ field: "task.due", direction: "asc" }],
group: [{ field: "task.status" }],
limit: 25,
scope: {
includeArchived: false,
folders: ["Tasks"],
},
});
```
Runtime query fields are canonical IDs such as `task.status`, `task.priority`, `task.due`, `task.projects`, `task.isBlocked`, `file.path`, and `user.<id-or-key>`. `api.catalog.filterProperties()` returns the complete queryable field catalog, including aliases such as `status` and `user:<id>`.
Canonical operators are `eq`, `ne`, `contains`, `notContains`, `in`, `notIn`, `exists`, `missing`, `lt`, `lte`, `gt`, `gte`, `isTrue`, and `isFalse`. Legacy operator aliases such as `is`, `is-not`, `is-on-or-before`, and `is-not-empty` are accepted and normalized.
`api.query.tasks()` returns explicit count semantics:
- `total`: tasks in scope before filtering
- `matched`: tasks matching `where` before `offset` and `limit`
- `returned`: tasks returned after `offset` and `limit`
- `tasks`: returned task records
- `groups`: optional group details with task paths
- `query`: the normalized canonical query
- `warnings`: non-fatal normalization notes
| Method | Description |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| `api.query.tasks(query?)` | Validates, normalizes, and executes a runtime task query. |
| `api.query.validate(query)` | Validates a query without executing it. |
| `api.query.normalize(query)` | Returns the canonical normalized query, or throws a typed `invalid_input` API error. |
| `api.query.explain(query)` | Returns validation, normalized query, count, grouping, and applied sort/limit details for dry run. |
| `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. |
TaskNotes compiles this DTO into its internal filter engine. Companion plugins should treat the runtime DTO and the catalog metadata as the public query contract.
## Pomodoro

View file

@ -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, query/support helpers, lifecycle events, typed errors/results, 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, canonical task query validation/normalization/explain helpers, catalogs, query/support helpers, lifecycle events, typed errors/results, 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.

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,6 @@ import type {
} from "@tasknotes/model";
import type { ParsedTaskData } from "../services/NaturalLanguageParser";
import type {
FilterQuery,
FilterOptions,
PomodoroHistoryStats,
PomodoroSessionHistory,
@ -62,6 +61,8 @@ export const TASKNOTES_RUNTIME_API_CAPABILITIES = [
"settings.snapshot",
"nlp.parse",
"query.tasks",
"query.validate",
"query.explain",
"query.filter-options",
"stats.tasks",
"system.health",
@ -463,7 +464,7 @@ export interface StartTimeEntryOptions {
}
export interface TaskNotesRuntimeTimeSummaryOptions {
period?: "today" | "week" | "month" | "all" | "custom" | string;
period?: "today" | "week" | "month" | "all" | "custom" | (string & {});
from?: string | Date | null;
to?: string | Date | null;
includeTags?: boolean;
@ -536,7 +537,7 @@ export interface TaskNotesRuntimeModelApi {
validatePatch(patch: TaskNotesTaskPatch): TaskValidationResult;
}
export type TaskNotesRuntimeFieldSource = "model" | "computed" | "user";
export type TaskNotesRuntimeFieldSource = "model" | "computed" | "file" | "user";
export type TaskNotesRuntimeFieldValueType =
| "string"
| "number"
@ -555,23 +556,55 @@ export interface TaskNotesRuntimeFieldDefinition {
valueType: TaskNotesRuntimeFieldValueType;
source: TaskNotesRuntimeFieldSource;
writable: boolean;
queryable?: boolean;
sortable?: boolean;
groupable?: boolean;
supportedOperators?: readonly TaskNotesRuntimeOperator[];
aliases?: readonly string[];
required?: boolean;
frontmatterKey?: string;
description?: string;
}
export const TASKNOTES_RUNTIME_QUERY_OPERATORS = [
"eq",
"ne",
"contains",
"notContains",
"in",
"notIn",
"exists",
"missing",
"lt",
"lte",
"gt",
"gte",
"isTrue",
"isFalse",
] as const;
export type TaskNotesRuntimeOperator = (typeof TASKNOTES_RUNTIME_QUERY_OPERATORS)[number];
export interface TaskNotesRuntimeFilterOperatorDefinition {
id: string;
id: TaskNotesRuntimeOperator;
label: string;
valueRequired: boolean;
appliesTo: readonly string[];
appliesTo: readonly TaskNotesRuntimeFieldValueType[];
aliases?: readonly string[];
}
export interface TaskNotesRuntimeFilterPropertyDefinition {
id: string;
label: string;
category: string;
supportedOperators: readonly string[];
valueType: TaskNotesRuntimeFieldValueType;
source: TaskNotesRuntimeFieldSource;
queryable: boolean;
sortable: boolean;
groupable: boolean;
supportedOperators: readonly TaskNotesRuntimeOperator[];
aliases?: readonly string[];
frontmatterKey?: string;
valueInputType: string;
}
@ -659,7 +692,7 @@ export type TaskNotesRuntimeEventHandler<EventName extends TaskNotesRuntimeEvent
export interface TaskNotesRuntimeTasksApi {
get(path: string): Promise<TaskInfo | null>;
list(query?: FilterQuery): Promise<TaskInfo[]>;
list(query?: TaskNotesRuntimeTaskQuery): Promise<TaskInfo[]>;
create(taskData: TaskCreationData, context?: TaskNotesMutationContext): Promise<TaskInfo>;
update(
path: string,
@ -858,15 +891,134 @@ export interface TaskNotesRuntimeErrorsApi {
toResult<T>(operation: () => Promise<T> | T): Promise<TaskNotesApiResult<T>>;
}
export interface TaskNotesRuntimeTaskQuery {
where?: TaskNotesRuntimePredicate;
sort?: TaskNotesRuntimeSort[];
limit?: number;
offset?: number;
group?: TaskNotesRuntimeGroup[];
scope?: TaskNotesRuntimeQueryScope;
}
export type TaskNotesRuntimePredicate =
| { all: TaskNotesRuntimePredicate[] }
| { any: TaskNotesRuntimePredicate[] }
| { not: TaskNotesRuntimePredicate }
| TaskNotesRuntimeCondition;
export interface TaskNotesRuntimeCondition {
field: string;
op: TaskNotesRuntimeOperator | (string & {});
value?: TaskNotesRuntimeValue;
}
export type TaskNotesRuntimeValue =
| string
| number
| boolean
| null
| TaskNotesRuntimeValue[]
| { fn: "today" | "now" }
| { fn: "date"; value: string }
| {
fn: "dateAdd";
value: TaskNotesRuntimeValue;
amount: number;
unit: "day" | "week" | "month";
};
export interface TaskNotesRuntimeSort {
field: string;
direction?: "asc" | "desc";
}
export interface TaskNotesRuntimeGroup {
field: string;
}
export interface TaskNotesRuntimeQueryScope {
includeArchived?: boolean;
folders?: string[];
excludeFolders?: string[];
}
export interface TaskNotesRuntimeNormalizedCondition {
field: string;
op: TaskNotesRuntimeOperator;
value?: TaskNotesRuntimeValue;
}
export type TaskNotesRuntimeNormalizedPredicate =
| { all: TaskNotesRuntimeNormalizedPredicate[] }
| { any: TaskNotesRuntimeNormalizedPredicate[] }
| { not: TaskNotesRuntimeNormalizedPredicate }
| TaskNotesRuntimeNormalizedCondition;
export interface TaskNotesRuntimeNormalizedTaskQuery {
where?: TaskNotesRuntimeNormalizedPredicate;
sort: TaskNotesRuntimeSort[];
limit?: number;
offset: number;
group: TaskNotesRuntimeGroup[];
scope: Required<Pick<TaskNotesRuntimeQueryScope, "includeArchived">> &
Omit<TaskNotesRuntimeQueryScope, "includeArchived">;
}
export interface TaskNotesRuntimeQueryIssue {
path: string;
code: string;
message: string;
}
export interface TaskNotesRuntimeQueryWarning {
path: string;
code: string;
message: string;
}
export interface TaskNotesRuntimeQueryValidationResult {
valid: boolean;
issues: TaskNotesRuntimeQueryIssue[];
warnings: TaskNotesRuntimeQueryWarning[];
normalized?: TaskNotesRuntimeNormalizedTaskQuery;
}
export interface TaskNotesRuntimeQueryGroup {
key: string;
label: string;
taskPaths: string[];
}
export interface TaskNotesRuntimeQueryExplainResult {
valid: boolean;
query?: TaskNotesRuntimeNormalizedTaskQuery;
issues: TaskNotesRuntimeQueryIssue[];
warnings: TaskNotesRuntimeQueryWarning[];
total?: number;
matched?: number;
returned?: number;
groups?: TaskNotesRuntimeQueryGroup[];
appliedSort?: TaskNotesRuntimeSort[];
appliedLimit?: number;
appliedOffset?: number;
notes: string[];
}
export interface TaskNotesRuntimeTaskQueryResult {
tasks: TaskInfo[];
total: number;
filtered: number;
groups: Record<string, string[]>;
matched: number;
returned: number;
groups?: TaskNotesRuntimeQueryGroup[];
query: TaskNotesRuntimeNormalizedTaskQuery;
warnings?: TaskNotesRuntimeQueryWarning[];
}
export interface TaskNotesRuntimeQueryApi {
tasks(query?: FilterQuery): Promise<TaskNotesRuntimeTaskQueryResult>;
tasks(query?: TaskNotesRuntimeTaskQuery): Promise<TaskNotesRuntimeTaskQueryResult>;
validate(query: unknown): TaskNotesRuntimeQueryValidationResult;
normalize(query: unknown): TaskNotesRuntimeNormalizedTaskQuery;
explain(query: unknown): Promise<TaskNotesRuntimeQueryExplainResult>;
filterOptions(): Promise<FilterOptions>;
}
@ -884,7 +1036,7 @@ export interface TaskNotesRuntimeTaskStats {
}
export interface TaskNotesRuntimeStatsApi {
tasks(query?: FilterQuery): Promise<TaskNotesRuntimeTaskStats>;
tasks(query?: TaskNotesRuntimeTaskQuery): Promise<TaskNotesRuntimeTaskStats>;
}
export interface TaskNotesRuntimeVaultInfo {
@ -932,7 +1084,7 @@ export interface TaskNotesRuntimeApiV1 {
parseNaturalLanguage(text: string): ParsedTaskData;
getTask(path: string): Promise<TaskInfo | null>;
listTasks(query?: FilterQuery): Promise<TaskInfo[]>;
listTasks(query?: TaskNotesRuntimeTaskQuery): Promise<TaskInfo[]>;
createTask(taskData: TaskCreationData, context?: TaskNotesMutationContext): Promise<TaskInfo>;
updateTask(
path: string,

View file

@ -475,6 +475,8 @@ describe("TaskNotesApiV1", () => {
expect(api.capabilities).toContain("relationships.read");
expect(api.capabilities).toContain("model.validate");
expect(api.capabilities).toContain("query.tasks");
expect(api.capabilities).toContain("query.validate");
expect(api.capabilities).toContain("query.explain");
expect(api.capabilities).toContain("system.health");
expect(api.capabilities).toContain("lifecycle.events");
expect(api.capabilities).toContain("errors.typed");
@ -548,8 +550,29 @@ describe("TaskNotesApiV1", () => {
])
);
expect(api.catalog.writableFields().some((field) => field.id === "path")).toBe(false);
expect(api.catalog.filterProperties()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "task.status",
aliases: expect.arrayContaining(["status"]),
queryable: true,
supportedOperators: expect.arrayContaining(["eq", "ne"]),
}),
expect.objectContaining({
id: "user.energy",
frontmatterKey: "energy",
sortable: true,
}),
])
);
expect(api.catalog.filterOperators()).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "is", valueRequired: true })])
expect.arrayContaining([
expect.objectContaining({
id: "eq",
valueRequired: true,
aliases: expect.arrayContaining(["is"]),
}),
])
);
expect(api.catalog.relationships()).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "dependencies" })])
@ -753,7 +776,7 @@ describe("TaskNotesApiV1", () => {
expect(api.errors.normalize(structuralPayload)).toBe(structuralPayload);
});
it("reads individual tasks and filtered task lists without exposing mutable arrays", async () => {
it("reads individual tasks and queried task lists without exposing mutable arrays", async () => {
const task = createTask({ tags: ["work"] });
const { plugin, filterService } = createPluginContext([task]);
const api = new TaskNotesAPI(plugin);
@ -765,16 +788,21 @@ describe("TaskNotesApiV1", () => {
const refetched = await api.getTask(task.path);
expect(refetched?.tags).toEqual(["work"]);
const query = {
type: "group",
id: "root",
conjunction: "and",
children: [],
} satisfies FilterQuery;
const query = { where: { field: "task.tags", op: "contains", value: "work" } };
filterService.getGroupedTasks.mockResolvedValueOnce(new Map([["work", [task]]]));
await expect(api.listTasks(query)).resolves.toEqual([task]);
expect(filterService.getGroupedTasks).toHaveBeenCalledWith(query);
expect(filterService.getGroupedTasks).toHaveBeenCalledWith(
expect.objectContaining({
children: [
expect.objectContaining({
property: "tags",
operator: "contains",
value: "work",
}),
],
})
);
});
it("exposes query, stats, time summary, task time data, and health helpers", async () => {
@ -790,18 +818,64 @@ describe("TaskNotesApiV1", () => {
const { plugin, filterService, taskStatsService } = createPluginContext([task]);
const api = new TaskNotesAPI(plugin);
const query = {
type: "group",
id: "root",
conjunction: "and",
children: [],
} satisfies FilterQuery;
where: { field: "task.status", op: "eq", value: "open" },
sort: [{ field: "task.due", direction: "asc" }],
group: [{ field: "task.status" }],
limit: 10,
scope: { includeArchived: false, folders: ["Tasks"] },
};
await expect(api.query.tasks(query)).resolves.toEqual(
expect.objectContaining({
total: 1,
filtered: 1,
matched: 1,
returned: 1,
tasks: [expect.objectContaining({ path: task.path })],
groups: { default: [task.path] },
groups: [{ key: "default", label: "default", taskPaths: [task.path] }],
query: expect.objectContaining({
where: { field: "task.status", op: "eq", value: "open" },
sort: [{ field: "task.due", direction: "asc" }],
group: [{ field: "task.status" }],
limit: 10,
offset: 0,
scope: { includeArchived: false, folders: ["Tasks"] },
}),
})
);
expect(filterService.getGroupedTasks).toHaveBeenCalledWith(
expect.objectContaining({
sortKey: "due",
groupKey: "status",
children: [
expect.objectContaining({
property: "status",
operator: "is",
value: "open",
}),
],
})
);
expect(api.query.validate(query)).toEqual(
expect.objectContaining({
valid: true,
normalized: expect.objectContaining({
where: { field: "task.status", op: "eq", value: "open" },
}),
})
);
await expect(api.query.explain(query)).resolves.toEqual(
expect.objectContaining({
valid: true,
total: 1,
matched: 1,
returned: 1,
appliedSort: [{ field: "task.due", direction: "asc" }],
})
);
expect(api.query.validate({ where: { field: "missing", op: "eq", value: "x" } })).toEqual(
expect.objectContaining({
valid: false,
issues: [expect.objectContaining({ code: "field_unknown" })],
})
);
await expect(api.query.filterOptions()).resolves.toEqual(