From bf5a68019ab72b37e80f71aa956bcc1c7e40bed5 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 1 Jun 2026 19:19:26 +1000 Subject: [PATCH] checkpoint runtime api and companion docs --- build-css.mjs | 1 + docs/companion-plugins.md | 48 ++ docs/companion-plugins/tasknotes-workflows.md | 552 ++++++++++++++++++ docs/features.md | 6 + docs/features/integrations.md | 6 + docs/index.md | 4 + docs/javascript-api.md | 34 ++ docs/releases/unreleased.md | 2 + mkdocs.yml | 5 +- src/api/TaskNotesAPI.ts | 163 ++++++ src/api/runtime-api.ts | 192 ++++++ styles/index.css | 7 +- styles/release-notes-view.css | 5 + tests/unit/api/tasknotes-api-v1.test.ts | 76 +++ 14 files changed, 1097 insertions(+), 4 deletions(-) create mode 100644 docs/companion-plugins.md create mode 100644 docs/companion-plugins/tasknotes-workflows.md create mode 100644 styles/release-notes-view.css diff --git a/build-css.mjs b/build-css.mjs index 6b82003a..abc2409c 100644 --- a/build-css.mjs +++ b/build-css.mjs @@ -35,6 +35,7 @@ const CSS_FILES = [ 'styles/pomodoro-view.css', // PomodoroView component with proper BEM scoping 'styles/pomodoro-stats-view.css', // PomodoroStatsView component with proper BEM scoping 'styles/stats-view.css', // StatsView component with proper BEM scoping + 'styles/release-notes-view.css', // Release notes view typography 'styles/settings-view.css', // SettingsView component with proper BEM scoping 'styles/webhook-settings.css', // Webhook settings UI with proper BEM scoping 'styles/status-bar.css', // StatusBar component with proper BEM scoping diff --git a/docs/companion-plugins.md b/docs/companion-plugins.md new file mode 100644 index 00000000..3f1ed185 --- /dev/null +++ b/docs/companion-plugins.md @@ -0,0 +1,48 @@ +# Companion Plugins + +Companion plugins are optional Obsidian plugins that build on TaskNotes without adding every workflow to the core plugin. They run inside the same Obsidian app, use TaskNotes' runtime API for live task reads and writes, and keep task data in ordinary Markdown files. + +Use companion plugins when you want a deeper product surface than a script or webhook, but you still want TaskNotes itself to stay focused on task storage, editing, views, time tracking, and calendar behavior. + +## Available Companion Plugins + +| Plugin | Purpose | Documentation | +| --- | --- | --- | +| TaskNotes Workflows | Markdown-defined automation for TaskNotes tasks, schedules, events, and manual command-palette workflows. | [TaskNotes Workflows](companion-plugins/tasknotes-workflows.md) | + +More companion plugins may be documented here over time. + +## How Companion Plugins Relate to TaskNotes + +TaskNotes remains the source of truth for task behavior. Companion plugins should use the [TaskNotes JavaScript Runtime API](javascript-api.md) for live vault operations instead of rewriting TaskNotes frontmatter directly. This keeps task updates consistent with TaskNotes settings, task cache, events, calendar behavior, time tracking, and future data-model changes. + +Companion plugins can also publish their own runtime surface through `api.extensions`. Other plugins and scripts can discover those extension APIs from TaskNotes: + +```js +const tasknotes = app.plugins.getPlugin("tasknotes"); +const workflows = tasknotes?.api?.extensions.get("tasknotes-workflows"); +``` + +The extension registry is useful when companion plugins need to expose commands, validation helpers, catalogs, or run APIs to other in-vault tools. + +## Choosing an Integration Surface + +| Use case | Prefer | +| --- | --- | +| A user-facing Obsidian UI that works with TaskNotes tasks | Companion plugin | +| In-vault scripting with Templater, QuickAdd, MetaBind, or another plugin | [JavaScript Runtime API](javascript-api.md) | +| Local tools outside Obsidian | [HTTP API](HTTP_API.md) or [TaskNotes Obsidian CLI](obsidian-cli.md) | +| Notifications to external services when tasks change | [Webhooks](webhooks.md) | +| Direct file analysis without Obsidian running | [mdbase-tasknotes CLI](mdbase-tasknotes-cli.md) | + +## Compatibility Expectations + +Companion plugins should: + +- Check `api.apiVersion` and `api.hasCapability(...)` before using runtime features. +- Include a `source` value when mutating tasks so TaskNotes events are debuggable. +- Avoid reacting to their own mutations unless the behavior is explicit. +- Keep user data in the vault where possible, using readable Markdown or JSON files. +- Treat TaskNotes task notes and Bases files as user-owned documents. + +TaskNotes does not need every companion plugin installed. If a companion plugin is disabled, TaskNotes task notes remain normal Markdown files and TaskNotes core views continue to work. diff --git a/docs/companion-plugins/tasknotes-workflows.md b/docs/companion-plugins/tasknotes-workflows.md new file mode 100644 index 00000000..3c56fb6b --- /dev/null +++ b/docs/companion-plugins/tasknotes-workflows.md @@ -0,0 +1,552 @@ +# TaskNotes Workflows + +TaskNotes Workflows is a companion plugin for Markdown-defined automation. It lets you create workflow notes that respond to TaskNotes events, schedules, manual commands, and selected Obsidian events, then run typed actions against TaskNotes tasks. + +The plugin is designed for workflows users often ask TaskNotes core to handle, such as: + +- Start time tracking when a task becomes active. +- Stop time tracking when a task is completed. +- Move completed tasks into a review folder. +- Copy parent task context, tags, priority, dates, or dependencies to subtasks. +- Warn when a blocked task is moved to active. +- Run a daily review query. + +TaskNotes Workflows keeps this behavior outside TaskNotes core while still using the [TaskNotes JavaScript Runtime API](../javascript-api.md) for safe task reads, writes, events, and relationship resolution. + +## Requirements + +- TaskNotes must be installed and enabled. +- TaskNotes must expose the JavaScript runtime API. +- Obsidian must be open for workflows to run. +- Scheduled workflows run while Obsidian is running. They are not a background service when Obsidian is closed. +- The plugin runtime uses Obsidian APIs only. Node is used for development and build scripts, not for workflow execution, so Markdown workflow definitions can work on Obsidian mobile while the plugin is loaded. + +If TaskNotes is disabled or unavailable, the workflow Base can still validate and display workflow files, but TaskNotes-mutating steps cannot run. + +## Files Created by the Plugin + +By default, TaskNotes Workflows uses: + +| Path | Purpose | +| --- | --- | +| `TaskNotes/Workflows/` | Workflow Markdown files. | +| `TaskNotes/Views/workflows.base` | Generated Obsidian Base that renders workflow cards through the custom workflow view. | +| Plugin config folder | Run history and detail logs, unless a run-log folder is configured. | + +Workflow notes are ordinary Markdown files. The YAML frontmatter is executable configuration, and the note body is for human explanation, testing notes, and maintenance guidance. + +## First Run + +On first load, the plugin can create a default workflow folder, a workflow Base, and a set of example workflow notes. The default workflow notes are disabled so they can be inspected, dry-run, edited, and enabled intentionally. + +The generated Base is the main workbench. It shows workflow cards with status, trigger and step summaries, recent run state, and buttons for editing, dry-running, running, and opening the source note. + +Workflow notes also show a compact workflow card in reading mode and live preview, so the note remains useful even when opened directly. + +## Default Workflows + +The plugin can write these starter workflows: + +| Workflow | What it demonstrates | +| --- | --- | +| Auto-start time tracking | Starts a timer when a task status changes to active. | +| Stop time tracking on complete | Stops the timer when a task is completed. | +| Morning overdue review | Runs a daily cron query and marks overdue open tasks high priority. | +| Move completed tasks to review | Moves completed task notes into a review folder. | +| Inherit subtask contexts and tags | Copies contexts and tags from the first parent task when a task becomes a subtask. | +| Inherit subtask priority | Copies priority from the first parent task when a task becomes a subtask. | +| Inherit subtask planning dates | Copies scheduled and due dates from the first parent task when a task becomes a subtask. | +| Inherit subtask dependencies | Adds the first parent task's blocking dependencies to new subtasks. | +| Mirror parent priority to subtasks | Updates existing subtasks when a parent task priority changes. | +| Mirror parent planning dates to subtasks | Updates existing subtasks when a parent scheduled or due date changes. | +| Mirror parent dependencies to subtasks | Replaces each subtask's dependencies with the parent task's current dependencies. | +| Warn when starting a blocked task | Shows a notice when a task becomes active while dependencies remain. | +| Daily active task review | Shows a daily count of tasks still marked active. | + +Treat these as editable examples. Adjust folders, statuses, priorities, dates, and filters to match your vault before enabling mutating workflows. + +## Workflow Shape + +A workflow file looks like this: + +```markdown +--- +type: tasknotes-workflow +schemaVersion: 1 +id: auto-start-time-tracking +name: Auto-start time tracking +enabled: false +description: Start a timer when a task status changes to active. +triggers: + - id: status-active + type: tasknotes.event + event: task.status.changed + to: active +conditions: + - field: trigger.after.path + operator: exists +steps: + - id: start-time + type: time.start + input: + task: "{{trigger.after.path}}" + options: + description: "Started by {{workflow.name}}" +run: + mode: sequential + noOverlap: true + source: tasknotes-workflows + maxTasks: 1 + onError: stop +--- + +# Auto-start time tracking + +Enable this workflow to start time tracking when a TaskNotes task moves to `active`. +``` + +Required frontmatter fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `type` | string | Must be `tasknotes-workflow`. | +| `schemaVersion` | number | Must be `1`. | +| `id` | string | Stable lowercase identifier. Use letters, numbers, dots, underscores, and dashes. | +| `name` | string | Human-readable name shown in workflow cards and command-palette commands. | +| `enabled` | boolean | Disabled workflows can be edited and dry-run but do not run automatically or from manual commands. | +| `triggers` | array | One or more trigger definitions. | +| `conditions` | array | Optional workflow-level guards. | +| `steps` | array | Linear typed step pipeline. | +| `run` | object | Run policy and safety limits. | + +## Triggers + +Triggers decide when a workflow should run. + +### TaskNotes Events + +Use `tasknotes.event` to react to TaskNotes runtime events: + +```yaml +triggers: + - id: status-active + type: tasknotes.event + event: task.status.changed + from: open + to: active +``` + +Common event names include: + +- `task.created` +- `task.updated` +- `task.deleted` +- `task.moved` +- `task.status.changed` +- `task.completed` +- `task.uncompleted` +- `task.archived` +- `task.unarchived` +- `task.scheduled.changed` +- `task.due.changed` +- `task.priority.changed` +- `task.tags.changed` +- `task.contexts.changed` +- `task.projects.changed` +- `task.reminders.changed` +- `task.dependencies.changed` +- `task.recurrence.changed` +- `time.started` +- `time.stopped` +- `pomodoro.started` +- `pomodoro.completed` +- `pomodoro.interrupted` +- `recurring.instance.completed` +- `recurring.instance.skipped` + +The workflow editor loads available TaskNotes events dynamically from `api.events.list()`, so event pickers can follow the runtime API instead of relying on a hardcoded list. + +TaskNotes event triggers can use these filters: + +| Field | Purpose | +| --- | --- | +| `event` | Runtime event name. | +| `from` | Previous status or value for status-change workflows. | +| `to` | New status or value for status-change workflows. | +| `path.glob` | Optional path glob limiting which task files can trigger the workflow. | +| `allowSelfTrigger` | Allows this workflow to react to writes caused by `tasknotes-workflows`. Leave off unless you have a clear reason. | + +TaskNotes event output fields include values such as `trigger.after.path`, `trigger.after.status`, `trigger.before.status`, `trigger.changes`, `trigger.source`, and `trigger.correlationId`. + +### Cron + +Cron triggers run on matching minutes while Obsidian is open: + +```yaml +triggers: + - id: weekday-review + type: cron + schedule: "0 17 * * 1-5" + timezone: local +``` + +The schedule uses five fields: minute, hour, day of month, month, day of week. Keep cron workflows bounded with `run.maxTasks`, especially if they query many tasks. + +### Interval + +Interval triggers run repeatedly while Obsidian is open: + +```yaml +triggers: + - id: half-hour-check + type: interval + every: 30m +``` + +The plugin enforces a minimum interval to avoid excessive background work. + +### Manual + +Manual triggers let a workflow run from a card button, another plugin, or the Obsidian command palette: + +```yaml +triggers: + - id: manual + type: manual +``` + +Enabled workflows with a manual trigger are registered as Obsidian commands named `Run: `. You can launch them from the command palette or assign hotkeys in Obsidian settings. + +The generated command id is based on the workflow `id`. Changing a workflow id creates a different command, so any hotkey assigned to the old command id will need to be reassigned. + +### Obsidian Events + +Advanced Obsidian triggers are opt-in from the plugin settings. They can listen to selected vault, metadata, and workspace events: + +```yaml +triggers: + - id: project-note-opened + type: obsidian.workspace + event: file-open + path: + glob: "Projects/**/*.md" + + - id: task-note-modified + type: obsidian.vault + event: modify + path: + glob: "Tasks/**/*.md" + + - id: metadata-changed + type: obsidian.metadata + event: changed + path: + glob: "Projects/**/*.md" +``` + +Keep path filters narrow for Obsidian triggers. Vault and metadata events can be frequent. + +## Conditions + +Conditions can appear at workflow level or on individual steps. + +```yaml +conditions: + - field: trigger.after.path + operator: exists + - field: trigger.after.status + operator: is + value: active +``` + +Supported operators: + +| Operator | Meaning | +| --- | --- | +| `is` / `isNot` | Exact comparison after scalar normalization. | +| `in` / `notIn` | Check a value against an array. | +| `exists` / `missing` | Check whether a field has a value. | +| `contains` | Check whether a string contains text or an array contains a value. | +| `startsWith` | String prefix check. | +| `before` / `after` | Date comparison. | +| `onOrBefore` / `onOrAfter` | Inclusive date comparison. | + +Step-level conditions use the same shape: + +```yaml +steps: + - id: warn + type: notice.show + if: + - field: steps.dependencies.tasks[0].path + operator: exists + input: + message: "This task still has dependencies." +``` + +## References + +References use constrained template expressions. Arbitrary JavaScript is not evaluated. + +```text +{{workflow.id}} +{{workflow.name}} +{{trigger.after.path}} +{{trigger.changes.status.after}} +{{steps.query.tasks}} +{{steps.parents.tasks[0].path}} +{{item.path}} +{{today}} +{{now}} +``` + +If a string is exactly one reference, the underlying value is preserved. If a reference is embedded in other text, the value is stringified. + +Use `forEach` when a step should run once per item: + +```yaml +steps: + - id: overdue + type: task.query + input: + query: + due: + operator: before + value: today + status: + operator: notIn + value: + - done + - cancelled + + - id: mark-high + type: task.patch + forEach: "{{steps.overdue.tasks}}" + input: + task: "{{item.path}}" + patch: + priority: high +``` + +When a step uses `forEach`, `{{item}}` refers to the current item and the step output becomes an array of per-item outputs. + +## Steps + +Steps are typed actions. The editor reads a step catalog so it can show expected inputs, output fields, examples, and TaskNotes option pickers. + +Task read steps: + +| Step | Output | +| --- | --- | +| `task.get` | One task. | +| `task.query` | Tasks matching a compact workflow query. | +| `task.parents` | Parent tasks linked from the task's projects. | +| `task.subtasks` | Tasks that reference the current task as a project. | +| `task.dependencies` | Dependencies stored in `blockedBy`, with resolved task data when available. | +| `task.blocking` | Tasks blocked by the current task. | +| `task.relationships` | The task plus parents, subtasks, dependencies, and blocking tasks. | + +Task write steps: + +| Step | Purpose | +| --- | --- | +| `task.create` | Create a new TaskNotes task. | +| `task.patch` | Update one or more fields. | +| `task.set` | Set one field. | +| `task.move` | Move the task note to a target folder. | +| `task.archive` / `task.unarchive` | Archive or unarchive a task. | +| `task.complete` / `task.uncomplete` | Complete or reopen a task. | +| `task.reschedule` | Update planning dates. | +| `task.setDue` / `task.clearDue` | Set or clear due date. | +| `task.setScheduled` / `task.clearScheduled` | Set or clear scheduled date. | +| `task.addTag` / `task.removeTag` | Mutate tags. | +| `task.addProject` / `task.removeProject` | Mutate project links. | +| `task.addContext` / `task.removeContext` | Mutate contexts. | +| `task.addDependency` / `task.removeDependency` | Mutate blocking dependencies. | + +Time and utility steps: + +| Step | Purpose | +| --- | --- | +| `time.start` | Start time tracking for a task. | +| `time.stop` | Stop active time tracking for a task. | +| `time.appendEntry` | Append a time entry. | +| `notice.show` | Show an Obsidian notice. | +| `workflow.stop` | Stop a workflow early. | + +Companion plugins and scripts can inspect the same catalog through the TaskNotes runtime extension: + +```js +const tasknotes = app.plugins.getPlugin("tasknotes")?.api; +const workflows = tasknotes?.extensions.get("tasknotes-workflows"); +const steps = workflows?.listStepDefinitions(); +``` + +## Run Policy + +Every workflow has a run policy: + +```yaml +run: + mode: sequential + noOverlap: true + source: tasknotes-workflows + maxTasks: 25 + onError: stop + timeout: 30s +``` + +| Field | Notes | +| --- | --- | +| `mode` | Currently `sequential`. Steps run in order. | +| `noOverlap` | Skips a new run if the same workflow is already running. | +| `source` | Mutation source passed to TaskNotes runtime API calls. | +| `maxTasks` | Safety limit for `forEach` batch steps. | +| `onError` | `stop` or `continue`. | +| `timeout` | Optional duration string. | + +TaskNotes attaches `source`, correlation, and reason metadata to events caused by runtime API writes. Workflows use this to make runs debuggable and to avoid accidental self-trigger loops. + +## Run Logs and Debugging + +Workflow run logs are stored under the plugin's Obsidian config folder by default. You can configure a run-log folder and a log level in the plugin settings. + +Run records include: + +- Workflow id, name, and source path. +- Trigger payload. +- Step statuses. +- Inputs and outputs, depending on the configured log level. +- Error text when a run fails. + +Use dry runs before enabling workflows that mutate tasks. Dry runs still execute the workflow path, but mutating steps should report what they would do instead of changing task files. + +## Example: Manual Command Workflow + +This workflow can be run from the command palette after it is enabled: + +```markdown +--- +type: tasknotes-workflow +schemaVersion: 1 +id: show-active-count +name: Show active task count +enabled: true +description: Show a notice with the number of active tasks. +triggers: + - id: manual + type: manual +steps: + - id: active + type: task.query + input: + query: + status: active + - id: notice + type: notice.show + input: + message: "Active tasks: {{steps.active.count}}" +run: + mode: sequential + noOverlap: true + source: tasknotes-workflows + maxTasks: 25 + onError: stop +--- + +# Show active task count + +Run this from the Obsidian command palette with `TaskNotes Workflows: Run: Show active task count`. +``` + +## Example: Inherit Parent Priority + +This workflow copies priority from the first parent task when a task becomes a subtask: + +```yaml +type: tasknotes-workflow +schemaVersion: 1 +id: inherit-subtask-priority +name: Inherit subtask priority +enabled: false +triggers: + - id: projects-changed + type: tasknotes.event + event: task.projects.changed + - id: task-created + type: tasknotes.event + event: task.created +conditions: + - field: trigger.after.path + operator: exists + - field: trigger.after.projects + operator: exists +steps: + - id: parents + type: task.parents + input: + task: "{{trigger.after.path}}" + - id: inherit-priority + type: task.patch + if: + - field: steps.parents.tasks[0].priority + operator: exists + input: + task: "{{trigger.after.path}}" + patch: + priority: "{{steps.parents.tasks[0].priority}}" +run: + mode: sequential + noOverlap: true + source: tasknotes-workflows + maxTasks: 1 + onError: stop +``` + +This pattern uses `task.parents` rather than trying to parse project links manually. + +## Authoring Guidelines + +When writing workflow notes: + +- Start with `enabled: false` for any workflow that mutates tasks. +- Give every trigger and step a stable `id`. +- Prefer typed TaskNotes steps over direct frontmatter editing. +- Use relationship read steps such as `task.parents`, `task.subtasks`, and `task.dependencies` for parent/subtask/dependency logic. +- Add workflow-level conditions for required trigger fields such as `trigger.after.path`. +- Add `run.noOverlap: true` unless overlapping runs are intentional. +- Set `run.maxTasks` for batch workflows. +- Use `allowSelfTrigger: true` only when the workflow really should react to its own writes. +- Keep the Markdown body useful for future maintenance: describe what the workflow changes, how to test it, and why it exists. + +## Troubleshooting + +### Task steps do not run + +Confirm TaskNotes is enabled and exposes the runtime API. The workflow Base can display definitions without TaskNotes, but steps such as `task.patch`, `time.start`, or `task.dependencies` need TaskNotes. + +### A manual workflow is missing from the command palette + +Check that: + +- The workflow is valid. +- `enabled` is `true`. +- It has a `manual` trigger. +- The workflow `id` is valid and stable. +- Workflows have reloaded after editing the file. + +Disabled manual workflows can still be dry-run from the workflow card, but they are not registered as run commands. + +### A cron or interval workflow did not run + +Scheduled workflows only run while Obsidian is open and the plugin is loaded. Check the schedule, timezone, enabled state, and `run.maxTasks` limit. + +### A workflow runs more than once + +Check whether multiple triggers match the same update. Also check `allowSelfTrigger`; most workflows should leave it unset so they do not react to writes caused by `tasknotes-workflows`. + +### A workflow is invalid + +Open the workflow Base or the workflow note card. Validation messages point to the field path, such as `triggers[0].type` or `steps[1].input`. + +Common causes are missing required fields, unsupported trigger types, invalid YAML, or references to step outputs that do not exist. diff --git a/docs/features.md b/docs/features.md index 63e79003..cdc17fd2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -60,6 +60,12 @@ Beyond calendar sync, TaskNotes includes an HTTP API and webhook support for aut See [Integrations](settings/integrations.md) for details. +## Companion Plugins + +TaskNotes can be extended by optional companion plugins that use the JavaScript runtime API while keeping task data in Markdown files. [TaskNotes Workflows](companion-plugins/tasknotes-workflows.md) adds Markdown-defined automation for TaskNotes events, schedules, manual commands, and typed task actions. + +See [Companion Plugins](companion-plugins.md) for details. + ## REST API External applications can interact with TaskNotes through its REST API for automation, reporting, and integration with other tools. diff --git a/docs/features/integrations.md b/docs/features/integrations.md index cf104a4f..f7398945 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -26,3 +26,9 @@ For API documentation, see [HTTP API](../HTTP_API.md). Webhooks send task event payloads to external services when subscribed events occur. Optional payload transformations support service-specific formats. For configuration, see [Webhooks](../webhooks.md). + +## Companion Plugins + +Companion plugins run inside Obsidian alongside TaskNotes and use the JavaScript runtime API for live task operations. They are useful when an integration needs a real Obsidian UI, command-palette commands, or vault-local configuration files. + +For the companion-plugin model and the TaskNotes Workflows guide, see [Companion Plugins](../companion-plugins.md). diff --git a/docs/index.md b/docs/index.md index e6e25df7..feaa1cc4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,6 +60,10 @@ Use [Core Concepts](core-concepts.md) to understand the data model, [Features](f JavaScript API In-process API for companion plugins and in-vault scripts + + Companion Plugins + Optional plugins such as TaskNotes Workflows that build on the runtime API + Migration Guide Upgrading from TaskNotes v3 to v4 diff --git a/docs/javascript-api.md b/docs/javascript-api.md index 8f32418f..bd14bdb0 100644 --- a/docs/javascript-api.md +++ b/docs/javascript-api.md @@ -11,6 +11,8 @@ This API is for Obsidian companion plugins and in-vault scripting tools such as This is separate from the [HTTP API](HTTP_API.md). The runtime API does not start a server, does not use bearer-token authentication, and is only available inside the running Obsidian app. +For user-facing extensions built on this API, see [Companion Plugins](companion-plugins.md). For a concrete automation companion plugin, see [TaskNotes Workflows](companion-plugins/tasknotes-workflows.md). + The TypeScript contract lives in `src/api/runtime-api.ts`. It is intentionally small and type-focused so it can later become a standalone `@tasknotes/runtime-api` package for companion plugins. ## Version and Capabilities @@ -35,6 +37,8 @@ Current capabilities: - `tasks.delete` - `tasks.move` - `tasks.events` +- `relationships.read` +- `events.list` - `time.read` - `time.write` - `pomodoro.read` @@ -51,6 +55,7 @@ Prefer the namespaced API for new code: ```javascript await api.tasks.update("Tasks/example.md", { status: "active" }); +const subtasks = await api.relationships.subtasks("Tasks/example.md"); await api.time.start("Tasks/example.md", { description: "Deep work" }); await api.pomodoro.start({ taskPath: "Tasks/example.md", duration: 25 }); ``` @@ -185,6 +190,28 @@ const task = await api.tasks.create( await api.tasks.complete(task.path); ``` +## Relationships + +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. | + +Example: + +```javascript +const relationships = await api.relationships.all("Tasks/example.md"); + +for (const subtask of relationships.subtasks) { + await api.tasks.setPriority(subtask.path, relationships.task.priority); +} +``` + ## Time Tracking | Method | Description | @@ -252,6 +279,13 @@ TaskNotes attaches this context to normalized events emitted during the mutation Subscribe with `api.events.on(eventName, handler)` and unsubscribe with `api.events.off(ref)`. In an Obsidian plugin, pass the returned `EventRef` to `this.registerEvent(ref)`. +Use `api.events.list()` to discover the event catalogue for companion-plugin UIs: + +```javascript +const events = api.events.list(); +// [{ name: "task.status.changed", label: "Task status changed", category: "task", ... }] +``` + Task events: - `task.created` diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 13440e23..d44e1b8f 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -35,6 +35,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 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. - (#1510, #1751, #1792, #1969) Added a Task List and Kanban view option to hide top-level subtasks when their parent task is also in the filtered view, while still allowing inherited expanded relationships to show the subtasks under the parent. Thanks to @Kickdak and @Glint-Eye for the requests, @inigourrestarazu for identifying the project-linked parent edge case, and @stanley-910 and @Spencerduran for the earlier PRs. @@ -49,6 +50,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#1976) Fixed embedded Calendar and Agenda Bases so "Navigate to date from property" can use the containing note's date property when the Base rows do not have that property. Thanks to @matesvecenik for reporting this. - (#1977) Fixed Calendar and Agenda views showing duplicate all-day entries when a task's scheduled date and due date are the same day. Timed due dates on the same day still show as separate deadline markers. Thanks to @pdgBC for reporting this. +- Fixed the release notes view using cramped interface typography instead of regular reading typography. - (#216) Improved inline conversion for Tasks plugin task lines, including Dataview-style fields, priority markers, recurrence text, date aliases, block links, and safer trailing-field parsing. Thanks to @ksdavidc for the request, @natleahh for the Dataview example, and @hangryscribe3 and @nayatiuh for the discussion. - (#1974) Stopped Calendar and Agenda property-based events from logging date parse errors for entries that do not have the selected date property. Thanks to @Jomo94 for suggesting completed-date Agenda events. - (#1973) Reduced unnecessary Calendar view recreations when external calendar providers are reported in a different order, and preserved Calendar scroll position when a config-driven refresh has to recreate the view. Thanks to @e-zz for reporting this. diff --git a/mkdocs.yml b/mkdocs.yml index 3e73885a..931623de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Third-party Integrations: features/integrations.md - User Fields: features/user-fields.md - Template Variables: features/template-variables.md + - Workflow Ideas: workflows.md - Views: - Overview: views.md - Task List View: views/task-list.md @@ -90,8 +91,10 @@ nav: - NLP API: nlp-api.md - mdbase-tasknotes CLI: mdbase-tasknotes-cli.md - Webhooks: webhooks.md - - Workflows: workflows.md - Calendar Setup: calendar-setup.md + - Companion Plugins: + - Overview: companion-plugins.md + - TaskNotes Workflows: companion-plugins/tasknotes-workflows.md - Specification: - About the Spec: spec.md - Overview: spec/00-overview.md diff --git a/src/api/TaskNotesAPI.ts b/src/api/TaskNotesAPI.ts index 5de9b21d..fcdb01b9 100644 --- a/src/api/TaskNotesAPI.ts +++ b/src/api/TaskNotesAPI.ts @@ -24,13 +24,16 @@ import { } from "../types"; import type { TaskNotesSettings } from "../types/settings"; import { ensureFolderExists } from "../utils/helpers"; +import { parseLinkToPath } from "../utils/linkUtils"; import { TASKNOTES_RUNTIME_API_CAPABILITIES, + TASKNOTES_RUNTIME_EVENT_DEFINITIONS, TASKNOTES_RUNTIME_API_VERSION, type ActiveTimeEntry, type CompleteTaskOptions, type PomodoroSessionsOptions, type PomodoroStartOptions, + type ResolvedTaskDependency, type StartTimeEntryOptions, type TaskNotesApiChanges, type TaskNotesApiEvent, @@ -42,6 +45,7 @@ import { type TaskNotesRuntimeExtensionHandle, type TaskNotesRuntimeExtensionInfo, type TaskNotesRuntimeEventName, + type TaskNotesTaskRelationships, type TaskNotesTaskPatch, type UncompleteTaskOptions, } from "./runtime-api"; @@ -77,6 +81,7 @@ const RESERVED_RUNTIME_EXTENSION_NAMESPACES = new Set([ "hascapability", "nlp", "pomodoro", + "relationships", "recurring", "settings", "tasks", @@ -160,6 +165,14 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { this.removeDependency(path, uid, context), }; + readonly relationships = { + parents: (path: string) => this.getParentTasks(path), + subtasks: (path: string) => this.getSubtasks(path), + dependencies: (path: string) => this.getTaskDependencies(path), + blocking: (path: string) => this.getBlockingTasks(path), + all: (path: string) => this.getTaskRelationships(path), + }; + readonly time = { start: ( path: string, @@ -206,6 +219,7 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { handler: TaskNotesApiEventHandler ) => this.on(event, handler), off: (ref: EventRef) => this.off(ref), + list: () => TASKNOTES_RUNTIME_EVENT_DEFINITIONS.map((event) => ({ ...event })), }; readonly settings = { @@ -267,6 +281,91 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { return tasks.map(copyTaskInfo); } + async getParentTasks(path: string): Promise { + const task = await this.requireTask(path); + const parents: TaskInfo[] = []; + + for (const project of task.projects ?? []) { + const parent = await this.resolveTaskReference(project, task.path); + if (parent) parents.push(parent); + } + + return uniqueTasks(parents).map(copyTaskInfo); + } + + async getSubtasks(path: string): Promise { + const task = await this.requireTask(path); + const allTasks = await this.plugin.cacheManager.getAllTasks(); + const subtasks: TaskInfo[] = []; + + for (const candidate of allTasks) { + if (candidate.path === task.path) continue; + for (const project of candidate.projects ?? []) { + if (await this.taskReferenceMatches(project, candidate.path, task.path)) { + subtasks.push(candidate); + break; + } + } + } + + return uniqueTasks(subtasks).map(copyTaskInfo); + } + + async getTaskDependencies(path: string): Promise { + const task = await this.requireTask(path); + const dependencies: ResolvedTaskDependency[] = []; + + for (const dependency of task.blockedBy ?? []) { + const dependencyPath = await this.resolveTaskReferencePath(dependency.uid, task.path); + const blockingTask = dependencyPath + ? await this.plugin.cacheManager.getTaskInfo(dependencyPath) + : null; + dependencies.push({ + dependency: copyTaskDependency(dependency), + task: blockingTask ? copyTaskInfo(blockingTask) : null, + path: blockingTask?.path ?? dependencyPath, + }); + } + + return dependencies; + } + + async getBlockingTasks(path: string): Promise { + const task = await this.requireTask(path); + const allTasks = await this.plugin.cacheManager.getAllTasks(); + const blocking: TaskInfo[] = []; + + for (const candidate of allTasks) { + if (candidate.path === task.path) continue; + for (const dependency of candidate.blockedBy ?? []) { + if (await this.taskReferenceMatches(dependency.uid, candidate.path, task.path)) { + blocking.push(candidate); + break; + } + } + } + + return uniqueTasks(blocking).map(copyTaskInfo); + } + + async getTaskRelationships(path: string): Promise { + const task = await this.requireTask(path); + const [parents, subtasks, dependencies, blocking] = await Promise.all([ + this.getParentTasks(task.path), + this.getSubtasks(task.path), + this.getTaskDependencies(task.path), + this.getBlockingTasks(task.path), + ]); + + return { + task: copyTaskInfo(task), + parents, + subtasks, + dependencies, + blocking, + }; + } + async createTask( taskData: TaskCreationData, context?: TaskNotesMutationContext @@ -824,6 +923,41 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { this.plugin.emitter.offref(ref); } + private async resolveTaskReference(reference: string, sourcePath: string): Promise { + const path = await this.resolveTaskReferencePath(reference, sourcePath); + return path ? await this.plugin.cacheManager.getTaskInfo(path) : null; + } + + private async taskReferenceMatches( + reference: string, + sourcePath: string, + targetPath: string + ): Promise { + const path = await this.resolveTaskReferencePath(reference, sourcePath); + return path === normalizePath(targetPath); + } + + private async resolveTaskReferencePath( + reference: string, + sourcePath: string + ): Promise { + const linkPath = firstReferencePathCandidate(reference); + if (!linkPath) return null; + + const metadataCache = this.plugin.app.metadataCache as + | { getFirstLinkpathDest?: (linkpath: string, sourcePath: string) => TFile | null } + | undefined; + const resolvedFile = metadataCache?.getFirstLinkpathDest?.(linkPath, sourcePath); + if (resolvedFile instanceof TFile) return normalizePath(resolvedFile.path); + + for (const candidate of taskReferencePathCandidates(linkPath)) { + const task = await this.plugin.cacheManager.getTaskInfo(candidate); + if (task) return task.path; + } + + return normalizePath(linkPath); + } + private async requireTask(path: string): Promise { const normalizedPath = this.normalizeTaskPath(path); const task = await this.plugin.cacheManager.getTaskInfo(normalizedPath); @@ -1089,6 +1223,35 @@ export class TaskNotesAPI implements TaskNotesRuntimeApiV1 { } } +function firstReferencePathCandidate(reference: string): string | null { + const parsed = parseLinkToPath(reference).trim(); + return parsed ? normalizePath(parsed) : null; +} + +function taskReferencePathCandidates(path: string): string[] { + const normalizedPath = normalizePath(path); + const candidates = [normalizedPath]; + if (!/\.md$/iu.test(normalizedPath)) { + candidates.push(`${normalizedPath}.md`); + } + return candidates; +} + +function uniqueTasks(tasks: TaskInfo[]): TaskInfo[] { + const seen = new Set(); + const unique: TaskInfo[] = []; + for (const task of tasks) { + if (seen.has(task.path)) continue; + seen.add(task.path); + unique.push(task); + } + return unique; +} + +function copyTaskDependency(dependency: TaskDependency): TaskDependency { + return { ...dependency }; +} + 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 0763c3c1..4c5fef9a 100644 --- a/src/api/runtime-api.ts +++ b/src/api/runtime-api.ts @@ -24,6 +24,8 @@ export const TASKNOTES_RUNTIME_API_CAPABILITIES = [ "tasks.delete", "tasks.move", "tasks.events", + "relationships.read", + "events.list", "time.read", "time.write", "pomodoro.read", @@ -65,6 +67,172 @@ export type TaskNotesRuntimeEventName = | TaskNotesTaskEventName | WebhookEvent; +export type TaskNotesRuntimeEventCategory = + | "task" + | "time" + | "pomodoro" + | "recurring"; + +export interface TaskNotesRuntimeEventDefinition { + name: TaskNotesRuntimeEventName; + label: string; + description: string; + category: TaskNotesRuntimeEventCategory; +} + +export const TASKNOTES_RUNTIME_EVENT_DEFINITIONS: readonly TaskNotesRuntimeEventDefinition[] = [ + { + name: "task.created", + label: "Task created", + description: "A TaskNotes task was created.", + category: "task", + }, + { + name: "task.updated", + label: "Task updated", + description: "Any tracked TaskNotes task property changed.", + category: "task", + }, + { + name: "task.deleted", + label: "Task deleted", + description: "A TaskNotes task was deleted.", + category: "task", + }, + { + name: "task.moved", + label: "Task moved", + description: "A TaskNotes task note moved to a new path.", + category: "task", + }, + { + name: "task.status.changed", + label: "Task status changed", + description: "A TaskNotes task status changed.", + category: "task", + }, + { + name: "task.completed", + label: "Task completed", + description: "A TaskNotes task moved into a completed status.", + category: "task", + }, + { + name: "task.uncompleted", + label: "Task uncompleted", + description: "A TaskNotes task moved out of a completed status.", + category: "task", + }, + { + name: "task.archived", + label: "Task archived", + description: "A TaskNotes task was archived.", + category: "task", + }, + { + name: "task.unarchived", + label: "Task unarchived", + description: "A TaskNotes task was unarchived.", + category: "task", + }, + { + name: "task.scheduled.changed", + label: "Task scheduled date changed", + description: "A TaskNotes task scheduled date changed.", + category: "task", + }, + { + name: "task.due.changed", + label: "Task due date changed", + description: "A TaskNotes task due date changed.", + category: "task", + }, + { + name: "task.priority.changed", + label: "Task priority changed", + description: "A TaskNotes task priority changed.", + category: "task", + }, + { + name: "task.tags.changed", + label: "Task tags changed", + description: "A TaskNotes task tag list changed.", + category: "task", + }, + { + name: "task.contexts.changed", + label: "Task contexts changed", + description: "A TaskNotes task context list changed.", + category: "task", + }, + { + name: "task.projects.changed", + label: "Task projects changed", + description: "A TaskNotes task project list changed.", + category: "task", + }, + { + name: "task.reminders.changed", + label: "Task reminders changed", + description: "A TaskNotes task reminder list changed.", + category: "task", + }, + { + name: "task.dependencies.changed", + label: "Task dependencies changed", + description: "A TaskNotes task dependency list changed.", + category: "task", + }, + { + name: "task.recurrence.changed", + label: "Task recurrence changed", + description: "A TaskNotes task recurrence rule changed.", + category: "task", + }, + { + name: "time.started", + label: "Time tracking started", + description: "A time entry started on a TaskNotes task.", + category: "time", + }, + { + name: "time.stopped", + label: "Time tracking stopped", + description: "A time entry stopped on a TaskNotes task.", + category: "time", + }, + { + name: "pomodoro.started", + label: "Pomodoro started", + description: "A Pomodoro session started.", + category: "pomodoro", + }, + { + name: "pomodoro.completed", + label: "Pomodoro completed", + description: "A Pomodoro session completed.", + category: "pomodoro", + }, + { + name: "pomodoro.interrupted", + label: "Pomodoro interrupted", + description: "A Pomodoro session was interrupted.", + category: "pomodoro", + }, + { + name: "recurring.instance.completed", + label: "Recurring instance completed", + description: "A recurring task instance was completed.", + category: "recurring", + }, + { + name: "recurring.instance.skipped", + label: "Recurring instance skipped", + description: "A recurring task instance was skipped.", + category: "recurring", + }, +] as const; + export interface TaskNotesMutationContext { source?: string; correlationId?: string; @@ -90,6 +258,20 @@ export interface ActiveTimeEntry { index: number; } +export interface ResolvedTaskDependency { + dependency: TaskDependency; + task: TaskInfo | null; + path: string | null; +} + +export interface TaskNotesTaskRelationships { + task: TaskInfo; + parents: TaskInfo[]; + subtasks: TaskInfo[]; + dependencies: ResolvedTaskDependency[]; + blocking: TaskInfo[]; +} + export interface StartTimeEntryOptions { description?: string; } @@ -248,6 +430,14 @@ export interface TaskNotesRuntimeTasksApi { ): Promise; } +export interface TaskNotesRuntimeRelationshipsApi { + parents(path: string): Promise; + subtasks(path: string): Promise; + dependencies(path: string): Promise; + blocking(path: string): Promise; + all(path: string): Promise; +} + export interface TaskNotesRuntimeTimeApi { start( path: string, @@ -294,6 +484,7 @@ export interface TaskNotesRuntimeEventsApi { handler: TaskNotesRuntimeEventHandler ): EventRef; off(ref: EventRef): void; + list(): readonly TaskNotesRuntimeEventDefinition[]; } export interface TaskNotesRuntimeSettingsApi { @@ -319,6 +510,7 @@ export interface TaskNotesRuntimeApiV1 { hasCapability(capability: string): boolean; readonly tasks: TaskNotesRuntimeTasksApi; + readonly relationships: TaskNotesRuntimeRelationshipsApi; readonly time: TaskNotesRuntimeTimeApi; readonly pomodoro: TaskNotesRuntimePomodoroApi; readonly recurring: TaskNotesRuntimeRecurringApi; diff --git a/styles/index.css b/styles/index.css index 69d8090e..46e0835d 100644 --- a/styles/index.css +++ b/styles/index.css @@ -24,10 +24,11 @@ 13. notes-view.css - BEM NotesView component 14. pomodoro-view.css - BEM PomodoroView component 15. pomodoro-stats-view.css - BEM PomodoroStatsView component - 16. settings-view.css - BEM SettingsView component + 16. release-notes-view.css - Release notes view typography + 17. settings-view.css - BEM SettingsView component === LEGACY SUPPORT === - 17. components.css - Reusable UI components, utilities, and modals + 18. components.css - Reusable UI components, utilities, and modals To build the final styles.css file, run: npm run build-css @@ -90,4 +91,4 @@ - The final CSS is properly ordered and documented */ -/* This file is for documentation only - it is not included in the build */ \ No newline at end of file +/* This file is for documentation only - it is not included in the build */ diff --git a/styles/release-notes-view.css b/styles/release-notes-view.css new file mode 100644 index 00000000..b0a8d31f --- /dev/null +++ b/styles/release-notes-view.css @@ -0,0 +1,5 @@ +.tasknotes-release-notes-view .release-notes-version-content { + font-family: var(--font-text); + font-size: var(--font-text-size); + line-height: var(--line-height-normal); +} diff --git a/tests/unit/api/tasknotes-api-v1.test.ts b/tests/unit/api/tasknotes-api-v1.test.ts index 740aeeb4..002b951b 100644 --- a/tests/unit/api/tasknotes-api-v1.test.ts +++ b/tests/unit/api/tasknotes-api-v1.test.ts @@ -368,17 +368,52 @@ describe("TaskNotesApiV1", () => { expect(api.apiVersion).toBe(1); expect(api.capabilities).toContain("tasks.write"); expect(api.capabilities).toContain("pomodoro.events"); + expect(api.capabilities).toContain("events.list"); expect(api.capabilities).toContain("extensions.register"); + expect(api.capabilities).toContain("relationships.read"); expect(api.hasCapability("tasks.events")).toBe(true); expect(api.hasCapability("missing.capability")).toBe(false); expect(typeof api.parseNaturalLanguage).toBe("function"); expect(typeof api.tasks.update).toBe("function"); + expect(typeof api.relationships.subtasks).toBe("function"); expect(typeof api.time.start).toBe("function"); expect(typeof api.pomodoro.start).toBe("function"); expect(typeof api.events.on).toBe("function"); + expect(typeof api.events.list).toBe("function"); expect(typeof api.extensions.register).toBe("function"); }); + it("lists runtime event definitions for companion plugin UIs", () => { + const { plugin } = createPluginContext(); + const api = new TaskNotesAPI(plugin); + + const events = api.events.list(); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "task.status.changed", + label: "Task status changed", + category: "task", + }), + expect.objectContaining({ + name: "time.started", + label: "Time tracking started", + category: "time", + }), + expect.objectContaining({ + name: "pomodoro.started", + label: "Pomodoro started", + category: "pomodoro", + }), + ]) + ); + + const first = events[0] as { label: string }; + first.label = "Mutated"; + + expect(api.events.list()[0]?.label).not.toBe("Mutated"); + }); + it("lets companion plugins register extension namespaces and capabilities", () => { const { plugin } = createPluginContext(); const api = new TaskNotesAPI(plugin); @@ -457,6 +492,47 @@ describe("TaskNotesApiV1", () => { expect(filterService.getGroupedTasks).toHaveBeenCalledWith(query); }); + it("resolves task relationships through projects and dependencies", async () => { + const parent = createTask({ + title: "Parent", + path: "Projects/project.md", + }); + const blocker = createTask({ + title: "Blocker", + path: "Tasks/blocker.md", + }); + const child = createTask({ + title: "Child", + path: "Tasks/child.md", + projects: ["[[Projects/project]]"], + blockedBy: [{ uid: "[[Tasks/blocker]]", reltype: "FINISHTOSTART" }], + }); + const { plugin } = createPluginContext([parent, blocker, child]); + const api = new TaskNotesAPI(plugin); + + await expect(api.relationships.parents(child.path)).resolves.toEqual([ + expect.objectContaining({ path: parent.path }), + ]); + await expect(api.relationships.subtasks(parent.path)).resolves.toEqual([ + expect.objectContaining({ path: child.path }), + ]); + await expect(api.relationships.dependencies(child.path)).resolves.toEqual([ + expect.objectContaining({ + path: blocker.path, + task: expect.objectContaining({ path: blocker.path }), + dependency: { uid: "[[Tasks/blocker]]", reltype: "FINISHTOSTART" }, + }), + ]); + await expect(api.relationships.blocking(blocker.path)).resolves.toEqual([ + expect.objectContaining({ path: child.path }), + ]); + + const relationships = await api.relationships.all(child.path); + expect(relationships.task.path).toBe(child.path); + expect(relationships.parents[0]?.path).toBe(parent.path); + expect(relationships.dependencies[0]?.task?.path).toBe(blocker.path); + }); + it("delegates task creation and common task mutations through TaskService", async () => { const task = createTask(); const { plugin, taskService } = createPluginContext([task]);