`assignee_id` has been on tasks since sharing was built and delegating has been possible from the drawer since yesterday. What was missing was anywhere to see it: work handed to somebody else left your lists and appeared in nothing. GET /delegated returns everything assigned to somebody who is not you, in the projects you can see, grouped by the person. Grouped on the server rather than flat, because the question is about people — "what is Anders sitting on" — and a client cannot group it itself: it would need the name behind an assignee id from a project it does not happen to have open, and there is no way to ask for that. The names come back with the tasks, so one request is one screen. The filter is a field of its own rather than a sentinel in AssigneeID. "What is on my plate" and "what have I handed over" are different questions, and one string that means an id most of the time and a magic word the rest is a field somebody eventually passes a real id to. An assignee whose account is gone is named "Ukendt" rather than dropped. Losing the row would make work disappear from the one view that exists to stop work disappearing. The sidebar entry only appears where delegating is possible at all. On an instance with one person it could never have anything in it, and a permanent empty entry is a question the sidebar keeps asking and answering "no". Building it turned up a bug in the split above it. "Projekter" and "Delt med mig" were divided on `shared`, which only means the project has more than one member — so inviting somebody to a project of your own moved it under "Delt med mig", where it lost its place in the order, stopped being draggable, and could not be filed under a group. It is divided on ownership now, which is what the two headings have always said. Sharing your own project is not the same event as somebody sharing theirs with you. The smoke test now follows the whole loop in one go: invite, accept in a second browser, assign a task to the person who just joined, and find it under their name — with the shared project still under "Projekter" on the way past. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2519 lines
81 KiB
YAML
2519 lines
81 KiB
YAML
openapi: 3.1.0
|
|
|
|
# The machine-readable half of docs/api.md.
|
|
#
|
|
# Hand-written, and kept honest by a test: internal/httpapi/openapi_test.go walks
|
|
# the router and fails if a route is missing here or described here but not
|
|
# routed. Generating it from annotations was the alternative, and it would have
|
|
# meant every handler carrying a second copy of itself in a comment.
|
|
|
|
info:
|
|
title: verdande
|
|
version: "0.1.0"
|
|
summary: Self-hosted tasks and projects, shared with other people.
|
|
description: |
|
|
REST under `/api/v1`, JSON in and out. Everything the web interface does goes
|
|
through this API, so anything you can do there you can script.
|
|
|
|
Authenticate with a session cookie or with a personal API token. Both are
|
|
accepted on every endpoint except `/tokens`, which needs a session — a token
|
|
that can mint another token is a leak that cannot be revoked.
|
|
|
|
Anything you may not see answers `404`, never `403`. A `403` would confirm
|
|
the thing exists, which is what somebody trying ids is hoping to learn.
|
|
license:
|
|
name: MIT
|
|
identifier: MIT
|
|
|
|
servers:
|
|
- url: https://todo.example.dk
|
|
description: Your own instance.
|
|
|
|
security:
|
|
- session: []
|
|
- bearer: []
|
|
|
|
tags:
|
|
- name: auth
|
|
description: Signing in, the second factor, and the account itself.
|
|
- name: tasks
|
|
description: Tasks, sub-tasks, comments, attachments and reminders.
|
|
- name: projects
|
|
description: Projects, sections, members and activity.
|
|
- name: views
|
|
description: Today, upcoming, search, labels and saved filters.
|
|
- name: integrations
|
|
description: Calendar feed, mail-to-task, Gmail, AI and Web Push.
|
|
- name: data
|
|
description: Import, export and project templates.
|
|
|
|
paths:
|
|
# --- auth -----------------------------------------------------------------
|
|
|
|
/api/v1/auth/setup:
|
|
get:
|
|
tags: [auth]
|
|
summary: Whether this instance still needs its first account.
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
needs_setup: { type: boolean }
|
|
post:
|
|
tags: [auth]
|
|
summary: Create the first account, which becomes the administrator.
|
|
description: Refused once any account exists. Rate limited.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email, name, password]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
name: { type: string }
|
|
password: { type: string, minLength: 10 }
|
|
timezone: { type: string, example: Europe/Copenhagen }
|
|
locale: { type: string, enum: [da, en] }
|
|
responses:
|
|
"201":
|
|
description: Created, and signed in.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
user: { $ref: "#/components/schemas/User" }
|
|
"409": { $ref: "#/components/responses/Conflict" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
/api/v1/auth/login:
|
|
post:
|
|
tags: [auth]
|
|
summary: Sign in.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email, password]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
password: { type: string }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Signed in, or waiting for a code. When `totp_required` is true the
|
|
session exists but reaches nothing until `/auth/login/totp`.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
user: { $ref: "#/components/schemas/User" }
|
|
totp_required: { type: boolean }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
/api/v1/auth/login/totp:
|
|
post:
|
|
tags: [auth]
|
|
summary: Complete a login with the code from an authenticator.
|
|
description: Accepts a recovery code in place of a generated one.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
user: { $ref: "#/components/schemas/User" }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
|
|
/api/v1/auth/signup:
|
|
post:
|
|
tags: [auth]
|
|
summary: Create an account from an invite.
|
|
description: |
|
|
There is no open registration. The address comes from the invite rather
|
|
than from the request, so an invite cannot be redirected to somebody else.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [token, name, password]
|
|
properties:
|
|
token: { type: string }
|
|
name: { type: string }
|
|
password: { type: string, minLength: 10 }
|
|
timezone: { type: string }
|
|
locale: { type: string, enum: [da, en] }
|
|
responses:
|
|
"201":
|
|
description: Created, and signed in.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
user: { $ref: "#/components/schemas/User" }
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/auth/password/forgot:
|
|
post:
|
|
tags: [auth]
|
|
summary: Ask for a reset link.
|
|
description: |
|
|
Always answers 204, whether or not the address has an account. Answering
|
|
differently would turn this into a way to test which addresses exist.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
"204": { description: Accepted. }
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
/api/v1/auth/password/reset:
|
|
post:
|
|
tags: [auth]
|
|
summary: Set a new password with a reset token.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [token, password]
|
|
properties:
|
|
token: { type: string }
|
|
password: { type: string, minLength: 10 }
|
|
responses:
|
|
"204": { description: Changed. Every other session is ended. }
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
/api/v1/auth/me:
|
|
get:
|
|
tags: [auth]
|
|
summary: Who the caller is.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/User" }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
patch:
|
|
tags: [auth]
|
|
summary: Change your own name, timezone or language.
|
|
description: |
|
|
The email address is not among them: it identifies the account and is what
|
|
invites were sent to. The timezone is not cosmetic — every date is
|
|
resolved in it, so "tomorrow at 9" means a different instant afterwards.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
name: { type: string, maxLength: 80 }
|
|
timezone: { type: string, example: Europe/Copenhagen }
|
|
locale: { type: string, enum: [da, en] }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/User" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/auth/logout:
|
|
post:
|
|
tags: [auth]
|
|
summary: End this session.
|
|
responses:
|
|
"204": { description: Signed out. }
|
|
|
|
/api/v1/auth/password/change:
|
|
post:
|
|
tags: [auth]
|
|
summary: Change your password.
|
|
description: Ends every other session, which is most of the reason to do it.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [current_password, new_password]
|
|
properties:
|
|
current_password: { type: string }
|
|
new_password: { type: string, minLength: 10 }
|
|
responses:
|
|
"204": { description: Changed. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/auth/totp/setup:
|
|
post:
|
|
tags: [auth]
|
|
summary: Begin two-factor enrolment.
|
|
description: |
|
|
Stores the secret but does not switch anything on. `/auth/totp/confirm`
|
|
does that, once a code has proved the authenticator really has it.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
secret: { type: string }
|
|
uri: { type: string, description: otpauth:// URI for a QR code. }
|
|
"409": { $ref: "#/components/responses/Conflict" }
|
|
|
|
/api/v1/auth/totp/confirm:
|
|
post:
|
|
tags: [auth]
|
|
summary: Finish enrolment and switch two-factor on.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
On. The recovery codes are returned once and never again — only their
|
|
hashes are kept.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/RecoveryCodes" }
|
|
"400": { $ref: "#/components/responses/BadRequest" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/auth/totp/disable:
|
|
post:
|
|
tags: [auth]
|
|
summary: Switch two-factor off.
|
|
description: |
|
|
Needs the password again: switching off a second factor is exactly what
|
|
somebody with a borrowed session would do first.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [password]
|
|
properties:
|
|
password: { type: string }
|
|
responses:
|
|
"204": { description: Off. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/auth/recovery-codes:
|
|
get:
|
|
tags: [auth]
|
|
summary: How many recovery codes are left.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
remaining: { type: integer }
|
|
post:
|
|
tags: [auth]
|
|
summary: Issue a fresh set and retire the old one.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [password]
|
|
properties:
|
|
password: { type: string }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/RecoveryCodes" }
|
|
"409": { $ref: "#/components/responses/Conflict" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
# --- tasks ----------------------------------------------------------------
|
|
|
|
/api/v1/tasks:
|
|
get:
|
|
tags: [tasks]
|
|
summary: List tasks.
|
|
parameters:
|
|
- { name: project_id, in: query, schema: { type: string } }
|
|
- { name: section_id, in: query, schema: { type: string } }
|
|
- { name: parent_id, in: query, schema: { type: string }, description: Sub-tasks of one task. }
|
|
- { name: label_id, in: query, schema: { type: string } }
|
|
- { name: priority, in: query, schema: { type: integer, minimum: 1, maximum: 4 } }
|
|
- { name: assignee_id, in: query, schema: { type: string }, description: "`me` resolves to the caller." }
|
|
- { name: due_before, in: query, schema: { type: string, format: date } }
|
|
- { name: due_from, in: query, schema: { type: string, format: date } }
|
|
- { name: no_date, in: query, schema: { type: boolean } }
|
|
- { name: q, in: query, schema: { type: string }, description: "Full-text search, diacritic-insensitive for Danish." }
|
|
- name: completed
|
|
in: query
|
|
schema: { type: string, enum: [include, only] }
|
|
description: Omitted, only open tasks are returned.
|
|
- { name: limit, in: query, schema: { type: integer } }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/TaskList" }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Create a task.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/TaskInput" }
|
|
responses:
|
|
"201": { $ref: "#/components/responses/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/tasks/{taskID}:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [tasks]
|
|
summary: One task.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
patch:
|
|
tags: [tasks]
|
|
summary: Change a task.
|
|
description: Only the fields present are changed.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/TaskInput" }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
delete:
|
|
tags: [tasks]
|
|
summary: Delete a task.
|
|
description: Soft-deleted, and purged from the trash after 30 days.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/{taskID}/complete:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Tick a task off.
|
|
description: |
|
|
A repeating task is not closed: it moves forward in the same row, keeping
|
|
its id, so sub-tasks and comments stay attached. `recurred` and `next_due`
|
|
say when that happened.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/Task"
|
|
- type: object
|
|
properties:
|
|
recurred: { type: boolean }
|
|
next_due: { type: string, format: date }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/{taskID}/reopen:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Un-tick a task.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/{taskID}/move:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Move a task, and place it between two others.
|
|
description: |
|
|
`after_id` and `before_id` are the tasks either side of the gap it lands
|
|
in. Give both to place between them, one to place at an end, neither to
|
|
place first.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
project_id: { type: string, description: "Omitted, the task stays in its project." }
|
|
section_id: { type: string, description: Empty string means no section. }
|
|
after_id: { type: string }
|
|
before_id: { type: string }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/quick-add:
|
|
post:
|
|
tags: [tasks]
|
|
summary: Create a task from one line of natural language.
|
|
description: |
|
|
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
|
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
|
vigtigt. An unknown `#project` is not an error — the task goes to the
|
|
Inbox rather than a thought being thrown away over a typo.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [text]
|
|
properties:
|
|
text: { type: string }
|
|
project_id:
|
|
type: string
|
|
description: Where it goes when the text names no project.
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/tasks/quick-add/preview:
|
|
get:
|
|
tags: [tasks]
|
|
summary: Show what a line would become, without creating anything.
|
|
parameters:
|
|
- { name: text, in: query, required: true, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ParsedTask" }
|
|
|
|
/api/v1/tasks/{taskID}/comments:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [tasks]
|
|
summary: Comments on a task, and the files attached to the task itself.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
comments:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Comment" }
|
|
attachments:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Attachment" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Write a comment.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [body]
|
|
properties:
|
|
body: { type: string, maxLength: 10000 }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Comment" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/comments/{commentID}:
|
|
parameters:
|
|
- { name: commentID, in: path, required: true, schema: { type: string } }
|
|
patch:
|
|
tags: [tasks]
|
|
summary: Edit a comment. Its author only.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [body]
|
|
properties:
|
|
body: { type: string, maxLength: 10000 }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Comment" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
delete:
|
|
tags: [tasks]
|
|
summary: Delete a comment. Its author only.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/{taskID}/attachments:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Attach a file, up to 25 MB.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
multipart/form-data:
|
|
schema:
|
|
type: object
|
|
required: [file]
|
|
properties:
|
|
file: { type: string, format: binary }
|
|
comment_id:
|
|
type: string
|
|
description: Hang it on a comment instead of on the task.
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Attachment" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"413": { $ref: "#/components/responses/TooLarge" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/attachments/{attachmentID}:
|
|
parameters:
|
|
- { name: attachmentID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [tasks]
|
|
summary: Download a file.
|
|
description: |
|
|
Always `application/octet-stream` and always an attachment, whatever the
|
|
file is. An uploaded SVG rendered inline would run its own script on this
|
|
origin with the session cookie attached.
|
|
responses:
|
|
"200":
|
|
description: The bytes.
|
|
content:
|
|
application/octet-stream:
|
|
schema: { type: string, format: binary }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
delete:
|
|
tags: [tasks]
|
|
summary: Delete a file.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/tasks/{taskID}/reminders:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [tasks]
|
|
summary: Reminders on a task.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
reminders:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Reminder" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
post:
|
|
tags: [tasks]
|
|
summary: Add a reminder.
|
|
description: |
|
|
Exactly one of `remind_at` and `offset_min`. The offset is relative to the
|
|
task's due time and negative for "before", which is the only kind anybody
|
|
wants.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
remind_at: { type: string, format: date-time }
|
|
offset_min: { type: integer }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Reminder" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/reminders/{reminderID}:
|
|
parameters:
|
|
- { name: reminderID, in: path, required: true, schema: { type: string } }
|
|
delete:
|
|
tags: [tasks]
|
|
summary: Delete a reminder.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
# --- projects -------------------------------------------------------------
|
|
|
|
/api/v1/projects:
|
|
get:
|
|
tags: [projects]
|
|
summary: Projects you can see.
|
|
parameters:
|
|
- { name: archived, in: query, schema: { type: boolean } }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
projects:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Project" }
|
|
post:
|
|
tags: [projects]
|
|
summary: Create a project.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ProjectInput" }
|
|
responses:
|
|
"201": { $ref: "#/components/responses/Project" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/projects/reorder:
|
|
post:
|
|
tags: [projects]
|
|
summary: Set the order projects appear in.
|
|
description: |
|
|
The whole list at once rather than one move at a time. A person has a
|
|
handful of projects, so sending the order you want is simpler than
|
|
midpoints and cannot land half-applied.
|
|
|
|
Projects you do not own are skipped: `sort_order` is a column on the
|
|
project rather than a preference per viewer, so a shared one stays where
|
|
its owner put it.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ids]
|
|
properties:
|
|
ids:
|
|
type: array
|
|
maxItems: 500
|
|
items: { type: string }
|
|
description: Project ids, top to bottom.
|
|
responses:
|
|
"204": { description: Reordered. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/projects/{projectID}:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [projects]
|
|
summary: One project.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Project" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
patch:
|
|
tags: [projects]
|
|
summary: Change a project. Its owner only.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ProjectInput" }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Project" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
delete:
|
|
tags: [projects]
|
|
summary: Delete a project. Its owner only.
|
|
description: Soft-deleted, and restorable from the trash for 30 days.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/project-groups:
|
|
get:
|
|
tags: [projects]
|
|
summary: Your foldable sidebar groups.
|
|
description: |
|
|
A group is a heading over some of your projects. It is yours rather than
|
|
the project's: it has no members and no roles, and a project shared with
|
|
you can be filed under one of your groups without the owner seeing it.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
groups:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/ProjectGroup" }
|
|
post:
|
|
tags: [projects]
|
|
summary: Make a group.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
|
responses:
|
|
"201": { $ref: "#/components/responses/ProjectGroup" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/project-groups/reorder:
|
|
post:
|
|
tags: [projects]
|
|
summary: Set the order groups appear in.
|
|
description: The whole list at once, for the same reasons as `/projects/reorder`.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ids]
|
|
properties:
|
|
ids:
|
|
type: array
|
|
maxItems: 500
|
|
items: { type: string }
|
|
description: Group ids, top to bottom.
|
|
responses:
|
|
"204": { description: Reordered. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/project-groups/{groupID}:
|
|
parameters:
|
|
- { name: groupID, in: path, required: true, schema: { type: string } }
|
|
patch:
|
|
tags: [projects]
|
|
summary: Rename a group, or fold it.
|
|
description: |
|
|
`collapsed` is stored on the account rather than in the browser. The
|
|
sidebar's width is a property of the screen you are at; a folded group is
|
|
a statement about the group, and is true wherever you sign in.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/ProjectGroup" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
delete:
|
|
tags: [projects]
|
|
summary: Delete a group.
|
|
description: |
|
|
The heading goes; the projects under it come back out as ungrouped. A
|
|
group holds no work of its own, so this is not soft-deleted — there is
|
|
nothing in it to recover.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/trash/projects:
|
|
get:
|
|
tags: [projects]
|
|
summary: Projects you have deleted and can still bring back.
|
|
description: |
|
|
Only your own. Restoring has been possible since the trash existed, but
|
|
the endpoint takes an id — and the id is what you no longer have once the
|
|
project has gone from the interface.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
projects:
|
|
type: array
|
|
items:
|
|
allOf:
|
|
- $ref: "#/components/schemas/Project"
|
|
- type: object
|
|
properties:
|
|
deleted_at: { type: string, format: date-time }
|
|
purge_after:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
When it stops being recoverable. Sent rather than
|
|
left for the client to work out, because the
|
|
retention window is the server's and a second
|
|
copy of it will one day disagree.
|
|
task_count:
|
|
type: integer
|
|
description: Tasks that went with it, and would come back with it.
|
|
|
|
/api/v1/trash/projects/{projectID}/restore:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [projects]
|
|
summary: Restore a deleted project.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Project" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/projects/{projectID}/sections:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [projects]
|
|
summary: Sections in a project.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
sections:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Section" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
post:
|
|
tags: [projects]
|
|
summary: Add a section. Editors and owners.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name: { type: string }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Section" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/sections/{sectionID}:
|
|
parameters:
|
|
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
|
patch:
|
|
tags: [projects]
|
|
summary: Rename or reorder a section.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
sort_order: { type: number }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Section" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
delete:
|
|
tags: [projects]
|
|
summary: Delete a section.
|
|
description: Its tasks are not deleted; they lose their section.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/projects/{projectID}/members:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [projects]
|
|
summary: Who the project is shared with.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
members:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Member" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/projects/{projectID}/members/{userID}:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
- { name: userID, in: path, required: true, schema: { type: string } }
|
|
delete:
|
|
tags: [projects]
|
|
summary: Remove somebody from a project. Its owner only.
|
|
responses:
|
|
"204": { description: Removed. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/projects/{projectID}/invites:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [projects]
|
|
summary: Invite somebody. Its owner only.
|
|
description: |
|
|
With no mail server configured the response carries the link instead of
|
|
emailing it, so the invite is not silently lost.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email, role]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
role: { type: string, enum: [editor, viewer] }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
link: { type: string }
|
|
emailed: { type: boolean }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/projects/{projectID}/activity:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [projects]
|
|
summary: What has happened in a project.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
activity:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Activity" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
# --- views ----------------------------------------------------------------
|
|
|
|
/api/v1/today:
|
|
get:
|
|
tags: [views]
|
|
summary: What is overdue, and what is due today.
|
|
description: Resolved in the caller's timezone, not the server's.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
overdue:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
today:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
|
|
/api/v1/upcoming:
|
|
get:
|
|
tags: [views]
|
|
summary: The next few days, empty ones included.
|
|
parameters:
|
|
- { name: days, in: query, schema: { type: integer, default: 7 } }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
days:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
date: { type: string, format: date }
|
|
tasks:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
|
|
/api/v1/delegated:
|
|
get:
|
|
tags: [views]
|
|
summary: What you are waiting on other people for.
|
|
description: |
|
|
Everything assigned to somebody who is not you, in the projects you can
|
|
see, grouped by the person it is assigned to.
|
|
|
|
Grouped rather than flat because the question is about people, and a
|
|
client grouping it itself would need every assignee's name — which it has
|
|
no way to ask for. The names come back with the tasks, so one request is
|
|
one screen.
|
|
parameters:
|
|
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
people:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
user_id: { type: string }
|
|
name: { type: string }
|
|
avatar_color: { type: string }
|
|
tasks:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
|
|
/api/v1/search:
|
|
get:
|
|
tags: [views]
|
|
summary: Full-text search over tasks.
|
|
description: |
|
|
Diacritic-folded, so "gron" finds "grøn". Danish treats ø and æ as letters
|
|
of their own rather than as accented forms, which is why the index carries
|
|
a folded copy of the text beside the original.
|
|
parameters:
|
|
- { name: q, in: query, required: true, schema: { type: string } }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/TaskList" }
|
|
|
|
/api/v1/labels:
|
|
get:
|
|
tags: [views]
|
|
summary: Your labels, with how many tasks carry each.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
labels:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Label" }
|
|
post:
|
|
tags: [views]
|
|
summary: Create a label.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name: { type: string }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Label" }
|
|
"409": { $ref: "#/components/responses/Conflict" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/labels/{labelID}:
|
|
parameters:
|
|
- { name: labelID, in: path, required: true, schema: { type: string } }
|
|
patch:
|
|
tags: [views]
|
|
summary: Rename or recolour a label.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
color: { type: string }
|
|
sort_order: { type: number }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Label" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
delete:
|
|
tags: [views]
|
|
summary: Delete a label.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/filters:
|
|
get:
|
|
tags: [views]
|
|
summary: Saved filters.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
filters:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Filter" }
|
|
post:
|
|
tags: [views]
|
|
summary: Save a filter.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/FilterInput" }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Filter" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/filters/{filterID}:
|
|
parameters:
|
|
- { name: filterID, in: path, required: true, schema: { type: string } }
|
|
patch:
|
|
tags: [views]
|
|
summary: Change a saved filter.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/FilterInput" }
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Filter" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
delete:
|
|
tags: [views]
|
|
summary: Delete a saved filter.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/filters/{filterID}/tasks:
|
|
parameters:
|
|
- { name: filterID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [views]
|
|
summary: Run a saved filter.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/TaskList" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/filters/preview:
|
|
get:
|
|
tags: [views]
|
|
summary: Run a filter expression without saving it.
|
|
description: |
|
|
The expression is compiled to a bound SQL fragment, never assembled from
|
|
the text — see the filter language in the documentation.
|
|
parameters:
|
|
- { name: query, in: query, required: true, schema: { type: string, example: "i dag & p1 | @arbejde" } }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/TaskList" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
# --- integrations ---------------------------------------------------------
|
|
|
|
/api/v1/tokens:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Your API tokens.
|
|
description: Needs a session. A bearer token is refused with 403.
|
|
security:
|
|
- session: []
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
tokens:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/ApiToken" }
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
post:
|
|
tags: [integrations]
|
|
summary: Mint an API token.
|
|
description: |
|
|
The plaintext comes back here and never again — only its hash is stored.
|
|
Needs a session: a token that can mint another token is a leak that
|
|
revoking the first one does not close.
|
|
security:
|
|
- session: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name: { type: string, maxLength: 80 }
|
|
expires_in_days:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 3650
|
|
default: 0
|
|
description: 0 means it never expires.
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/ApiToken"
|
|
- type: object
|
|
properties:
|
|
token: { type: string, example: vrd_… }
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/tokens/{tokenID}:
|
|
parameters:
|
|
- { name: tokenID, in: path, required: true, schema: { type: string } }
|
|
delete:
|
|
tags: [integrations]
|
|
summary: Revoke an API token, immediately.
|
|
security:
|
|
- session: []
|
|
responses:
|
|
"204": { description: Revoked. }
|
|
"403": { $ref: "#/components/responses/Forbidden" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/feed:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Your calendar subscription URL.
|
|
description: |
|
|
Minted on first ask. The token in the URL is the whole credential — a
|
|
calendar client cannot log in — so treat the address as a password.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/FeedURL" }
|
|
|
|
/api/v1/feed/rotate:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Issue a new feed URL.
|
|
description: |
|
|
Breaks every existing subscription at once. That is the point: it is what
|
|
you do when the old address has ended up somewhere it should not.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/FeedURL" }
|
|
|
|
/api/v1/mail-address:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Your personal mail-to-task address.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/MailAddress" }
|
|
|
|
/api/v1/mail-address/rotate:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Issue a new mail-to-task address.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/MailAddress" }
|
|
|
|
/api/v1/notifications:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Recent notifications, and how many are unread.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
notifications:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Notification" }
|
|
unread: { type: integer }
|
|
|
|
/api/v1/notifications/read:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Mark every notification as read.
|
|
responses:
|
|
"204": { description: Marked. }
|
|
|
|
/api/v1/notifications/{notificationID}/read:
|
|
parameters:
|
|
- { name: notificationID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [integrations]
|
|
summary: Mark one notification as read.
|
|
responses:
|
|
"204": { description: Marked. }
|
|
|
|
/api/v1/push/key:
|
|
get:
|
|
tags: [integrations]
|
|
summary: The instance's VAPID public key.
|
|
description: What `PushManager.subscribe` needs before it will produce a subscription.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
public_key: { type: string, description: base64url }
|
|
|
|
/api/v1/push/subscribe:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Register a Web Push subscription for this device.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/PushSubscription" }
|
|
responses:
|
|
"204": { description: Registered. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/push/unsubscribe:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Forget a subscription.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/PushSubscription" }
|
|
responses:
|
|
"204": { description: Forgotten. }
|
|
|
|
/api/v1/gmail:
|
|
get:
|
|
tags: [integrations]
|
|
summary: The Gmail connection and what triggers a task.
|
|
responses:
|
|
"200": { $ref: "#/components/responses/Gmail" }
|
|
put:
|
|
tags: [integrations]
|
|
summary: Change what triggers a task.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
trigger: { type: string, enum: [starred, label, both] }
|
|
label: { type: string }
|
|
responses:
|
|
"204": { description: Saved. }
|
|
delete:
|
|
tags: [integrations]
|
|
summary: Disconnect Gmail and forget its tokens.
|
|
responses:
|
|
"204": { description: Disconnected. }
|
|
|
|
/api/v1/gmail/authorize:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Begin the OAuth flow.
|
|
description: |
|
|
Returns the Google URL to send the browser to. The PKCE verifier and state
|
|
are kept server-side and expire in ten minutes.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
url: { type: string }
|
|
"409":
|
|
$ref: "#/components/responses/Conflict"
|
|
|
|
/api/v1/gmail/sync:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Poll Gmail now.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
created: { type: integer }
|
|
"502": { $ref: "#/components/responses/Upstream" }
|
|
|
|
/api/v1/ai/settings:
|
|
get:
|
|
tags: [integrations]
|
|
summary: The AI provider in use.
|
|
description: |
|
|
The key itself is never sent back; `has_key` says whether one is stored.
|
|
A settings page that repopulates a password field is one that will
|
|
eventually leak a key into a screenshot.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/AISettings" }
|
|
put:
|
|
tags: [integrations]
|
|
summary: Set the AI provider.
|
|
description: An empty `api_key` leaves the stored key alone rather than clearing it.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/AISettings"
|
|
- type: object
|
|
properties:
|
|
api_key: { type: string }
|
|
responses:
|
|
"204": { description: Saved. }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/ai/summary:
|
|
post:
|
|
tags: [integrations]
|
|
summary: A short prioritisation note over what is outstanding.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
summary: { type: string }
|
|
"409":
|
|
$ref: "#/components/responses/Conflict"
|
|
"502": { $ref: "#/components/responses/Upstream" }
|
|
|
|
/api/v1/ai/tasks/{taskID}/split:
|
|
parameters:
|
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [integrations]
|
|
summary: Break a task into sub-tasks and create them.
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
subtasks:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"409":
|
|
$ref: "#/components/responses/Conflict"
|
|
"502": { $ref: "#/components/responses/Upstream" }
|
|
|
|
/api/v1/mcp:
|
|
post:
|
|
tags: [integrations]
|
|
summary: The Model Context Protocol endpoint.
|
|
description: |
|
|
JSON-RPC 2.0. A connector authenticating with an API token sees exactly
|
|
what its owner sees. A notification — a message with no `id` — is answered
|
|
with 202 and no body, as the specification requires.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [jsonrpc, method]
|
|
properties:
|
|
jsonrpc: { type: string, const: "2.0" }
|
|
id: { oneOf: [{ type: string }, { type: integer }] }
|
|
method: { type: string }
|
|
params: { type: object }
|
|
responses:
|
|
"200":
|
|
description: A JSON-RPC response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
jsonrpc: { type: string, const: "2.0" }
|
|
id: { oneOf: [{ type: string }, { type: integer }] }
|
|
result: { type: object }
|
|
error: { type: object }
|
|
"202": { description: A notification. No body. }
|
|
|
|
/api/v1/version:
|
|
get:
|
|
tags: [integrations]
|
|
summary: The running version, and whether a newer one exists.
|
|
description: |
|
|
The update notice is only filled in for an administrator — telling an
|
|
ordinary member the server is out of date is telling them about somebody
|
|
else's job.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Version" }
|
|
|
|
/api/v1/ws:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Live changes, over a WebSocket.
|
|
description: |
|
|
One-way by design: nothing a client sends over the socket changes
|
|
anything. Writes go through the REST API, where the permission checks are.
|
|
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
|
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
|
`reminder`.
|
|
responses:
|
|
"101": { description: Switching protocols. }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
|
|
/api/v1/ping:
|
|
get:
|
|
tags: [integrations]
|
|
summary: A liveness check that does not touch the database.
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status: { type: string, const: ok }
|
|
|
|
# --- data -----------------------------------------------------------------
|
|
|
|
/api/v1/import/todoist:
|
|
post:
|
|
tags: [data]
|
|
summary: Import a Todoist CSV export.
|
|
description: |
|
|
Priorities are converted, not copied: Todoist's CSV writes 4 for what its
|
|
interface calls P1. What could not be brought across exactly comes back in
|
|
`warnings` rather than being dropped silently.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
multipart/form-data:
|
|
schema:
|
|
type: object
|
|
required: [file]
|
|
properties:
|
|
file: { type: string, format: binary }
|
|
name: { type: string, description: Defaults to the file's own name. }
|
|
responses:
|
|
"201": { $ref: "#/components/responses/ImportResult" }
|
|
"413": { $ref: "#/components/responses/TooLarge" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/import/csv:
|
|
post:
|
|
tags: [data]
|
|
summary: Import mapped rows from any CSV.
|
|
description: |
|
|
The file is parsed in the browser and the mapped rows arrive as JSON, so
|
|
the column-mapping interface does not have to upload the file twice.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mapping, rows]
|
|
properties:
|
|
name: { type: string }
|
|
mapping:
|
|
type: object
|
|
additionalProperties: { type: string }
|
|
example: { content: Opgave, due: Frist }
|
|
rows:
|
|
type: array
|
|
maxItems: 5000
|
|
items:
|
|
type: object
|
|
additionalProperties: { type: string }
|
|
responses:
|
|
"201": { $ref: "#/components/responses/ImportResult" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/export/account:
|
|
get:
|
|
tags: [data]
|
|
summary: Everything, as JSON.
|
|
description: |
|
|
The "you can leave" guarantee, and deliberately the raw shape: an export
|
|
that has been tidied is an export that has lost something.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { type: object }
|
|
|
|
/api/v1/export/projects/{projectID}.csv:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [data]
|
|
summary: One project as a Todoist-compatible CSV.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
text/csv:
|
|
schema: { type: string }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/export/projects/{projectID}.ics:
|
|
parameters:
|
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [data]
|
|
summary: One project as a calendar file.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
text/calendar:
|
|
schema: { type: string }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/templates:
|
|
get:
|
|
tags: [data]
|
|
summary: Your project templates.
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
templates:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Template" }
|
|
post:
|
|
tags: [data]
|
|
summary: Save a project as a template.
|
|
description: Due dates are stored relative to the project's start.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [project_id]
|
|
properties:
|
|
project_id: { type: string }
|
|
name: { type: string }
|
|
description: { type: string }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Template" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
/api/v1/templates/{templateID}:
|
|
parameters:
|
|
- { name: templateID, in: path, required: true, schema: { type: string } }
|
|
delete:
|
|
tags: [data]
|
|
summary: Delete a template.
|
|
responses:
|
|
"204": { description: Deleted. }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
|
|
/api/v1/templates/{templateID}/use:
|
|
parameters:
|
|
- { name: templateID, in: path, required: true, schema: { type: string } }
|
|
post:
|
|
tags: [data]
|
|
summary: Create a project from a template.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
start_date:
|
|
type: string
|
|
format: date
|
|
description: Day zero for the template's relative dates. Defaults to today.
|
|
responses:
|
|
"201": { $ref: "#/components/responses/Project" }
|
|
"404": { $ref: "#/components/responses/NotFound" }
|
|
"422": { $ref: "#/components/responses/Validation" }
|
|
|
|
# --- outside /api/v1 -------------------------------------------------------
|
|
|
|
/healthz:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Health check.
|
|
description: |
|
|
Reads the database rather than merely answering, so a container with a
|
|
broken volume reports unhealthy instead of pretending to work.
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: Healthy. Carries the running version — the one thing you can ask without a session.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status: { type: string, const: ok }
|
|
version: { type: string, example: v0.3.1 }
|
|
"503": { description: Unhealthy. }
|
|
|
|
/ics/{token}:
|
|
parameters:
|
|
- { name: token, in: path, required: true, schema: { type: string } }
|
|
get:
|
|
tags: [integrations]
|
|
summary: The calendar feed itself.
|
|
description: |
|
|
Unauthenticated by necessity: a calendar client fetches a URL with no
|
|
credentials and no way to prompt for any, so the token in the path is the
|
|
credential. A missing feed answers 404 rather than 401, because a
|
|
calendar client would show a password prompt nobody can answer.
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
text/calendar:
|
|
schema: { type: string }
|
|
"404": { description: No such feed. }
|
|
|
|
/inbound/mail:
|
|
post:
|
|
tags: [integrations]
|
|
summary: Turn a delivered email into a task.
|
|
description: |
|
|
Called by the mail server, not by a browser. Authenticated by the token in
|
|
the recipient address, which is the only credential inbound mail carries.
|
|
The subject line goes through the quick-add parser.
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [to]
|
|
properties:
|
|
to: { type: string }
|
|
from: { type: string }
|
|
subject: { type: string }
|
|
body: { type: string }
|
|
responses:
|
|
"201":
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
task_id: { type: string }
|
|
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
|
|
|
/oauth/gmail/callback:
|
|
get:
|
|
tags: [integrations]
|
|
summary: Where Google sends the browser after consent.
|
|
description: |
|
|
Answers with a redirect to `/indstillinger`, not with JSON: the person is
|
|
looking at a browser tab, not at a fetch response.
|
|
parameters:
|
|
- { name: code, in: query, schema: { type: string } }
|
|
- { name: state, in: query, schema: { type: string } }
|
|
- { name: error, in: query, schema: { type: string } }
|
|
responses:
|
|
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
|
|
|
/mcp:
|
|
post:
|
|
tags: [integrations]
|
|
summary: The MCP endpoint, with the token in the query string.
|
|
description: |
|
|
The same server as `/api/v1/mcp`, for a client that can only be given a
|
|
URL. Claude's custom-connector dialog asks for a name, an address and
|
|
OAuth client credentials — there is no field for a bearer token — so the
|
|
API path cannot be configured from it.
|
|
|
|
The key is the only credential accepted here. A session cookie is
|
|
ignored: this route sits outside the CSRF check, and honouring a cookie
|
|
would make it a cross-site request that acts as whoever is signed in.
|
|
security: []
|
|
parameters:
|
|
- name: key
|
|
in: query
|
|
required: true
|
|
schema: { type: string, example: vrd_… }
|
|
description: A personal API token. Treat the whole address as a password.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [jsonrpc, method]
|
|
properties:
|
|
jsonrpc: { type: string, const: "2.0" }
|
|
id: { oneOf: [{ type: string }, { type: integer }] }
|
|
method: { type: string }
|
|
params: { type: object }
|
|
responses:
|
|
"200":
|
|
description: A JSON-RPC response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
jsonrpc: { type: string, const: "2.0" }
|
|
id: { oneOf: [{ type: string }, { type: integer }] }
|
|
result: { type: object }
|
|
error: { type: object }
|
|
"202": { description: A notification. No body. }
|
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
|
|
/.well-known/caldav:
|
|
get:
|
|
tags: [integrations]
|
|
summary: CalDAV discovery.
|
|
description: |
|
|
The rest of the CalDAV surface lives under `/caldav` and speaks WebDAV
|
|
verbs — PROPFIND, REPORT — which are outside what OpenAPI describes. See
|
|
the CalDAV page in the documentation.
|
|
security: []
|
|
responses:
|
|
"301": { description: To the CalDAV root. }
|
|
|
|
components:
|
|
securitySchemes:
|
|
session:
|
|
type: apiKey
|
|
in: cookie
|
|
name: __Host-verdande_session
|
|
description: |
|
|
Set by `/auth/login`. `httpOnly`, `SameSite=Lax`, and prefixed `__Host-`
|
|
when served over HTTPS. Over plain HTTP the prefix is invalid and the
|
|
cookie is named `verdande_session`.
|
|
bearer:
|
|
type: http
|
|
scheme: bearer
|
|
description: |
|
|
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
|
there is no separate service identity — and is refused on `/tokens`.
|
|
|
|
responses:
|
|
BadRequest:
|
|
description: The body could not be read.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
Unauthorized:
|
|
description: Not signed in, or the credential is wrong.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
Forbidden:
|
|
description: A cross-site request, or a token where a session is required.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
NotFound:
|
|
description: Does not exist, or you cannot see it. The two are not distinguished.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
Conflict:
|
|
description: Already exists, or the state does not allow it.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
TooLarge:
|
|
description: The body or the file is too large.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
Validation:
|
|
description: One or more fields are not valid. See `fields`.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
RateLimited:
|
|
description: Too many attempts. Carries `Retry-After`.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
Upstream:
|
|
description: An external service failed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
Task:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Task" }
|
|
TaskList:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
tasks:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Task" }
|
|
Project:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Project" }
|
|
ProjectGroup:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ProjectGroup" }
|
|
FeedURL:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
url: { type: string }
|
|
MailAddress:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
address: { type: string, example: todo+abc123@example.dk }
|
|
configured:
|
|
type: boolean
|
|
description: |
|
|
Whether a mail server is set up at all. The address is real
|
|
either way, but with no mail server nothing on the internet
|
|
routes to it — and an interface that does not say so is
|
|
promising something that will not happen.
|
|
Gmail:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
connected: { type: boolean }
|
|
email: { type: string }
|
|
trigger: { type: string, enum: ["", starred, label, both] }
|
|
label: { type: string }
|
|
ImportResult:
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
project_id: { type: string }
|
|
projects: { type: integer }
|
|
tasks: { type: integer }
|
|
sections: { type: integer }
|
|
comments: { type: integer }
|
|
warnings:
|
|
type: array
|
|
items: { type: string }
|
|
|
|
schemas:
|
|
Error:
|
|
type: object
|
|
description: |
|
|
The one shape every failure takes. `code` is stable and meant to be
|
|
matched on; `error` is English prose for a log, and what a person reads is
|
|
decided by the client, which knows what language they are in.
|
|
required: [code, error]
|
|
properties:
|
|
code:
|
|
type: string
|
|
enum:
|
|
- bad_request
|
|
- validation_failed
|
|
- unauthorized
|
|
- totp_required
|
|
- forbidden
|
|
- not_found
|
|
- conflict
|
|
- rate_limited
|
|
- internal_error
|
|
- payload_too_large
|
|
- gmail_not_configured
|
|
- ai_not_configured
|
|
- totp_not_enabled
|
|
- inbox_protected
|
|
error: { type: string }
|
|
fields:
|
|
type: object
|
|
additionalProperties: { type: string }
|
|
example: { due_date: "must be a date like 2026-03-15" }
|
|
|
|
User:
|
|
type: object
|
|
description: Never carries the password hash or the TOTP secret.
|
|
properties:
|
|
id: { type: string }
|
|
email: { type: string, format: email }
|
|
name: { type: string }
|
|
avatar_color: { type: string }
|
|
timezone: { type: string }
|
|
locale: { type: string, enum: [da, en] }
|
|
is_admin: { type: boolean }
|
|
totp_enabled: { type: boolean }
|
|
created_at: { type: string, format: date-time }
|
|
|
|
RecoveryCodes:
|
|
type: object
|
|
properties:
|
|
recovery_codes:
|
|
type: array
|
|
items: { type: string }
|
|
|
|
Task:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
project_id: { type: string }
|
|
section_id: { type: string }
|
|
parent_id: { type: string }
|
|
content: { type: string }
|
|
description: { type: string }
|
|
priority: { type: integer, minimum: 1, maximum: 4, description: 1 is most urgent. }
|
|
due_date: { type: string, format: date }
|
|
due_datetime: { type: string, format: date-time }
|
|
duration_min: { type: integer }
|
|
recurrence_rule: { type: string, description: An RFC 5545 RRULE. }
|
|
recurrence_text: { type: string, description: "The rule in words, for display." }
|
|
assignee_id: { type: string }
|
|
labels:
|
|
type: array
|
|
items: { type: string }
|
|
completed: { type: boolean }
|
|
completed_at: { type: string, format: date-time }
|
|
created_by: { type: string }
|
|
sort_order: { type: number }
|
|
created_at: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
|
|
TaskInput:
|
|
type: object
|
|
properties:
|
|
project_id:
|
|
type: string
|
|
description: |
|
|
Omitted on create, the task goes to the Inbox. On update it moves the
|
|
task — which needs editor rights on the destination.
|
|
|
|
section_id: { type: string }
|
|
parent_id: { type: string }
|
|
content: { type: string }
|
|
description: { type: string }
|
|
priority: { type: integer, minimum: 1, maximum: 4 }
|
|
due_date: { type: string, format: date }
|
|
due_time: { type: string, example: "10:00" }
|
|
duration_min: { type: integer }
|
|
recurrence_rule: { type: string }
|
|
assignee_id: { type: string }
|
|
labels:
|
|
type: array
|
|
items: { type: string }
|
|
sort_order: { type: number }
|
|
|
|
QuickAddResult:
|
|
allOf:
|
|
- $ref: "#/components/schemas/Task"
|
|
- type: object
|
|
properties:
|
|
unknown_project:
|
|
type: string
|
|
description: |
|
|
Present when the text named a `#project` that does not exist. The
|
|
task is still created, in the Inbox — a thought is not thrown away
|
|
over a typo — but the caller is told, because the alternative is
|
|
that the name vanishes from the title and nothing says why.
|
|
|
|
ParsedTask:
|
|
type: object
|
|
description: What the quick-add parser made of a line.
|
|
properties:
|
|
content: { type: string }
|
|
project: { type: string }
|
|
priority: { type: integer }
|
|
due_date: { type: string, format: date }
|
|
due_datetime: { type: string, format: date-time }
|
|
recurrence: { type: string }
|
|
labels:
|
|
type: array
|
|
items: { type: string }
|
|
|
|
Project:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
color: { type: string }
|
|
icon: { type: string }
|
|
view_mode: { type: string, enum: [list, board, calendar] }
|
|
owner_id: { type: string }
|
|
is_inbox: { type: boolean }
|
|
archived: { type: boolean }
|
|
sort_order: { type: number }
|
|
role: { type: string, enum: [owner, editor, viewer] }
|
|
member_count: { type: integer }
|
|
shared: { type: boolean }
|
|
group_id:
|
|
type: string
|
|
description: |
|
|
The sidebar group *you* have filed it under, absent for none. Always
|
|
absent on a project shared with you: grouping is a column on the
|
|
project row, so it is the owner's filing rather than yours.
|
|
|
|
ProjectInput:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
color: { type: string }
|
|
icon: { type: string }
|
|
view_mode: { type: string, enum: [list, board, calendar] }
|
|
archived: { type: boolean }
|
|
sort_order: { type: number }
|
|
group_id:
|
|
type: string
|
|
description: |
|
|
Files the project under one of your groups. The empty string takes it
|
|
out of one — `null` cannot mean that, because absent and null are the
|
|
same thing once decoded, and telling "put it nowhere" apart from "I
|
|
did not mention it" is what makes this a PATCH.
|
|
|
|
ProjectGroup:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
collapsed: { type: boolean }
|
|
sort_order: { type: number }
|
|
|
|
ProjectGroupInput:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
collapsed: { type: boolean }
|
|
|
|
Section:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
project_id: { type: string }
|
|
name: { type: string }
|
|
sort_order: { type: number }
|
|
|
|
Member:
|
|
type: object
|
|
properties:
|
|
user_id: { type: string }
|
|
email: { type: string, format: email }
|
|
name: { type: string }
|
|
avatar_color: { type: string }
|
|
role: { type: string, enum: [owner, editor, viewer] }
|
|
|
|
Activity:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
project_id: { type: string }
|
|
task_id: { type: string }
|
|
actor_id: { type: string }
|
|
actor_name: { type: string }
|
|
kind: { type: string, example: task.completed }
|
|
data: { type: object }
|
|
created_at: { type: string, format: date-time }
|
|
|
|
Label:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
color: { type: string }
|
|
sort_order: { type: number }
|
|
task_count: { type: integer }
|
|
|
|
Filter:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
query: { type: string, example: "i dag & p1" }
|
|
color: { type: string }
|
|
sort_order: { type: number }
|
|
|
|
FilterInput:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
query: { type: string }
|
|
color: { type: string }
|
|
sort_order: { type: number }
|
|
|
|
Comment:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
task_id: { type: string }
|
|
user_id: { type: string }
|
|
user_name: { type: string }
|
|
user_color: { type: string }
|
|
body: { type: string }
|
|
attachments:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Attachment" }
|
|
created_at: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
|
|
Attachment:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
filename: { type: string }
|
|
mime_type: { type: string }
|
|
size: { type: integer }
|
|
url: { type: string }
|
|
|
|
Reminder:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
task_id: { type: string }
|
|
remind_at: { type: string, format: date-time }
|
|
offset_min: { type: integer }
|
|
sent: { type: boolean }
|
|
|
|
Template:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
description: { type: string }
|
|
color: { type: string }
|
|
task_count: { type: integer }
|
|
created_at: { type: string, format: date-time }
|
|
|
|
ApiToken:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
prefix: { type: string, description: "The first characters, which is all that is ever shown again." }
|
|
created_at: { type: string, format: date-time }
|
|
last_used_at: { type: string, format: date-time }
|
|
expires_at: { type: string, format: date-time }
|
|
|
|
Notification:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
actor_name: { type: string }
|
|
project_id: { type: string }
|
|
task_id: { type: string }
|
|
kind: { type: string }
|
|
title: { type: string }
|
|
body: { type: string }
|
|
read: { type: boolean }
|
|
created_at: { type: string, format: date-time }
|
|
|
|
PushSubscription:
|
|
type: object
|
|
required: [endpoint]
|
|
properties:
|
|
endpoint: { type: string }
|
|
keys:
|
|
type: object
|
|
properties:
|
|
p256dh: { type: string }
|
|
auth: { type: string }
|
|
|
|
AISettings:
|
|
type: object
|
|
properties:
|
|
provider: { type: string, enum: ["", anthropic, openai, google, compatible] }
|
|
base_url: { type: string }
|
|
model: { type: string }
|
|
has_key: { type: boolean, readOnly: true }
|
|
|
|
Version:
|
|
type: object
|
|
properties:
|
|
current: { type: string }
|
|
latest: { type: string }
|
|
update_available: { type: boolean }
|
|
url: { type: string }
|
|
notes: { type: string }
|
|
checked_at: { type: string, format: date-time }
|
|
disabled:
|
|
type: boolean
|
|
description: |
|
|
Distinguishes "no update" from "we never looked", which the interface
|
|
has to show differently or it implies a guarantee it cannot make.
|