2026-08-17 10:40:54 +02:00
|
|
|
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": { $ref: "#/components/responses/Task" }
|
|
|
|
|
"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/{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/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/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:
|
2026-08-17 14:33:00 +02:00
|
|
|
"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 }
|
2026-08-17 10:40:54 +02:00
|
|
|
"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." }
|
|
|
|
|
|
2026-08-17 14:00:39 +02:00
|
|
|
/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" }
|
|
|
|
|
|
2026-08-17 10:40:54 +02:00
|
|
|
/.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" }
|
|
|
|
|
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 }
|
2026-08-17 14:32:42 +02:00
|
|
|
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.
|
2026-08-17 10:40:54 +02:00
|
|
|
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
|
2026-08-17 14:32:42 +02:00
|
|
|
- gmail_not_configured
|
|
|
|
|
- ai_not_configured
|
|
|
|
|
- totp_not_enabled
|
|
|
|
|
- inbox_protected
|
2026-08-17 10:40:54 +02:00
|
|
|
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." }
|
|
|
|
|
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 }
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
|
|
|
|
|
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.
|