From dfdb13b7fa1ac6ba198d5bd81103e6f9f09035d2 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 1 Jun 2026 19:26:00 +1000 Subject: [PATCH] add runtime api catalogs --- docs/javascript-api.md | 222 +++++++----- docs/releases/unreleased.md | 2 +- src/api/TaskNotesAPI.ts | 461 ++++++++++++++++++++++-- src/api/runtime-api.ts | 126 +++++-- tests/unit/api/tasknotes-api-v1.test.ts | 103 +++++- 5 files changed, 758 insertions(+), 156 deletions(-) diff --git a/docs/javascript-api.md b/docs/javascript-api.md index c4d80fc6..03097830 100644 --- a/docs/javascript-api.md +++ b/docs/javascript-api.md @@ -24,7 +24,7 @@ const tasknotes = app.plugins.plugins.tasknotes; const api = tasknotes?.api; if (!api || api.apiVersion !== 1 || !api.hasCapability("tasks.write")) { - throw new Error("This workflow requires TaskNotes runtime API v1 task writes"); + throw new Error("This workflow requires TaskNotes runtime API v1 task writes"); } ``` @@ -32,6 +32,7 @@ Current capabilities: - `model.read` - `model.validate` +- `catalog.read` - `extensions.read` - `extensions.register` - `tasks.read` @@ -73,20 +74,20 @@ const tasknotes = this.app.plugins.getPlugin("tasknotes"); const api = tasknotes?.api; if (!api?.hasCapability("extensions.register")) { - return; + return; } const handle = api.extensions.register({ - id: this.manifest.id, - namespace: "tasknotes-workflows", - displayName: "TaskNotes Workflows", - version: this.manifest.version, - capabilities: ["tasknotes-workflows.run", "tasknotes-workflows.events"], - api: { - runWorkflow: async (workflowId, input) => { - // companion plugin implementation - } - } + id: this.manifest.id, + namespace: "tasknotes-workflows", + displayName: "TaskNotes Workflows", + version: this.manifest.version, + capabilities: ["tasknotes-workflows.run", "tasknotes-workflows.events"], + api: { + runWorkflow: async (workflowId, input) => { + // companion plugin implementation + }, + }, }); this.register(() => handle.unregister()); @@ -98,7 +99,7 @@ Other plugins can discover and consume extension namespaces: const workflows = api.extensions.get("tasknotes-workflows"); if (api.hasCapability("tasknotes-workflows.run")) { - await workflows.runWorkflow("start-timer-on-active", { taskPath: "Tasks/example.md" }); + await workflows.runWorkflow("start-timer-on-active", { taskPath: "Tasks/example.md" }); } ``` @@ -113,13 +114,13 @@ const tasknotes = this.app.plugins.getPlugin("tasknotes"); const api = tasknotes?.api; if (!api?.hasCapability("tasks.events")) { - return; + return; } this.registerEvent( - api.events.on("task.status.changed", (event) => { - console.log(event.taskPath, event.changes.status); - }) + api.events.on("task.status.changed", (event) => { + console.log(event.taskPath, event.changes.status); + }) ); ``` @@ -133,14 +134,14 @@ Use the exported runtime contract for type safety: import type { TaskNotesRuntimeApiV1 } from "tasknotes/src/api/runtime-api"; type TaskNotesPluginInstance = { - api?: TaskNotesRuntimeApiV1; + api?: TaskNotesRuntimeApiV1; }; const tasknotes = app.plugins.getPlugin("tasknotes") as TaskNotesPluginInstance | null; const api = tasknotes?.api; if (api?.apiVersion === 1 && api.hasCapability("tasks.write")) { - await api.tasks.setStatus("Tasks/example.md", "active"); + await api.tasks.setStatus("Tasks/example.md", "active"); } ``` @@ -156,54 +157,77 @@ const config = api.model.config(); const validation = api.model.validateTask(task); ``` -| Method | Description | -| --- | --- | -| `api.model.info()` | Returns the model package name, TaskNotes spec version, and runtime API version. | -| `api.model.config()` | Returns a resolved, Obsidian-free model configuration snapshot derived from TaskNotes settings. | -| `api.model.validateTask(task)` | Validates a partial or complete task against the shared model schema and TaskNotes status rules. | -| `api.model.validatePatch(patch)` | Validates a task patch before passing it to a runtime task mutation. | +| Method | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------ | +| `api.model.info()` | Returns the model package name, TaskNotes spec version, and runtime API version. | +| `api.model.config()` | Returns a resolved, Obsidian-free model configuration snapshot derived from TaskNotes settings. | +| `api.model.validateTask(task)` | Validates a partial or complete task against the shared model schema and TaskNotes status rules. | +| `api.model.validatePatch(patch)` | Validates a task patch before passing it to a runtime task mutation. | + +## Catalog + +Use `api.catalog` to build companion-plugin editors without hardcoding TaskNotes values: + +```javascript +const statuses = api.catalog.statuses(); +const writableFields = api.catalog.writableFields(); +const operators = api.catalog.filterOperators(); +``` + +| Method | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `api.catalog.statuses()` | Returns configured TaskNotes status definitions. | +| `api.catalog.priorities()` | Returns configured TaskNotes priority definitions. | +| `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.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()`. | ## Tasks 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 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`. | Example: ```javascript const task = await api.tasks.create( - { - title: "Review automation design", - status: "open", - priority: "normal", - scheduled: "2026-06-01", - tags: ["tasknotes"] - }, - { - source: "my-companion-plugin", - correlationId: crypto.randomUUID(), - reason: "manual workflow command" - } + { + title: "Review automation design", + status: "open", + priority: "normal", + scheduled: "2026-06-01", + tags: ["tasknotes"], + }, + { + source: "my-companion-plugin", + correlationId: crypto.randomUUID(), + reason: "manual workflow command", + } ); await api.tasks.complete(task.path); @@ -213,13 +237,13 @@ await api.tasks.complete(task.path); Relationship methods resolve TaskNotes' project-as-parent links and `blockedBy` dependencies into task records where possible. -| Method | Description | -| --- | --- | -| `api.relationships.parents(path)` | Returns parent tasks referenced from the task's projects. | -| `api.relationships.subtasks(path)` | Returns tasks that reference this task as a project. | -| `api.relationships.dependencies(path)` | Returns `blockedBy` dependencies with resolved task data when available. | -| `api.relationships.blocking(path)` | Returns tasks that are blocked by this task. | -| `api.relationships.all(path)` | Returns the task plus parents, subtasks, dependencies, and blocking tasks. | +| Method | Description | +| -------------------------------------- | -------------------------------------------------------------------------- | +| `api.relationships.parents(path)` | Returns parent tasks referenced from the task's projects. | +| `api.relationships.subtasks(path)` | Returns tasks that reference this task as a project. | +| `api.relationships.dependencies(path)` | Returns `blockedBy` dependencies with resolved task data when available. | +| `api.relationships.blocking(path)` | Returns tasks that are blocked by this task. | +| `api.relationships.all(path)` | Returns the task plus parents, subtasks, dependencies, and blocking tasks. | Example: @@ -227,39 +251,39 @@ Example: const relationships = await api.relationships.all("Tasks/example.md"); for (const subtask of relationships.subtasks) { - await api.tasks.setPriority(subtask.path, relationships.task.priority); + await api.tasks.setPriority(subtask.path, relationships.task.priority); } ``` ## Time Tracking -| Method | Description | -| --- | --- | -| `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.append(path, entry, context?)` | Appends a time entry. | -| `api.time.deleteEntry(path, entryIndex, context?)` | Deletes a time entry through TaskNotes' service. | +| Method | Description | +| -------------------------------------------------- | -------------------------------------------------------------------- | +| `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.append(path, entry, context?)` | Appends a time entry. | +| `api.time.deleteEntry(path, entryIndex, context?)` | Deletes a time entry through TaskNotes' service. | ## Pomodoro -| Method | Description | -| --- | --- | -| `api.pomodoro.status()` | Returns current Pomodoro state. | -| `api.pomodoro.start(options?, context?)` | Starts a Pomodoro session. | -| `api.pomodoro.stop(context?)` | Stops and resets the active Pomodoro session. | -| `api.pomodoro.pause(context?)` | Pauses the current session. | -| `api.pomodoro.resume(context?)` | Resumes a paused session. | -| `api.pomodoro.assignTask(pathOrNull, context?)` | Assigns or clears the current session task. | -| `api.pomodoro.sessions(options?)` | Returns Pomodoro session history. | -| `api.pomodoro.stats(date?)` | Returns Pomodoro stats for a date or today. | +| Method | Description | +| ----------------------------------------------- | --------------------------------------------- | +| `api.pomodoro.status()` | Returns current Pomodoro state. | +| `api.pomodoro.start(options?, context?)` | Starts a Pomodoro session. | +| `api.pomodoro.stop(context?)` | Stops and resets the active Pomodoro session. | +| `api.pomodoro.pause(context?)` | Pauses the current session. | +| `api.pomodoro.resume(context?)` | Resumes a paused session. | +| `api.pomodoro.assignTask(pathOrNull, context?)` | Assigns or clears the current session task. | +| `api.pomodoro.sessions(options?)` | Returns Pomodoro session history. | +| `api.pomodoro.stats(date?)` | Returns Pomodoro stats for a date or today. | ## Recurring Tasks -| Method | Description | -| --- | --- | -| `api.recurring.toggleCompleteInstance(path, date?, context?)` | Toggles completion for a recurring task instance. | -| `api.recurring.toggleSkippedInstance(path, date?, context?)` | Toggles skipped state for a recurring task instance. | +| Method | Description | +| ------------------------------------------------------------- | ---------------------------------------------------- | +| `api.recurring.toggleCompleteInstance(path, date?, context?)` | Toggles completion for a recurring task instance. | +| `api.recurring.toggleSkippedInstance(path, date?, context?)` | Toggles skipped state for a recurring task instance. | ## Natural Language Parser @@ -376,21 +400,21 @@ Example automation-style listener: const source = "my-workflow-plugin"; this.registerEvent( - api.events.on("task.status.changed", async (event) => { - if (event.source === source) { - return; - } + api.events.on("task.status.changed", async (event) => { + if (event.source === source) { + return; + } - if (event.after?.status !== "active") { - return; - } + if (event.after?.status !== "active") { + return; + } - await api.time.start(event.after.path, undefined, { - source, - correlationId: event.correlationId ?? crypto.randomUUID(), - reason: "status changed to active" - }); - }) + await api.time.start(event.after.path, undefined, { + source, + correlationId: event.correlationId ?? crypto.randomUUID(), + reason: "status changed to active", + }); + }) ); ``` diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 086c12ef..35ed1b8b 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, 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, 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 db63d51b..5e76966c 100644 --- a/src/api/TaskNotesAPI.ts +++ b/src/api/TaskNotesAPI.ts @@ -8,12 +8,10 @@ import { type TaskValidationResult, } from "@tasknotes/model"; import type TaskNotesPlugin from "../main"; -import { - NaturalLanguageParser, - type ParsedTaskData, -} from "../services/NaturalLanguageParser"; +import { NaturalLanguageParser, type ParsedTaskData } from "../services/NaturalLanguageParser"; import type { FilterQuery, + FILTER_PROPERTIES, PomodoroHistoryStats, PomodoroSessionHistory, PomodoroState, @@ -43,6 +41,11 @@ import { type PomodoroStartOptions, type ResolvedTaskDependency, type StartTimeEntryOptions, + type TaskNotesRuntimeDependencyRelTypeDefinition, + type TaskNotesRuntimeFieldDefinition, + type TaskNotesRuntimeFilterOperatorDefinition, + type TaskNotesRuntimeFilterPropertyDefinition, + type TaskNotesRuntimeRelationshipDefinition, type TaskNotesApiChanges, type TaskNotesApiEvent, type TaskNotesApiEventHandler, @@ -96,6 +99,323 @@ const RESERVED_RUNTIME_EXTENSION_NAMESPACES = new Set([ "time", ]); +const CORE_FIELD_DEFINITIONS: ReadonlyArray< + Omit +> = [ + { + id: "title", + label: "Title", + valueType: "string", + source: "model", + writable: true, + required: true, + }, + { + id: "status", + label: "Status", + valueType: "string", + source: "model", + writable: true, + required: true, + }, + { + id: "priority", + label: "Priority", + valueType: "string", + source: "model", + writable: true, + required: true, + }, + { id: "due", label: "Due", valueType: "date", source: "model", writable: true }, + { id: "scheduled", label: "Scheduled", valueType: "date", source: "model", writable: true }, + { id: "archived", label: "Archived", valueType: "boolean", source: "model", writable: true }, + { id: "tags", label: "Tags", valueType: "string[]", source: "model", writable: true }, + { id: "contexts", label: "Contexts", valueType: "string[]", source: "model", writable: true }, + { id: "projects", label: "Projects", valueType: "string[]", source: "model", writable: true }, + { id: "recurrence", label: "Recurrence", valueType: "string", source: "model", writable: true }, + { + id: "recurrence_anchor", + label: "Recurrence anchor", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "complete_instances", + label: "Complete instances", + valueType: "string[]", + source: "model", + writable: true, + }, + { + id: "skipped_instances", + label: "Skipped instances", + valueType: "string[]", + source: "model", + writable: true, + }, + { + id: "recurrence_parent", + label: "Recurrence parent", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "occurrence_date", + label: "Occurrence date", + valueType: "date", + source: "model", + writable: true, + }, + { + id: "occurrence_materialization", + label: "Occurrence materialization", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "occurrence_next_trigger", + label: "Occurrence next trigger", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "occurrence_template", + label: "Occurrence template", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "occurrence_past_horizon", + label: "Occurrence past horizon", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "occurrence_future_horizon", + label: "Occurrence future horizon", + valueType: "string", + source: "model", + writable: true, + }, + { + id: "completedDate", + label: "Completed date", + valueType: "date", + source: "model", + writable: true, + }, + { + id: "timeEstimate", + label: "Time estimate", + valueType: "number", + source: "model", + writable: true, + }, + { + id: "timeEntries", + label: "Time entries", + valueType: "timeEntry[]", + source: "model", + writable: true, + }, + { + id: "dateCreated", + label: "Date created", + valueType: "datetime", + source: "model", + writable: true, + }, + { + id: "dateModified", + label: "Date modified", + valueType: "datetime", + source: "model", + writable: true, + }, + { + id: "reminders", + label: "Reminders", + valueType: "reminder[]", + source: "model", + writable: true, + }, + { + id: "blockedBy", + label: "Blocked by", + valueType: "dependency[]", + source: "model", + writable: true, + }, + { id: "details", label: "Details", valueType: "string", source: "model", writable: true }, + { id: "sortOrder", label: "Sort order", valueType: "string", source: "model", writable: true }, + { id: "path", label: "Path", valueType: "string", source: "model", writable: false }, + { + id: "totalTrackedTime", + label: "Total tracked time", + valueType: "number", + source: "computed", + writable: false, + }, + { + id: "blocking", + label: "Blocking", + valueType: "string[]", + source: "computed", + writable: false, + }, + { + id: "isBlocked", + label: "Is blocked", + valueType: "boolean", + source: "computed", + writable: false, + }, + { + id: "isBlocking", + label: "Is blocking", + valueType: "boolean", + source: "computed", + writable: false, + }, + { + id: "hasSubtasks", + label: "Has subtasks", + valueType: "boolean", + source: "computed", + writable: false, + }, +]; + +const FIELD_MAPPING_KEY_BY_FIELD_ID: Partial< + Record +> = { + title: "title", + status: "status", + priority: "priority", + due: "due", + scheduled: "scheduled", + contexts: "contexts", + projects: "projects", + recurrence: "recurrence", + recurrence_anchor: "recurrenceAnchor", + complete_instances: "completeInstances", + skipped_instances: "skippedInstances", + recurrence_parent: "recurrenceParent", + occurrence_date: "occurrenceDate", + occurrence_materialization: "occurrenceMaterialization", + occurrence_next_trigger: "occurrenceNextTrigger", + occurrence_template: "occurrenceTemplate", + occurrence_past_horizon: "occurrencePastHorizon", + occurrence_future_horizon: "occurrenceFutureHorizon", + completedDate: "completedDate", + timeEstimate: "timeEstimate", + timeEntries: "timeEntries", + dateCreated: "dateCreated", + dateModified: "dateModified", + reminders: "reminders", + blockedBy: "blockedBy", + sortOrder: "sortOrder", +}; + +const FILTER_OPERATOR_DEFINITIONS: readonly TaskNotesRuntimeFilterOperatorDefinition[] = [ + { + id: "is", + label: "is", + valueRequired: true, + appliesTo: ["text", "select", "date", "numeric"], + }, + { + id: "is-not", + label: "is not", + valueRequired: true, + appliesTo: ["text", "select", "date", "numeric"], + }, + { id: "contains", label: "contains", valueRequired: true, appliesTo: ["text", "select"] }, + { + id: "does-not-contain", + 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"], + }, + { + 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", + valueRequired: true, + appliesTo: ["numeric"], + }, + { id: "is-less-than", label: "is less than", valueRequired: true, appliesTo: ["numeric"] }, + { + id: "is-greater-than-or-equal", + label: "is greater than or equal", + valueRequired: true, + appliesTo: ["numeric"], + }, + { + id: "is-less-than-or-equal", + label: "is less than or equal", + valueRequired: true, + appliesTo: ["numeric"], + }, +]; + +const RELATIONSHIP_DEFINITIONS: readonly TaskNotesRuntimeRelationshipDefinition[] = [ + { id: "parents", label: "Parents", description: "Tasks referenced from this task's projects." }, + { + id: "subtasks", + label: "Subtasks", + description: "Tasks that reference this task as a project.", + }, + { id: "dependencies", label: "Dependencies", description: "Tasks this task is blocked by." }, + { id: "blocking", label: "Blocking", description: "Tasks blocked by this task." }, +]; + +const DEPENDENCY_REL_TYPE_DEFINITIONS: readonly TaskNotesRuntimeDependencyRelTypeDefinition[] = [ + { + value: "FINISHTOSTART", + label: "Finish to start", + description: "The blocking task should finish before this task starts.", + }, + { + value: "FINISHTOFINISH", + label: "Finish to finish", + description: "The blocking task should finish before this task finishes.", + }, + { + value: "STARTTOSTART", + label: "Start to start", + description: "The blocking task should start before this task starts.", + }, + { + value: "STARTTOFINISH", + label: "Start to finish", + description: "The blocking task should start before this task finishes.", + }, +]; + export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { readonly apiVersion = TASKNOTES_RUNTIME_API_VERSION; @@ -110,6 +430,20 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { validatePatch: (patch: TaskNotesTaskPatch) => this.validateTaskPatch(patch), }; + readonly catalog = { + statuses: () => this.getStatuses(), + priorities: () => this.getPriorities(), + userFields: () => this.getUserFields(), + fields: () => this.getFieldDefinitions(), + writableFields: () => this.getFieldDefinitions().filter((field) => field.writable), + filterProperties: () => this.getFilterPropertyDefinitions(), + filterOperators: () => FILTER_OPERATOR_DEFINITIONS.map((operator) => ({ ...operator })), + relationships: () => RELATIONSHIP_DEFINITIONS.map((relationship) => ({ ...relationship })), + dependencyRelTypes: () => + DEPENDENCY_REL_TYPE_DEFINITIONS.map((relationshipType) => ({ ...relationshipType })), + events: () => this.events.list(), + }; + readonly tasks = { get: (path: string) => this.getTask(path), list: (query?: FilterQuery) => this.listTasks(query), @@ -161,11 +495,7 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { this.updateStringList(path, "contexts", contextName, "add", context), removeContext: (path: string, contextName: string, context?: TaskNotesMutationContext) => this.updateStringList(path, "contexts", contextName, "remove", context), - setReminders: ( - path: string, - reminders: Reminder[], - context?: TaskNotesMutationContext - ) => + setReminders: (path: string, reminders: Reminder[], context?: TaskNotesMutationContext) => this.updateTask( path, { reminders: reminders.map((reminder) => ({ ...reminder })) }, @@ -207,7 +537,8 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { }; readonly pomodoro = { - status: () => Promise.resolve(this.copyPomodoroState(this.plugin.pomodoroService.getState())), + status: () => + Promise.resolve(this.copyPomodoroState(this.plugin.pomodoroService.getState())), start: (options?: PomodoroStartOptions, context?: TaskNotesMutationContext) => this.startPomodoro(options, context), stop: (context?: TaskNotesMutationContext) => this.stopPomodoro(context), @@ -220,16 +551,10 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { }; readonly recurring = { - toggleCompleteInstance: ( - path: string, - date?: string, - context?: TaskNotesMutationContext - ) => this.toggleRecurringComplete(path, date, context), - toggleSkippedInstance: ( - path: string, - date?: string, - context?: TaskNotesMutationContext - ) => this.toggleRecurringSkipped(path, date, context), + toggleCompleteInstance: (path: string, date?: string, context?: TaskNotesMutationContext) => + this.toggleRecurringComplete(path, date, context), + toggleSkippedInstance: (path: string, date?: string, context?: TaskNotesMutationContext) => + this.toggleRecurringSkipped(path, date, context), }; readonly events = { @@ -321,6 +646,52 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { return validateModelTask(patch); } + private getStatuses() { + return (this.plugin.settings.customStatuses ?? []).map((status) => ({ ...status })); + } + + private getPriorities() { + return (this.plugin.settings.customPriorities ?? []).map((priority) => ({ ...priority })); + } + + private getUserFields() { + return (this.plugin.settings.userFields ?? []).map((field) => ({ ...field })); + } + + private getFieldDefinitions(): TaskNotesRuntimeFieldDefinition[] { + const mapping = this.plugin.settings.fieldMapping ?? {}; + const coreFields = CORE_FIELD_DEFINITIONS.map((field) => { + const mappingKey = FIELD_MAPPING_KEY_BY_FIELD_ID[field.id]; + return { + ...field, + frontmatterKey: mappingKey ? mapping[mappingKey] : undefined, + }; + }); + const userFields = this.getUserFields().map( + (field): TaskNotesRuntimeFieldDefinition => ({ + id: `user:${field.id || field.key}`, + label: field.displayName || field.key, + valueType: userFieldTypeToRuntimeValueType(field.type), + source: "user", + writable: true, + frontmatterKey: field.key, + description: `User-defined field ${field.key}`, + }) + ); + + return [...coreFields, ...userFields]; + } + + private getFilterPropertyDefinitions(): TaskNotesRuntimeFilterPropertyDefinition[] { + return FILTER_PROPERTIES.map((property) => ({ + id: property.id, + label: property.label, + category: property.category, + supportedOperators: [...property.supportedOperators], + valueInputType: property.valueInputType, + })); + } + async getTask(path: string): Promise { const task = await this.plugin.cacheManager.getTaskInfo(this.normalizeTaskPath(path)); return task ? copyTaskInfo(task) : null; @@ -771,7 +1142,9 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { throw new Error(`Cannot register TaskNotes API extension namespace "${namespace}"`); } if (this.extensionRegistry.has(namespace)) { - throw new Error(`TaskNotes API extension namespace "${namespace}" is already registered`); + throw new Error( + `TaskNotes API extension namespace "${namespace}" is already registered` + ); } const token = Symbol(namespace); @@ -816,7 +1189,9 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { const normalizedNamespace = this.normalizeExtensionNamespace(namespace); const extension = this.extensionRegistry.get(normalizedNamespace); if (!extension) { - throw new Error(`TaskNotes API extension namespace "${normalizedNamespace}" is not registered`); + throw new Error( + `TaskNotes API extension namespace "${normalizedNamespace}" is not registered` + ); } return extension.api as TApi; } @@ -881,7 +1256,9 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { } private async stopPomodoro(context?: TaskNotesMutationContext): Promise { - await this.withMutationContext([], context, () => this.plugin.pomodoroService.stopPomodoro()); + await this.withMutationContext([], context, () => + this.plugin.pomodoroService.stopPomodoro() + ); return this.copyPomodoroState(this.plugin.pomodoroService.getState()); } @@ -916,7 +1293,8 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { let sessions = await this.plugin.pomodoroService.getSessionHistory(); if (options?.date) { sessions = sessions.filter( - (session) => new Date(session.startTime).toISOString().split("T")[0] === options.date + (session) => + new Date(session.startTime).toISOString().split("T")[0] === options.date ); } if (options?.limit && options.limit > 0) { @@ -982,7 +1360,10 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { this.plugin.emitter.offref(ref); } - private async resolveTaskReference(reference: string, sourcePath: string): Promise { + private async resolveTaskReference( + reference: string, + sourcePath: string + ): Promise { const path = await this.resolveTaskReferencePath(reference, sourcePath); return path ? await this.plugin.cacheManager.getTaskInfo(path) : null; } @@ -1155,11 +1536,19 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { events.push({ ...common, event: "task.recurrence.changed" }); } - if (before && after && hasNewArrayValue(before.complete_instances, after.complete_instances)) { + if ( + before && + after && + hasNewArrayValue(before.complete_instances, after.complete_instances) + ) { events.push({ ...common, event: "recurring.instance.completed" }); } - if (before && after && hasNewArrayValue(before.skipped_instances, after.skipped_instances)) { + if ( + before && + after && + hasNewArrayValue(before.skipped_instances, after.skipped_instances) + ) { events.push({ ...common, event: "recurring.instance.skipped" }); } @@ -1311,6 +1700,24 @@ function copyTaskDependency(dependency: TaskDependency): TaskDependency { return { ...dependency }; } +function userFieldTypeToRuntimeValueType( + type: string +): TaskNotesRuntimeFieldDefinition["valueType"] { + switch (type) { + case "number": + return "number"; + case "date": + return "date"; + case "boolean": + return "boolean"; + case "list": + return "string[]"; + case "text": + default: + return "string"; + } +} + 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 ab46e324..dfa6413c 100644 --- a/src/api/runtime-api.ts +++ b/src/api/runtime-api.ts @@ -1,12 +1,15 @@ import type { EventRef } from "obsidian"; import type { Reminder, + PriorityConfig, + StatusConfig, TaskCreationData, TaskDependency, TaskInfo, TaskNotesModelConfig, TaskValidationResult, TimeEntry, + UserMappedField, } from "@tasknotes/model"; import type { ParsedTaskData } from "../services/NaturalLanguageParser"; import type { @@ -20,6 +23,8 @@ import type { TaskNotesSettings } from "../types/settings"; export type { Reminder, + PriorityConfig, + StatusConfig, TaskCreationData, TaskDependency, TaskInfo, @@ -27,6 +32,7 @@ export type { TaskValidationIssue, TaskValidationResult, TimeEntry, + UserMappedField, } from "@tasknotes/model"; export const TASKNOTES_RUNTIME_API_VERSION = 1 as const; @@ -34,6 +40,7 @@ export const TASKNOTES_RUNTIME_API_VERSION = 1 as const; export const TASKNOTES_RUNTIME_API_CAPABILITIES = [ "model.read", "model.validate", + "catalog.read", "extensions.read", "extensions.register", "tasks.read", @@ -56,9 +63,7 @@ export const TASKNOTES_RUNTIME_API_CAPABILITIES = [ export type TaskNotesRuntimeApiVersion = typeof TASKNOTES_RUNTIME_API_VERSION; export type TaskNotesRuntimeCoreCapability = (typeof TASKNOTES_RUNTIME_API_CAPABILITIES)[number]; -export type TaskNotesRuntimeApiCapability = - | TaskNotesRuntimeCoreCapability - | (string & {}); +export type TaskNotesRuntimeApiCapability = TaskNotesRuntimeCoreCapability | (string & {}); export type TaskNotesTaskEventName = | "task.created" @@ -80,15 +85,9 @@ export type TaskNotesTaskEventName = | "task.dependencies.changed" | "task.recurrence.changed"; -export type TaskNotesRuntimeEventName = - | TaskNotesTaskEventName - | WebhookEvent; +export type TaskNotesRuntimeEventName = TaskNotesTaskEventName | WebhookEvent; -export type TaskNotesRuntimeEventCategory = - | "task" - | "time" - | "pomodoro" - | "recurring"; +export type TaskNotesRuntimeEventCategory = "task" | "time" | "pomodoro" | "recurring"; export interface TaskNotesRuntimeEventDefinition { name: TaskNotesRuntimeEventName; @@ -316,6 +315,70 @@ export interface TaskNotesRuntimeModelApi { validatePatch(patch: TaskNotesTaskPatch): TaskValidationResult; } +export type TaskNotesRuntimeFieldSource = "model" | "computed" | "user"; +export type TaskNotesRuntimeFieldValueType = + | "string" + | "number" + | "boolean" + | "date" + | "datetime" + | "string[]" + | "timeEntry[]" + | "dependency[]" + | "reminder[]" + | "unknown"; + +export interface TaskNotesRuntimeFieldDefinition { + id: string; + label: string; + valueType: TaskNotesRuntimeFieldValueType; + source: TaskNotesRuntimeFieldSource; + writable: boolean; + required?: boolean; + frontmatterKey?: string; + description?: string; +} + +export interface TaskNotesRuntimeFilterOperatorDefinition { + id: string; + label: string; + valueRequired: boolean; + appliesTo: readonly string[]; +} + +export interface TaskNotesRuntimeFilterPropertyDefinition { + id: string; + label: string; + category: string; + supportedOperators: readonly string[]; + valueInputType: string; +} + +export interface TaskNotesRuntimeRelationshipDefinition { + id: "parents" | "subtasks" | "dependencies" | "blocking"; + label: string; + description: string; +} + +export interface TaskNotesRuntimeDependencyRelTypeDefinition { + value: TaskDependency["reltype"]; + label: string; + description: string; +} + +export interface TaskNotesRuntimeCatalogApi { + statuses(): StatusConfig[]; + priorities(): PriorityConfig[]; + userFields(): UserMappedField[]; + fields(): TaskNotesRuntimeFieldDefinition[]; + writableFields(): TaskNotesRuntimeFieldDefinition[]; + filterProperties(): TaskNotesRuntimeFilterPropertyDefinition[]; + filterOperators(): TaskNotesRuntimeFilterOperatorDefinition[]; + relationships(): TaskNotesRuntimeRelationshipDefinition[]; + dependencyRelTypes(): TaskNotesRuntimeDependencyRelTypeDefinition[]; + events(): readonly TaskNotesRuntimeEventDefinition[]; +} + export interface TaskNotesRuntimeExtension { id: string; namespace: string; @@ -406,32 +469,32 @@ export interface TaskNotesRuntimeTasksApi { ): Promise; setDue(path: string, date: string, context?: TaskNotesMutationContext): Promise; clearDue(path: string, context?: TaskNotesMutationContext): Promise; - setScheduled( - path: string, - date: string, - context?: TaskNotesMutationContext - ): Promise; + setScheduled(path: string, date: string, context?: TaskNotesMutationContext): Promise; clearScheduled(path: string, context?: TaskNotesMutationContext): Promise; reschedule( path: string, date: string | null, context?: TaskNotesMutationContext ): Promise; - archive( - path: string, - archived: boolean, - context?: TaskNotesMutationContext - ): Promise; + archive(path: string, archived: boolean, context?: TaskNotesMutationContext): Promise; move(path: string, targetFolder: string, context?: TaskNotesMutationContext): Promise; addTag(path: string, tag: string, context?: TaskNotesMutationContext): Promise; removeTag(path: string, tag: string, context?: TaskNotesMutationContext): Promise; - addProject(path: string, project: string, context?: TaskNotesMutationContext): Promise; + addProject( + path: string, + project: string, + context?: TaskNotesMutationContext + ): Promise; removeProject( path: string, project: string, context?: TaskNotesMutationContext ): Promise; - addContext(path: string, contextName: string, context?: TaskNotesMutationContext): Promise; + addContext( + path: string, + contextName: string, + context?: TaskNotesMutationContext + ): Promise; removeContext( path: string, contextName: string, @@ -442,7 +505,11 @@ export interface TaskNotesRuntimeTasksApi { reminders: Reminder[], context?: TaskNotesMutationContext ): Promise; - addReminder(path: string, reminder: Reminder, context?: TaskNotesMutationContext): Promise; + addReminder( + path: string, + reminder: Reminder, + context?: TaskNotesMutationContext + ): Promise; removeReminder( path: string, reminderId: string, @@ -486,7 +553,10 @@ export interface TaskNotesRuntimeTimeApi { export interface TaskNotesRuntimePomodoroApi { status(): Promise; - start(options?: PomodoroStartOptions, context?: TaskNotesMutationContext): Promise; + start( + options?: PomodoroStartOptions, + context?: TaskNotesMutationContext + ): Promise; stop(context?: TaskNotesMutationContext): Promise; pause(context?: TaskNotesMutationContext): Promise; resume(context?: TaskNotesMutationContext): Promise; @@ -540,6 +610,7 @@ export interface TaskNotesRuntimeApiV1 { hasCapability(capability: string): boolean; readonly model: TaskNotesRuntimeModelApi; + readonly catalog: TaskNotesRuntimeCatalogApi; readonly tasks: TaskNotesRuntimeTasksApi; readonly relationships: TaskNotesRuntimeRelationshipsApi; readonly time: TaskNotesRuntimeTimeApi; @@ -595,8 +666,9 @@ export type TaskNotesPublicAPI = TaskNotesRuntimeApiV1; export type TaskNotesApiV1 = TaskNotesRuntimeApiV1; export type TaskNotesApiEvent = TaskNotesRuntimeEventName; export type TaskNotesApiEventPayload = TaskNotesRuntimeEventPayload; -export type TaskNotesApiEventHandler = - TaskNotesRuntimeEventHandler; +export type TaskNotesApiEventHandler< + EventName extends TaskNotesRuntimeEventName = TaskNotesRuntimeEventName, +> = TaskNotesRuntimeEventHandler; export type TaskNotesApiCapability = TaskNotesRuntimeApiCapability; export type TaskNotesApiCoreCapability = TaskNotesRuntimeCoreCapability; export type TaskNotesApiVersion = TaskNotesRuntimeApiVersion; diff --git a/tests/unit/api/tasknotes-api-v1.test.ts b/tests/unit/api/tasknotes-api-v1.test.ts index 1607fec4..713968a4 100644 --- a/tests/unit/api/tasknotes-api-v1.test.ts +++ b/tests/unit/api/tasknotes-api-v1.test.ts @@ -333,10 +333,71 @@ function createPluginContext(initialTasks: TaskInfo[] = [createTask()]): TestPlu settings: { defaultTaskStatus: "open", defaultTaskPriority: "normal", + taskTag: "task", customStatuses: [ - { value: "open", isCompleted: false }, - { value: "done", isCompleted: true }, + { + id: "open", + value: "open", + label: "Open", + color: "#888888", + isCompleted: false, + order: 0, + autoArchive: false, + autoArchiveDelay: 0, + }, + { + id: "done", + value: "done", + label: "Done", + color: "#00aa00", + isCompleted: true, + order: 1, + autoArchive: false, + autoArchiveDelay: 0, + }, ], + customPriorities: [ + { id: "normal", value: "normal", label: "Normal", color: "#888888", weight: 0 }, + { id: "high", value: "high", label: "High", color: "#ff0000", weight: 10 }, + ], + userFields: [{ id: "energy", displayName: "Energy", key: "energy", type: "number" }], + fieldMapping: { + title: "title", + status: "status", + priority: "priority", + due: "due", + scheduled: "scheduled", + contexts: "contexts", + projects: "projects", + timeEstimate: "timeEstimate", + completedDate: "completedDate", + dateCreated: "dateCreated", + dateModified: "dateModified", + recurrence: "recurrence", + recurrenceAnchor: "recurrence_anchor", + recurrenceParent: "recurrence_parent", + occurrenceDate: "occurrence_date", + occurrenceMaterialization: "occurrence_materialization", + occurrenceNextTrigger: "occurrence_next_trigger", + occurrenceTemplate: "occurrence_template", + occurrencePastHorizon: "occurrence_past_horizon", + occurrenceFutureHorizon: "occurrence_future_horizon", + archiveTag: "archived", + timeEntries: "timeEntries", + completeInstances: "complete_instances", + skippedInstances: "skipped_instances", + blockedBy: "blockedBy", + pomodoros: "pomodoros", + icsEventId: "icsEventId", + icsEventTag: "ics-event", + googleCalendarEventId: "googleCalendarEventId", + googleCalendarExceptionEventId: "googleCalendarExceptionEventId", + googleCalendarExceptionOriginalScheduled: + "googleCalendarExceptionOriginalScheduled", + googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", + reminders: "reminders", + sortOrder: "sortOrder", + }, }, statusManager: { getCompletedStatuses: jest.fn(() => ["done"]), @@ -414,6 +475,44 @@ describe("TaskNotesApiV1", () => { ); }); + it("exposes catalog metadata for companion plugin editors", () => { + const { plugin } = createPluginContext(); + const api = new TaskNotesAPI(plugin); + + expect(api.catalog.statuses()).toEqual([ + expect.objectContaining({ value: "open", label: "Open" }), + expect.objectContaining({ value: "done", label: "Done" }), + ]); + expect(api.catalog.priorities()).toEqual([ + expect.objectContaining({ value: "normal", label: "Normal" }), + expect.objectContaining({ value: "high", label: "High" }), + ]); + expect(api.catalog.fields()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "status", + frontmatterKey: "status", + writable: true, + }), + expect.objectContaining({ + id: "user:energy", + frontmatterKey: "energy", + valueType: "number", + }), + ]) + ); + expect(api.catalog.writableFields().some((field) => field.id === "path")).toBe(false); + expect(api.catalog.filterOperators()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "is", valueRequired: true })]) + ); + expect(api.catalog.relationships()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "dependencies" })]) + ); + expect(api.catalog.dependencyRelTypes()).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "FINISHTOSTART" })]) + ); + }); + it("lists runtime event definitions for companion plugin UIs", () => { const { plugin } = createPluginContext(); const api = new TaskNotesAPI(plugin);