diff --git a/docs/javascript-api.md b/docs/javascript-api.md index ccab8ccc..be2edce5 100644 --- a/docs/javascript-api.md +++ b/docs/javascript-api.md @@ -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.`. `api.catalog.filterProperties()` returns the complete queryable field catalog, including aliases such as `status` and `user:`. + +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 diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0c81693f..089ecffd 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, 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. diff --git a/src/api/TaskNotesAPI.ts b/src/api/TaskNotesAPI.ts index 3d891797..60932345 100644 --- a/src/api/TaskNotesAPI.ts +++ b/src/api/TaskNotesAPI.ts @@ -10,15 +10,20 @@ import { import type TaskNotesPlugin from "../main"; import { NaturalLanguageParser, type ParsedTaskData } from "../services/NaturalLanguageParser"; import type { + FilterCondition, + FilterNode, + FilterOperator, + FilterProperty, FilterQuery, - FILTER_PROPERTIES, PomodoroHistoryStats, PomodoroSessionHistory, PomodoroState, Reminder, + TaskGroupKey, TaskCreationData, TaskDependency, TaskInfo, + TaskSortKey, TimeEntry, } from "../types"; import { @@ -27,6 +32,7 @@ import { EVENT_POMODORO_START, EVENT_TASK_DELETED, EVENT_TASK_UPDATED, + FILTER_PROPERTIES, } from "../types"; import type { TaskNotesSettings } from "../types/settings"; import { ensureFolderExists } from "../utils/helpers"; @@ -38,6 +44,7 @@ import { TASKNOTES_RUNTIME_LIFECYCLE_EVENT_DEFINITIONS, TASKNOTES_RUNTIME_LIFECYCLE_RAW_EVENTS, TASKNOTES_RUNTIME_API_VERSION, + TASKNOTES_RUNTIME_QUERY_OPERATORS, TaskNotesApiError, isTaskNotesApiError, isTaskNotesApiErrorPayload, @@ -55,9 +62,20 @@ import { type TaskNotesRuntimeLifecycleEventName, type TaskNotesRuntimeLifecycleHandler, type TaskNotesRuntimeLifecyclePayload, + type TaskNotesRuntimeNormalizedCondition, + type TaskNotesRuntimeNormalizedPredicate, + type TaskNotesRuntimeNormalizedTaskQuery, + type TaskNotesRuntimeOperator, + type TaskNotesRuntimeQueryExplainResult, + type TaskNotesRuntimeQueryGroup, + type TaskNotesRuntimeQueryIssue, + type TaskNotesRuntimeQueryValidationResult, + type TaskNotesRuntimeQueryWarning, type TaskNotesRuntimeRelationshipDefinition, + type TaskNotesRuntimeTaskQuery, type TaskNotesRuntimeTaskQueryResult, type TaskNotesRuntimeTaskStats, + type TaskNotesRuntimeValue, type TaskNotesRuntimeTaskTimeData, type TaskNotesRuntimeTimeSummary, type TaskNotesRuntimeTimeSummaryOptions, @@ -346,63 +364,309 @@ const FIELD_MAPPING_KEY_BY_FIELD_ID: Partial< const FILTER_OPERATOR_DEFINITIONS: readonly TaskNotesRuntimeFilterOperatorDefinition[] = [ { - id: "is", - label: "is", + id: "eq", + label: "equals", valueRequired: true, - appliesTo: ["text", "select", "date", "numeric"], + appliesTo: ["string", "number", "boolean", "date", "datetime", "string[]"], + aliases: ["is"], }, { - id: "is-not", - label: "is not", + id: "ne", + label: "does not equal", valueRequired: true, - appliesTo: ["text", "select", "date", "numeric"], + appliesTo: ["string", "number", "boolean", "date", "datetime", "string[]"], + aliases: ["is-not"], }, - { id: "contains", label: "contains", valueRequired: true, appliesTo: ["text", "select"] }, { - id: "does-not-contain", + id: "contains", + label: "contains", + valueRequired: true, + appliesTo: ["string", "string[]", "dependency[]"], + }, + { + id: "notContains", label: "does not contain", valueRequired: true, - appliesTo: ["text", "select"], - }, - { id: "is-before", label: "is before", valueRequired: true, appliesTo: ["date"] }, - { id: "is-after", label: "is after", valueRequired: true, appliesTo: ["date"] }, - { id: "is-on-or-before", label: "is on or before", valueRequired: true, appliesTo: ["date"] }, - { id: "is-on-or-after", label: "is on or after", valueRequired: true, appliesTo: ["date"] }, - { - id: "is-empty", - label: "is empty", - valueRequired: false, - appliesTo: ["text", "select", "date", "numeric"], + appliesTo: ["string", "string[]", "dependency[]"], + aliases: ["does-not-contain"], }, { - id: "is-not-empty", - label: "is not empty", - valueRequired: false, - appliesTo: ["text", "select", "date", "numeric"], - }, - { id: "is-checked", label: "is checked", valueRequired: false, appliesTo: ["boolean"] }, - { id: "is-not-checked", label: "is not checked", valueRequired: false, appliesTo: ["boolean"] }, - { - id: "is-greater-than", - label: "is greater than", + id: "in", + label: "is one of", valueRequired: true, - appliesTo: ["numeric"], + appliesTo: ["string", "number", "boolean", "date", "datetime"], }, - { id: "is-less-than", label: "is less than", valueRequired: true, appliesTo: ["numeric"] }, { - id: "is-greater-than-or-equal", - label: "is greater than or equal", + id: "notIn", + label: "is not one of", valueRequired: true, - appliesTo: ["numeric"], + appliesTo: ["string", "number", "boolean", "date", "datetime"], }, { - id: "is-less-than-or-equal", + id: "exists", + label: "exists", + valueRequired: false, + appliesTo: ["string", "number", "boolean", "date", "datetime", "string[]"], + aliases: ["is-not-empty"], + }, + { + id: "missing", + label: "is missing", + valueRequired: false, + appliesTo: ["string", "number", "boolean", "date", "datetime", "string[]"], + aliases: ["is-empty"], + }, + { + id: "lt", + label: "is less than", + valueRequired: true, + appliesTo: ["number", "date", "datetime"], + aliases: ["is-before", "is-less-than"], + }, + { + id: "lte", label: "is less than or equal", valueRequired: true, - appliesTo: ["numeric"], + appliesTo: ["number", "date", "datetime"], + aliases: ["is-on-or-before", "is-less-than-or-equal"], + }, + { + id: "gt", + label: "is greater than", + valueRequired: true, + appliesTo: ["number", "date", "datetime"], + aliases: ["is-after", "is-greater-than"], + }, + { + id: "gte", + label: "is greater than or equal", + valueRequired: true, + appliesTo: ["number", "date", "datetime"], + aliases: ["is-on-or-after", "is-greater-than-or-equal"], + }, + { + id: "isTrue", + label: "is true", + valueRequired: false, + appliesTo: ["boolean"], + aliases: ["is-checked"], + }, + { + id: "isFalse", + label: "is false", + valueRequired: false, + appliesTo: ["boolean"], + aliases: ["is-not-checked"], }, ]; +const RUNTIME_OPERATOR_BY_ALIAS = new Map( + FILTER_OPERATOR_DEFINITIONS.flatMap((operator) => [ + [operator.id, operator.id] as const, + ...(operator.aliases ?? []).map((alias) => [alias, operator.id] as const), + ]) +); + +type RuntimeFilterPropertyDefinition = TaskNotesRuntimeFilterPropertyDefinition & { + internalProperty: FilterProperty; +}; + +const RUNTIME_FILTER_PROPERTY_DEFINITIONS: readonly RuntimeFilterPropertyDefinition[] = [ + runtimeFilterProperty("task.title", "title", "Title", "text", "string", "model", { + aliases: ["title", "note.title"], + sortable: true, + }), + runtimeFilterProperty("file.path", "path", "Path", "file", "string", "file", { + aliases: ["path", "task.path", "note.path"], + }), + runtimeFilterProperty("task.status", "status", "Status", "select", "string", "model", { + aliases: ["status", "note.status"], + sortable: true, + groupable: true, + }), + runtimeFilterProperty("task.priority", "priority", "Priority", "select", "string", "model", { + aliases: ["priority", "note.priority"], + sortable: true, + groupable: true, + }), + runtimeFilterProperty("task.tags", "tags", "Tags", "select", "string[]", "model", { + aliases: ["tags", "file.tags", "note.tags"], + sortable: true, + groupable: true, + }), + runtimeFilterProperty("task.contexts", "contexts", "Contexts", "select", "string[]", "model", { + aliases: ["contexts"], + groupable: true, + }), + runtimeFilterProperty("task.projects", "projects", "Projects", "select", "string[]", "model", { + aliases: ["projects"], + groupable: true, + }), + runtimeFilterProperty( + "task.blockedBy", + "blockedBy", + "Blocked by", + "select", + "dependency[]", + "model", + { + aliases: ["blockedBy"], + } + ), + runtimeFilterProperty( + "task.blocking", + "blocking", + "Blocking", + "select", + "string[]", + "computed", + { + aliases: ["blocking"], + } + ), + runtimeFilterProperty("task.due", "due", "Due date", "date", "date", "model", { + aliases: ["due", "note.due"], + sortable: true, + groupable: true, + }), + runtimeFilterProperty( + "task.scheduled", + "scheduled", + "Scheduled date", + "date", + "date", + "model", + { + aliases: ["scheduled", "note.scheduled"], + sortable: true, + groupable: true, + } + ), + runtimeFilterProperty( + "task.completedDate", + "completedDate", + "Completed date", + "date", + "date", + "model", + { + aliases: ["completedDate"], + sortable: true, + groupable: true, + } + ), + runtimeFilterProperty( + "task.dateCreated", + "dateCreated", + "Created date", + "date", + "datetime", + "model", + { + aliases: ["dateCreated", "file.ctime"], + sortable: true, + } + ), + runtimeFilterProperty( + "task.dateModified", + "dateModified", + "Modified date", + "date", + "datetime", + "model", + { + aliases: ["dateModified", "file.mtime"], + } + ), + runtimeFilterProperty("task.archived", "archived", "Archived", "boolean", "boolean", "model", { + aliases: ["archived"], + }), + runtimeFilterProperty( + "task.hasSubtasks", + "hasSubtasks", + "Has subtasks", + "boolean", + "boolean", + "computed", + { + aliases: ["hasSubtasks"], + } + ), + runtimeFilterProperty( + "task.isBlocked", + "dependencies.isBlocked", + "Blocked", + "boolean", + "boolean", + "computed", + { + aliases: ["dependencies.isBlocked", "isBlocked"], + } + ), + runtimeFilterProperty( + "task.isBlocking", + "dependencies.isBlocking", + "Blocking others", + "boolean", + "boolean", + "computed", + { + aliases: ["dependencies.isBlocking", "isBlocking"], + } + ), + runtimeFilterProperty( + "task.timeEstimate", + "timeEstimate", + "Time estimate", + "numeric", + "number", + "model", + { + aliases: ["timeEstimate"], + sortable: true, + } + ), + runtimeFilterProperty( + "task.recurrence", + "recurrence", + "Recurrence", + "special", + "string", + "model", + { + aliases: ["recurrence"], + } + ), + runtimeFilterProperty( + "task.isCompleted", + "status.isCompleted", + "Completed", + "boolean", + "boolean", + "computed", + { + aliases: ["status.isCompleted", "completed"], + } + ), +]; + +const RUNTIME_TO_LEGACY_OPERATOR: Record = { + eq: "is", + ne: "is-not", + contains: "contains", + notContains: "does-not-contain", + in: "is", + notIn: "is-not", + exists: "is-not-empty", + missing: "is-empty", + lt: "is-before", + lte: "is-on-or-before", + gt: "is-after", + gte: "is-on-or-after", + isTrue: "is-checked", + isFalse: "is-not-checked", +}; + const RELATIONSHIP_DEFINITIONS: readonly TaskNotesRuntimeRelationshipDefinition[] = [ { id: "parents", label: "Parents", description: "Tasks referenced from this task's projects." }, { @@ -467,7 +731,7 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { readonly tasks = { get: (path: string) => this.getTask(path), - list: (query?: FilterQuery) => this.listTasks(query), + list: (query?: TaskNotesRuntimeTaskQuery) => this.listTasks(query), create: (taskData: TaskCreationData, context?: TaskNotesMutationContext) => this.createTask(taskData, context), update: (path: string, patch: TaskNotesTaskPatch, context?: TaskNotesMutationContext) => @@ -598,12 +862,15 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { }; readonly query = { - tasks: (query?: FilterQuery) => this.queryTasks(query), + tasks: (query?: TaskNotesRuntimeTaskQuery) => this.queryTasks(query), + validate: (query: unknown) => this.validateRuntimeQuery(query), + normalize: (query: unknown) => this.normalizeRuntimeQueryOrThrow(query), + explain: (query: unknown) => this.explainRuntimeQuery(query), filterOptions: () => this.getFilterOptions(), }; readonly stats = { - tasks: (query?: FilterQuery) => this.getTaskStats(query), + tasks: (query?: TaskNotesRuntimeTaskQuery) => this.getTaskStats(query), }; readonly system = { @@ -740,9 +1007,15 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { const mapping = this.plugin.settings.fieldMapping ?? {}; const coreFields = CORE_FIELD_DEFINITIONS.map((field) => { const mappingKey = FIELD_MAPPING_KEY_BY_FIELD_ID[field.id]; + const queryField = runtimeFilterPropertyForInternal(field.id); return { ...field, frontmatterKey: mappingKey ? mapping[mappingKey] : undefined, + queryable: !!queryField, + sortable: queryField?.sortable ?? false, + groupable: queryField?.groupable ?? false, + supportedOperators: queryField?.supportedOperators, + aliases: queryField ? [queryField.id, ...(queryField.aliases ?? [])] : undefined, }; }); const userFields = this.getUserFields().map( @@ -752,6 +1025,13 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { valueType: userFieldTypeToRuntimeValueType(field.type), source: "user", writable: true, + queryable: true, + sortable: true, + groupable: true, + supportedOperators: operatorsForRuntimeValueType( + userFieldTypeToRuntimeValueType(field.type) + ), + aliases: [`user.${field.id || field.key}`, `user:${field.id || field.key}`], frontmatterKey: field.key, description: `User-defined field ${field.key}`, }) @@ -761,39 +1041,136 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { } private getFilterPropertyDefinitions(): TaskNotesRuntimeFilterPropertyDefinition[] { - return FILTER_PROPERTIES.map((property) => ({ - id: property.id, - label: property.label, - category: property.category, - supportedOperators: [...property.supportedOperators], - valueInputType: property.valueInputType, - })); + const userFields = this.getUserFields().map( + (field): TaskNotesRuntimeFilterPropertyDefinition => ({ + id: `user.${field.id || field.key}`, + label: field.displayName || field.key, + category: "user", + valueType: userFieldTypeToRuntimeValueType(field.type), + source: "user", + queryable: true, + sortable: true, + groupable: true, + supportedOperators: operatorsForRuntimeValueType( + userFieldTypeToRuntimeValueType(field.type) + ), + aliases: [ + `user:${field.id || field.key}`, + `user.${field.key}`, + `user:${field.key}`, + ], + frontmatterKey: field.key, + valueInputType: userFieldTypeToFilterInputType(field.type), + }) + ); + return [ + ...RUNTIME_FILTER_PROPERTY_DEFINITIONS.map( + ({ internalProperty: _internal, ...field }) => ({ + ...field, + aliases: field.aliases ? [...field.aliases] : undefined, + supportedOperators: [...field.supportedOperators], + }) + ), + ...userFields, + ]; } - private async queryTasks(query?: FilterQuery): Promise { + private async queryTasks( + query?: TaskNotesRuntimeTaskQuery + ): Promise { + const execution = this.normalizeRuntimeQueryForExecution(query); + if (!execution.validation.valid) { + throw new TaskNotesApiError("invalid_input", "TaskNotes runtime query is invalid", { + status: 400, + details: { issues: execution.validation.issues }, + }); + } + const allTasks = await this.plugin.cacheManager.getAllTasks(); - if (!query) { + const scopedTasks = applyRuntimeQueryScope(allTasks, execution.normalized.scope); + const scopedPaths = new Set(scopedTasks.map((task) => task.path)); + const groupedTasks = await this.plugin.filterService.getGroupedTasks(execution.filterQuery); + const matchedTasksByPath = new Map(); + + for (const groupTasks of groupedTasks.values()) { + for (const task of groupTasks) { + if (scopedPaths.has(task.path) && !matchedTasksByPath.has(task.path)) { + matchedTasksByPath.set(task.path, task); + } + } + } + + const matchedTasks = Array.from(matchedTasksByPath.values()); + const offset = execution.normalized.offset; + const limit = execution.normalized.limit; + const returnedTasks = + typeof limit === "number" + ? matchedTasks.slice(offset, offset + limit) + : matchedTasks.slice(offset); + const returnedPaths = new Set(returnedTasks.map((task) => task.path)); + + return { + tasks: returnedTasks.map(copyTaskInfo), + total: scopedTasks.length, + matched: matchedTasks.length, + returned: returnedTasks.length, + groups: + execution.normalized.group.length > 0 + ? runtimeQueryGroups(groupedTasks, returnedPaths) + : undefined, + query: execution.normalized, + warnings: execution.validation.warnings, + }; + } + + private validateRuntimeQuery(query: unknown): TaskNotesRuntimeQueryValidationResult { + return this.normalizeRuntimeQueryForExecution(query).validation; + } + + private normalizeRuntimeQueryOrThrow(query: unknown): TaskNotesRuntimeNormalizedTaskQuery { + const execution = this.normalizeRuntimeQueryForExecution(query); + if (!execution.validation.valid) { + throw new TaskNotesApiError("invalid_input", "TaskNotes runtime query is invalid", { + status: 400, + details: { issues: execution.validation.issues }, + }); + } + return execution.normalized; + } + + private async explainRuntimeQuery(query: unknown): Promise { + const execution = this.normalizeRuntimeQueryForExecution(query); + if (!execution.validation.valid) { return { - tasks: allTasks.map(copyTaskInfo), - total: allTasks.length, - filtered: allTasks.length, - groups: { all: allTasks.map((task) => task.path) }, + valid: false, + issues: execution.validation.issues, + warnings: execution.validation.warnings, + notes: ["Query validation failed before execution."], }; } - 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); + const result = await this.queryTasks(query as TaskNotesRuntimeTaskQuery); + const notes: string[] = []; + if (execution.normalized.sort.length > 1) { + notes.push("Only the first sort is currently applied by the TaskNotes filter engine."); + } + if (execution.normalized.group.length > 1) { + notes.push("Only the first group is currently applied by the TaskNotes filter engine."); } return { - tasks: tasks.map(copyTaskInfo), - total: allTasks.length, - filtered: tasks.length, - groups, + valid: true, + query: result.query, + issues: [], + warnings: result.warnings ?? [], + total: result.total, + matched: result.matched, + returned: result.returned, + groups: result.groups, + appliedSort: result.query.sort.slice(0, 1), + appliedLimit: result.query.limit, + appliedOffset: result.query.offset, + notes, }; } @@ -801,7 +1178,473 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { return this.plugin.filterService.getFilterOptions(); } - private async getTaskStats(query?: FilterQuery): Promise { + private normalizeRuntimeQueryForExecution(query: unknown): RuntimeQueryExecution { + const issues: TaskNotesRuntimeQueryIssue[] = []; + const warnings: TaskNotesRuntimeQueryWarning[] = []; + const normalized = this.normalizeRuntimeTaskQuery(query, issues, warnings); + const filterQuery = this.runtimeQueryToFilterQuery(normalized, issues, warnings); + return { + filterQuery, + normalized, + validation: { + valid: issues.length === 0, + issues, + warnings, + normalized: issues.length === 0 ? normalized : undefined, + }, + }; + } + + private normalizeRuntimeTaskQuery( + query: unknown, + issues: TaskNotesRuntimeQueryIssue[], + warnings: TaskNotesRuntimeQueryWarning[] + ): TaskNotesRuntimeNormalizedTaskQuery { + if (query == null) return emptyRuntimeTaskQuery(); + if (!isRecord(query)) { + issues.push({ + path: "$", + code: "query_not_object", + message: "Runtime task query must be an object.", + }); + return emptyRuntimeTaskQuery(); + } + + const where = + typeof query["where"] === "undefined" + ? undefined + : this.normalizeRuntimePredicate(query["where"], "$.where", issues); + const sort = this.normalizeRuntimeSorts(query["sort"], issues, warnings); + const group = this.normalizeRuntimeGroups(query["group"], issues, warnings); + const limit = normalizeRuntimeLimit(query["limit"], "$.limit", issues); + const offset = normalizeRuntimeOffset(query["offset"], "$.offset", issues); + const scope = normalizeRuntimeScope(query["scope"], issues); + + return { where, sort, limit, offset, group, scope }; + } + + private normalizeRuntimePredicate( + predicate: unknown, + path: string, + issues: TaskNotesRuntimeQueryIssue[] + ): TaskNotesRuntimeNormalizedPredicate | undefined { + if (!isRecord(predicate)) { + issues.push({ + path, + code: "predicate_not_object", + message: "Runtime query predicate must be an object.", + }); + return undefined; + } + + if (Array.isArray(predicate["all"])) { + return { + all: predicate["all"] + .map((child, index) => + this.normalizeRuntimePredicate(child, `${path}.all.${index}`, issues) + ) + .filter(isDefined), + }; + } + if (Array.isArray(predicate["any"])) { + return { + any: predicate["any"] + .map((child, index) => + this.normalizeRuntimePredicate(child, `${path}.any.${index}`, issues) + ) + .filter(isDefined), + }; + } + if (typeof predicate["not"] !== "undefined") { + const normalized = this.normalizeRuntimePredicate( + predicate["not"], + `${path}.not`, + issues + ); + return normalized ? { not: normalized } : undefined; + } + + return this.normalizeRuntimeCondition(predicate, path, issues); + } + + private normalizeRuntimeCondition( + condition: Record, + path: string, + issues: TaskNotesRuntimeQueryIssue[] + ): TaskNotesRuntimeNormalizedCondition | undefined { + if (typeof condition["field"] !== "string" || condition["field"].trim().length === 0) { + issues.push({ + path: `${path}.field`, + code: "field_required", + message: "Runtime query condition requires a field.", + }); + return undefined; + } + const field = this.resolveRuntimeQueryField(condition["field"]); + if (!field) { + issues.push({ + path: `${path}.field`, + code: "field_unknown", + message: `Unsupported runtime query field: ${condition["field"]}`, + }); + return undefined; + } + + const op = normalizeRuntimeOperator(condition["op"]); + if (!op) { + issues.push({ + path: `${path}.op`, + code: "operator_unknown", + message: `Unsupported runtime query operator: ${String(condition["op"])}`, + }); + return undefined; + } + if (!field.definition.supportedOperators.includes(op)) { + issues.push({ + path: `${path}.op`, + code: "operator_unsupported", + message: `Operator ${op} is not supported for ${field.definition.id}.`, + }); + return undefined; + } + + const requiresValue = runtimeOperatorRequiresValue(op); + if (requiresValue && typeof condition["value"] === "undefined") { + issues.push({ + path: `${path}.value`, + code: "value_required", + message: `Operator ${op} requires a value.`, + }); + return undefined; + } + if ((op === "in" || op === "notIn") && !Array.isArray(condition["value"])) { + issues.push({ + path: `${path}.value`, + code: "value_array_required", + message: `Operator ${op} requires an array value.`, + }); + return undefined; + } + + return { + field: field.definition.id, + op, + value: requiresValue ? normalizeRuntimeValue(condition["value"]) : undefined, + }; + } + + private normalizeRuntimeSorts( + value: unknown, + issues: TaskNotesRuntimeQueryIssue[], + warnings: TaskNotesRuntimeQueryWarning[] + ): TaskNotesRuntimeNormalizedTaskQuery["sort"] { + if (typeof value === "undefined") return []; + if (!Array.isArray(value)) { + issues.push({ + path: "$.sort", + code: "sort_not_array", + message: "Runtime query sort must be an array.", + }); + return []; + } + + const sorts = value + .map((entry, index): TaskNotesRuntimeNormalizedTaskQuery["sort"][number] | null => { + if (!isRecord(entry) || typeof entry["field"] !== "string") { + issues.push({ + path: `$.sort.${index}`, + code: "sort_invalid", + message: "Runtime query sort entries require a field.", + }); + return null; + } + const field = this.resolveRuntimeQueryField(entry["field"]); + if (!field || !field.definition.sortable) { + issues.push({ + path: `$.sort.${index}.field`, + code: "sort_field_unsupported", + message: `Runtime query field is not sortable: ${entry["field"]}`, + }); + return null; + } + const direction = entry["direction"] === "desc" ? "desc" : "asc"; + return { field: field.definition.id, direction }; + }) + .filter(isDefined); + + if (sorts.length > 1) { + warnings.push({ + path: "$.sort", + code: "multiple_sorts", + message: "Only the first sort is currently applied by TaskNotes.", + }); + } + return sorts; + } + + private normalizeRuntimeGroups( + value: unknown, + issues: TaskNotesRuntimeQueryIssue[], + warnings: TaskNotesRuntimeQueryWarning[] + ): TaskNotesRuntimeNormalizedTaskQuery["group"] { + if (typeof value === "undefined") return []; + if (!Array.isArray(value)) { + issues.push({ + path: "$.group", + code: "group_not_array", + message: "Runtime query group must be an array.", + }); + return []; + } + + const groups = value + .map((entry, index): TaskNotesRuntimeNormalizedTaskQuery["group"][number] | null => { + if (!isRecord(entry) || typeof entry["field"] !== "string") { + issues.push({ + path: `$.group.${index}`, + code: "group_invalid", + message: "Runtime query group entries require a field.", + }); + return null; + } + const field = this.resolveRuntimeQueryField(entry["field"]); + if (!field || !field.definition.groupable) { + issues.push({ + path: `$.group.${index}.field`, + code: "group_field_unsupported", + message: `Runtime query field is not groupable: ${entry["field"]}`, + }); + return null; + } + return { field: field.definition.id }; + }) + .filter(isDefined); + + if (groups.length > 1) { + warnings.push({ + path: "$.group", + code: "multiple_groups", + message: "Only the first group is currently applied by TaskNotes.", + }); + } + return groups; + } + + private runtimeQueryToFilterQuery( + query: TaskNotesRuntimeNormalizedTaskQuery, + issues: TaskNotesRuntimeQueryIssue[], + warnings: TaskNotesRuntimeQueryWarning[] + ): FilterQuery { + const root = this.runtimePredicateToFilterNode(query.where, "$.where", issues); + const children = root + ? root.type === "group" && root.conjunction === "and" + ? root.children + : [root] + : []; + const filterQuery: FilterQuery = { + type: "group", + id: "runtime-query-root", + conjunction: "and", + children, + }; + + const sort = query.sort[0]; + if (sort) { + const sortKey = this.runtimeFieldToSortKey(sort.field); + if (sortKey) { + filterQuery.sortKey = sortKey; + filterQuery.sortDirection = sort.direction ?? "asc"; + } else { + warnings.push({ + path: "$.sort.0.field", + code: "sort_not_applied", + message: `Sort field ${sort.field} could not be mapped to TaskNotes sorting.`, + }); + } + } + + const group = query.group[0]; + if (group) { + const groupKey = this.runtimeFieldToGroupKey(group.field); + if (groupKey) { + filterQuery.groupKey = groupKey; + } else { + warnings.push({ + path: "$.group.0.field", + code: "group_not_applied", + message: `Group field ${group.field} could not be mapped to TaskNotes grouping.`, + }); + } + } + + return filterQuery; + } + + private runtimePredicateToFilterNode( + predicate: TaskNotesRuntimeNormalizedPredicate | undefined, + path: string, + issues: TaskNotesRuntimeQueryIssue[] + ): FilterNode | null { + if (!predicate) return null; + if ("all" in predicate) { + return { + type: "group", + id: runtimeNodeId(path), + conjunction: "and", + children: predicate.all + .map((child, index) => + this.runtimePredicateToFilterNode(child, `${path}.all.${index}`, issues) + ) + .filter(isDefined), + }; + } + if ("any" in predicate) { + return { + type: "group", + id: runtimeNodeId(path), + conjunction: "or", + children: predicate.any + .map((child, index) => + this.runtimePredicateToFilterNode(child, `${path}.any.${index}`, issues) + ) + .filter(isDefined), + }; + } + if ("not" in predicate) { + const inverted = invertRuntimePredicate(predicate.not); + if (!inverted) { + issues.push({ + path: `${path}.not`, + code: "not_unsupported", + message: "Unable to invert runtime query predicate.", + }); + return null; + } + return this.runtimePredicateToFilterNode(inverted, `${path}.not`, issues); + } + + return this.runtimeConditionToFilterNode(predicate, path); + } + + private runtimeConditionToFilterNode( + condition: TaskNotesRuntimeNormalizedCondition, + path: string + ): FilterNode { + const field = this.resolveRuntimeQueryField(condition.field); + const values = + Array.isArray(condition.value) && (condition.op === "in" || condition.op === "notIn") + ? condition.value + : null; + if (values) { + return { + type: "group", + id: runtimeNodeId(path), + conjunction: condition.op === "in" ? "or" : "and", + children: values.map( + (value, index): FilterCondition => ({ + type: "condition", + id: runtimeNodeId(`${path}.value.${index}`), + property: field?.internalProperty ?? (condition.field as FilterProperty), + operator: RUNTIME_TO_LEGACY_OPERATOR[condition.op], + value: toFilterConditionValue(normalizeRuntimeValue(value)), + }) + ), + }; + } + + return { + type: "condition", + id: runtimeNodeId(path), + property: field?.internalProperty ?? (condition.field as FilterProperty), + operator: RUNTIME_TO_LEGACY_OPERATOR[condition.op], + value: toFilterConditionValue(condition.value), + }; + } + + private resolveRuntimeQueryField(field: string): ResolvedRuntimeQueryField | null { + const normalizedField = field.trim(); + const userField = this.resolveRuntimeUserField(normalizedField); + if (userField) return userField; + + const direct = RUNTIME_FILTER_PROPERTY_DEFINITIONS.find( + (definition) => + definition.id === normalizedField || + (definition.aliases ?? []).includes(normalizedField) + ); + return direct ? { definition: direct, internalProperty: direct.internalProperty } : null; + } + + private resolveRuntimeUserField(field: string): ResolvedRuntimeQueryField | null { + if (!field.startsWith("user.") && !field.startsWith("user:")) return null; + const key = field.slice(5); + const userField = this.getUserFields().find( + (candidate) => candidate.id === key || candidate.key === key + ); + if (!userField) return null; + const id = userField.id || userField.key; + const valueType = userFieldTypeToRuntimeValueType(userField.type); + return { + internalProperty: `user:${id}`, + definition: { + id: `user.${id}`, + label: userField.displayName || userField.key, + category: "user", + valueType, + source: "user", + queryable: true, + sortable: true, + groupable: true, + supportedOperators: operatorsForRuntimeValueType(valueType), + aliases: [`user:${id}`, `user.${userField.key}`, `user:${userField.key}`], + frontmatterKey: userField.key, + valueInputType: userFieldTypeToFilterInputType(userField.type), + }, + }; + } + + private runtimeFieldToSortKey(field: string): TaskSortKey | null { + const resolved = this.resolveRuntimeQueryField(field); + if (!resolved) return null; + const property = resolved.internalProperty; + if (property.startsWith("user:")) return property as `user:${string}`; + if ( + property === "due" || + property === "scheduled" || + property === "priority" || + property === "status" || + property === "title" || + property === "dateCreated" || + property === "completedDate" || + property === "tags" + ) { + return property; + } + return null; + } + + private runtimeFieldToGroupKey(field: string): TaskGroupKey | null { + const resolved = this.resolveRuntimeQueryField(field); + if (!resolved) return null; + const property = resolved.internalProperty; + if (property.startsWith("user:")) return property as `user:${string}`; + if ( + property === "priority" || + property === "due" || + property === "scheduled" || + property === "status" || + property === "tags" || + property === "completedDate" + ) { + return property; + } + if (property === "contexts") return "context"; + if (property === "projects") return "project"; + return null; + } + + private async getTaskStats( + query?: TaskNotesRuntimeTaskQuery + ): Promise { const tasks = query ? (await this.queryTasks(query)).tasks : await this.plugin.cacheManager.getAllTasks(); @@ -920,18 +1763,12 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { return task ? copyTaskInfo(task) : null; } - async listTasks(query?: FilterQuery): Promise { + async listTasks(query?: TaskNotesRuntimeTaskQuery): Promise { if (!query) { const tasks = await this.plugin.cacheManager.getAllTasks(); return tasks.map(copyTaskInfo); } - - const groupedTasks = await this.plugin.filterService.getGroupedTasks(query); - const tasks: TaskInfo[] = []; - for (const groupTasks of groupedTasks.values()) { - tasks.push(...groupTasks); - } - return tasks.map(copyTaskInfo); + return (await this.queryTasks(query)).tasks; } async getParentTasks(path: string): Promise { @@ -1923,7 +2760,7 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { ): TaskNotesRuntimeLifecyclePayload { const record = isRecord(payload) ? payload : {}; const extension = isRecord(record.extension) - ? (record.extension as TaskNotesRuntimeLifecyclePayload["extension"]) + ? (record.extension as unknown as TaskNotesRuntimeLifecyclePayload["extension"]) : undefined; return { event, @@ -2031,6 +2868,379 @@ function firstReferencePathCandidate(reference: string): string | null { return parsed ? normalizePath(parsed) : null; } +type RuntimeQueryExecution = { + filterQuery: FilterQuery; + normalized: TaskNotesRuntimeNormalizedTaskQuery; + validation: TaskNotesRuntimeQueryValidationResult; +}; + +type ResolvedRuntimeQueryField = { + definition: TaskNotesRuntimeFilterPropertyDefinition; + internalProperty: FilterProperty; +}; + +function runtimeFilterProperty( + id: string, + internalProperty: FilterProperty, + label: string, + category: string, + valueType: TaskNotesRuntimeFieldDefinition["valueType"], + source: TaskNotesRuntimeFieldDefinition["source"], + options: { + aliases?: readonly string[]; + sortable?: boolean; + groupable?: boolean; + valueInputType?: string; + } = {} +): RuntimeFilterPropertyDefinition { + const internalPropertyDefinition = FILTER_PROPERTIES.find( + (property) => property.id === internalProperty + ); + return { + id, + internalProperty, + label, + category, + valueType, + source, + queryable: true, + sortable: options.sortable ?? false, + groupable: options.groupable ?? false, + supportedOperators: runtimeOperatorsFromLegacy( + internalPropertyDefinition?.supportedOperators ?? + operatorsForRuntimeValueType(valueType) + ), + aliases: options.aliases, + valueInputType: + options.valueInputType ?? internalPropertyDefinition?.valueInputType ?? "text", + }; +} + +function runtimeFilterPropertyForInternal( + internalProperty: string +): RuntimeFilterPropertyDefinition | undefined { + return RUNTIME_FILTER_PROPERTY_DEFINITIONS.find( + (definition) => definition.internalProperty === internalProperty + ); +} + +function runtimeOperatorsFromLegacy( + operators: readonly (FilterOperator | TaskNotesRuntimeOperator)[] +): TaskNotesRuntimeOperator[] { + const normalizedOperators = operators + .map((operator) => normalizeRuntimeOperator(operator)) + .filter(isDefined); + if (normalizedOperators.includes("eq")) normalizedOperators.push("in"); + if (normalizedOperators.includes("ne")) normalizedOperators.push("notIn"); + return Array.from(new Set(normalizedOperators)); +} + +function operatorsForRuntimeValueType( + valueType: TaskNotesRuntimeFieldDefinition["valueType"] +): TaskNotesRuntimeOperator[] { + if (valueType === "boolean") return ["isTrue", "isFalse"]; + if (valueType === "number") { + return ["eq", "ne", "in", "notIn", "lt", "lte", "gt", "gte", "exists", "missing"]; + } + if (valueType === "date" || valueType === "datetime") { + return ["eq", "ne", "in", "notIn", "lt", "lte", "gt", "gte", "exists", "missing"]; + } + if (valueType.endsWith("[]")) { + return ["contains", "notContains", "exists", "missing"]; + } + return ["eq", "ne", "in", "notIn", "contains", "notContains", "exists", "missing"]; +} + +function userFieldTypeToFilterInputType(type: string): string { + if (type === "number") return "number"; + if (type === "date") return "date"; + if (type === "boolean") return "none"; + if (type === "list") return "multi-select"; + return "text"; +} + +function normalizeRuntimeOperator(value: unknown): TaskNotesRuntimeOperator | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + const exact = RUNTIME_OPERATOR_BY_ALIAS.get(normalized); + if (exact) return exact; + if ((TASKNOTES_RUNTIME_QUERY_OPERATORS as readonly string[]).includes(normalized)) { + return normalized as TaskNotesRuntimeOperator; + } + return null; +} + +function runtimeOperatorRequiresValue(operator: TaskNotesRuntimeOperator): boolean { + return !["exists", "missing", "isTrue", "isFalse"].includes(operator); +} + +function emptyRuntimeTaskQuery(): TaskNotesRuntimeNormalizedTaskQuery { + return { + sort: [], + offset: 0, + group: [], + scope: { includeArchived: true }, + }; +} + +function normalizeRuntimeLimit( + value: unknown, + path: string, + issues: TaskNotesRuntimeQueryIssue[] +): number | undefined { + if (typeof value === "undefined" || value === null) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + issues.push({ + path, + code: "limit_invalid", + message: "Runtime query limit must be a positive integer.", + }); + return undefined; + } + return value; +} + +function normalizeRuntimeOffset( + value: unknown, + path: string, + issues: TaskNotesRuntimeQueryIssue[] +): number { + if (typeof value === "undefined" || value === null) return 0; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + issues.push({ + path, + code: "offset_invalid", + message: "Runtime query offset must be a non-negative integer.", + }); + return 0; + } + return value; +} + +function normalizeRuntimeScope( + value: unknown, + issues: TaskNotesRuntimeQueryIssue[] +): TaskNotesRuntimeNormalizedTaskQuery["scope"] { + if (typeof value === "undefined" || value === null) return { includeArchived: true }; + if (!isRecord(value)) { + issues.push({ + path: "$.scope", + code: "scope_not_object", + message: "Runtime query scope must be an object.", + }); + return { includeArchived: true }; + } + + return { + includeArchived: value["includeArchived"] !== false, + folders: normalizeFolderList(value["folders"], "$.scope.folders", issues), + excludeFolders: normalizeFolderList( + value["excludeFolders"], + "$.scope.excludeFolders", + issues + ), + }; +} + +function normalizeFolderList( + value: unknown, + path: string, + issues: TaskNotesRuntimeQueryIssue[] +): string[] | undefined { + if (typeof value === "undefined") return undefined; + if (!Array.isArray(value)) { + issues.push({ + path, + code: "folder_list_invalid", + message: "Runtime query folder scope must be an array of folder paths.", + }); + return undefined; + } + + const folders = value + .map((folder, index) => { + if (typeof folder !== "string" || folder.trim().length === 0) { + issues.push({ + path: `${path}.${index}`, + code: "folder_invalid", + message: "Runtime query folder path must be a non-empty string.", + }); + return null; + } + return normalizePath(folder); + }) + .filter(isDefined); + return folders.length > 0 ? folders : undefined; +} + +function normalizeRuntimeValue(value: unknown): TaskNotesRuntimeValue { + if (Array.isArray(value)) return value.map(normalizeRuntimeValue); + if (isRecord(value)) { + const fn = value["fn"]; + if (fn === "today") return { fn: "today" }; + if (fn === "now") return { fn: "now" }; + if (fn === "date" && typeof value["value"] === "string") { + return { fn: "date", value: value["value"] }; + } + if ( + fn === "dateAdd" && + Number.isFinite(value["amount"]) && + (value["unit"] === "day" || value["unit"] === "week" || value["unit"] === "month") + ) { + return { + fn: "dateAdd", + value: normalizeRuntimeValue(value["value"]), + amount: Number(value["amount"]), + unit: value["unit"], + }; + } + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ) { + return value; + } + return stringifyUnknownValue(value); +} + +function toFilterConditionValue( + value: TaskNotesRuntimeValue | undefined +): FilterCondition["value"] { + if (typeof value === "undefined") return null; + if (Array.isArray(value)) { + return value.map((entry) => String(toFilterConditionValue(entry))); + } + if (isRecord(value)) { + if (value["fn"] === "today") return "today"; + if (value["fn"] === "now") return new Date().toISOString(); + if (value["fn"] === "date" && typeof value["value"] === "string") return value["value"]; + if (value["fn"] === "dateAdd") return dateAddRuntimeValue(value); + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ) { + return value; + } + return stringifyUnknownValue(value); +} + +function stringifyUnknownValue(value: unknown): string { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } +} + +function dateAddRuntimeValue(value: { + fn: "dateAdd"; + value: TaskNotesRuntimeValue; + amount: number; + unit: "day" | "week" | "month"; +}): string { + const baseValue = toFilterConditionValue(value.value); + const base = + baseValue === "today" + ? new Date() + : typeof baseValue === "string" + ? new Date(baseValue) + : new Date(); + if (value.unit === "day") base.setDate(base.getDate() + value.amount); + if (value.unit === "week") base.setDate(base.getDate() + value.amount * 7); + if (value.unit === "month") base.setMonth(base.getMonth() + value.amount); + return base.toISOString().slice(0, 10); +} + +function invertRuntimePredicate( + predicate: TaskNotesRuntimeNormalizedPredicate +): TaskNotesRuntimeNormalizedPredicate | null { + if ("all" in predicate) { + return { any: predicate.all.map(invertRuntimePredicate).filter(isDefined) }; + } + if ("any" in predicate) { + return { all: predicate.any.map(invertRuntimePredicate).filter(isDefined) }; + } + if ("not" in predicate) return predicate.not; + const invertedOperator = invertRuntimeOperator(predicate.op); + return invertedOperator ? { ...predicate, op: invertedOperator } : null; +} + +function invertRuntimeOperator( + operator: TaskNotesRuntimeOperator +): TaskNotesRuntimeOperator | null { + const inverses: Partial> = { + eq: "ne", + ne: "eq", + contains: "notContains", + notContains: "contains", + in: "notIn", + notIn: "in", + exists: "missing", + missing: "exists", + lt: "gte", + lte: "gt", + gt: "lte", + gte: "lt", + isTrue: "isFalse", + isFalse: "isTrue", + }; + return inverses[operator] ?? null; +} + +function applyRuntimeQueryScope( + tasks: TaskInfo[], + scope: TaskNotesRuntimeNormalizedTaskQuery["scope"] +): TaskInfo[] { + return tasks.filter((task) => { + if (!scope.includeArchived && task.archived) return false; + if (scope.folders && !scope.folders.some((folder) => taskIsInFolder(task, folder))) { + return false; + } + if (scope.excludeFolders?.some((folder) => taskIsInFolder(task, folder))) return false; + return true; + }); +} + +function taskIsInFolder(task: TaskInfo, folder: string): boolean { + const normalizedFolder = normalizePath(folder); + if (!normalizedFolder) return true; + return task.path === normalizedFolder || task.path.startsWith(`${normalizedFolder}/`); +} + +function runtimeQueryGroups( + groupedTasks: Map, + returnedPaths: Set +): TaskNotesRuntimeQueryGroup[] { + const groups: TaskNotesRuntimeQueryGroup[] = []; + for (const [key, tasks] of groupedTasks.entries()) { + const taskPaths = tasks.map((task) => task.path).filter((path) => returnedPaths.has(path)); + if (taskPaths.length > 0) groups.push({ key, label: key, taskPaths }); + } + return groups; +} + +function runtimeNodeId(path: string): string { + return `runtime:${path.replace(/[^a-z0-9._-]+/giu, "-")}`; +} + +function isDefined(value: T | null | undefined): value is T { + return value !== null && typeof value !== "undefined"; +} + function taskReferencePathCandidates(path: string): string[] { const normalizedPath = normalizePath(path); const candidates = [normalizedPath]; @@ -2079,7 +3289,7 @@ function coerceDateOption(value: string | Date | null | undefined): Date | null } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); } function buildTaskChanges(before?: TaskInfo, after?: TaskInfo): TaskNotesApiChanges { diff --git a/src/api/runtime-api.ts b/src/api/runtime-api.ts index 8a068b1d..61f58444 100644 --- a/src/api/runtime-api.ts +++ b/src/api/runtime-api.ts @@ -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; - list(query?: FilterQuery): Promise; + list(query?: TaskNotesRuntimeTaskQuery): Promise; create(taskData: TaskCreationData, context?: TaskNotesMutationContext): Promise; update( path: string, @@ -858,15 +891,134 @@ export interface TaskNotesRuntimeErrorsApi { toResult(operation: () => Promise | T): Promise>; } +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> & + Omit; +} + +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; + matched: number; + returned: number; + groups?: TaskNotesRuntimeQueryGroup[]; + query: TaskNotesRuntimeNormalizedTaskQuery; + warnings?: TaskNotesRuntimeQueryWarning[]; } export interface TaskNotesRuntimeQueryApi { - tasks(query?: FilterQuery): Promise; + tasks(query?: TaskNotesRuntimeTaskQuery): Promise; + validate(query: unknown): TaskNotesRuntimeQueryValidationResult; + normalize(query: unknown): TaskNotesRuntimeNormalizedTaskQuery; + explain(query: unknown): Promise; filterOptions(): Promise; } @@ -884,7 +1036,7 @@ export interface TaskNotesRuntimeTaskStats { } export interface TaskNotesRuntimeStatsApi { - tasks(query?: FilterQuery): Promise; + tasks(query?: TaskNotesRuntimeTaskQuery): Promise; } export interface TaskNotesRuntimeVaultInfo { @@ -932,7 +1084,7 @@ export interface TaskNotesRuntimeApiV1 { parseNaturalLanguage(text: string): ParsedTaskData; getTask(path: string): Promise; - listTasks(query?: FilterQuery): Promise; + listTasks(query?: TaskNotesRuntimeTaskQuery): Promise; createTask(taskData: TaskCreationData, context?: TaskNotesMutationContext): Promise; updateTask( path: string, diff --git a/tests/unit/api/tasknotes-api-v1.test.ts b/tests/unit/api/tasknotes-api-v1.test.ts index c2a6756e..a6e5a8c9 100644 --- a/tests/unit/api/tasknotes-api-v1.test.ts +++ b/tests/unit/api/tasknotes-api-v1.test.ts @@ -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(