Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4784fd174 | ||
|
|
094ea5cdd8 | ||
|
|
f60758b770 | ||
|
|
9d390d021f | ||
|
|
18db79c063 | ||
|
|
1612f53966 | ||
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
84ca506961 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
1dc9751a33 | ||
|
|
b81f8f67cc | ||
|
|
f62ffc8d92 | ||
|
|
73ee93deab | ||
|
|
b2fcc0b104 | ||
|
|
8dc601d09a | ||
|
|
85e769a603 | ||
|
|
0363d5f422 | ||
|
|
05b7dac713 | ||
|
|
cfd382e3df | ||
|
|
bbcff40401 | ||
|
|
01001d0980 | ||
|
|
4ac7180cda | ||
|
|
e1683ef09d | ||
|
|
f89fb21502 | ||
|
|
ec147cd1ba | ||
|
|
1f9f14099b | ||
|
|
6cf63113df | ||
|
|
e24a0fa849 | ||
|
|
1c223a2c26 | ||
|
|
a9c90ee6f1 | ||
|
|
dc4a584d5f | ||
|
|
378980de0c | ||
|
|
04c7a52a5a | ||
|
|
52df528994 | ||
|
|
2f00246d6f | ||
|
|
acec77a20c | ||
|
|
59413a8b64 | ||
|
|
cf69cac410 | ||
|
|
b0998fe524 | ||
|
|
8dfab618f7 | ||
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 | ||
|
|
e94d731c30 | ||
|
|
0aa0ec2989 | ||
|
|
21b60b007a | ||
|
|
7066e2e06e | ||
|
|
2b76a172e8 | ||
|
|
15495255ef | ||
|
|
3b9b68f4b1 | ||
|
|
7dcd475036 | ||
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c | ||
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 | ||
|
|
7f8d925299 | ||
|
|
cdab817141 | ||
|
|
2e76ced5fa | ||
|
|
711fb4838b | ||
|
|
643b30555e | ||
|
|
91671a4f97 | ||
|
|
15be5d2165 | ||
|
|
77994eff09 | ||
|
|
b4038356f0 | ||
|
|
8a87cfeb44 |
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
|||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
lists — on your own hardware, with your own data, and with the integrations that a
|
||||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||||
|
and an MCP endpoint so Claude can work your task list directly.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| CalDAV server (two-way VTODO) | Done, tested |
|
| CalDAV server (two-way VTODO) | Done, tested |
|
||||||
| Mail-to-task | Done, tested |
|
| Mail-to-task | Done, tested |
|
||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
|
||||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ func run() error {
|
|||||||
log.Info("notes sealed", "count", sealed)
|
log.Info("notes sealed", "count", sealed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Names created before the app capitalised them on the way in — "andreas"
|
||||||
|
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||||
|
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||||
|
// a blemish, not a broken instance.
|
||||||
|
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||||
|
log.Error("normalize user names", "err", err)
|
||||||
|
} else if fixed > 0 {
|
||||||
|
log.Info("user names normalized", "count", fixed)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
|
|||||||
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||||
-H "Authorization: Bearer vrd_..." \
|
-H "Authorization: Bearer vrd_..." \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
|
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Projects and sections
|
### Projects and sections
|
||||||
@@ -108,7 +108,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| Method | Path | |
|
| Method | Path | |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/today` | Overdue and due today, in your timezone. |
|
| `GET` | `/today` | Overdue and due today, in your timezone. |
|
||||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
|
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
|
||||||
| `GET` | `/search?q=` | Across everything you can see. |
|
| `GET` | `/search?q=` | Across everything you can see. |
|
||||||
| `GET` `POST` | `/filters` | |
|
| `GET` `POST` | `/filters` | |
|
||||||
| `GET` | `/filters/{id}/tasks` | Run one. |
|
| `GET` | `/filters/{id}/tasks` | Run one. |
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
|
|||||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||||
|
|
||||||
|
## Behind a proxy
|
||||||
|
|
||||||
|
verdande keys its login rate limit and its audit log on the caller's address, so
|
||||||
|
that address has to be one the caller cannot forge. A forwarded-for header is
|
||||||
|
believed only when the machine that connected is a trusted proxy; a connection
|
||||||
|
straight off the internet is keyed on the address it really came from.
|
||||||
|
|
||||||
|
| Variable | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
|
||||||
|
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
|
||||||
|
|
||||||
|
!!! warning "A proxy that does not overwrite the header"
|
||||||
|
The default is safe when your proxy replaces a client-supplied
|
||||||
|
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
|
||||||
|
do. If yours passes the client's own header through, narrow
|
||||||
|
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
|
||||||
|
choose the address they are rate-limited and logged as.
|
||||||
|
|
||||||
## Sessions and links
|
## Sessions and links
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
@@ -55,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
|
|||||||
|
|
||||||
| For | Enable | Scope |
|
| For | Enable | Scope |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
| Mailboxes | Gmail API | `gmail.modify` |
|
||||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||||
|
|
||||||
| Variable | What it does |
|
| Variable | What it does |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Saved filters
|
# Saved filters
|
||||||
|
|
||||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||||
Todoist's, so if you are coming from there you already know it.
|
feel familiar if you have used query-based filters in other task apps.
|
||||||
|
|
||||||
```
|
```
|
||||||
today & p1
|
today & p1
|
||||||
|
|||||||
@@ -11,28 +11,46 @@ of. Which is what a to-do list is.
|
|||||||
|
|
||||||
## What it is for
|
## What it is for
|
||||||
|
|
||||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||||
|
lists — on your own hardware, with your own data. And with the integrations a
|
||||||
hosted product will not build for you: a real CalDAV server, an address you can
|
hosted product will not build for you: a real CalDAV server, an address you can
|
||||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
|
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
|
||||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
|
||||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
|
||||||
|
line.
|
||||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
The calendar draws each task to its length — drag the foot to resize, the body
|
||||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
to move it.
|
||||||
and a task shows what has been written about it.
|
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||||
|
single note with a person, or file it in a project; `#project` and
|
||||||
|
`[[another note]]` are links, and a task shows what has been written about it.
|
||||||
|
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||||
|
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||||
|
greyed, at the foot of the list until a moment you pick — without touching when
|
||||||
|
it is due.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
under a group, drop a task on another project, or drop it on another day.
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||||
joins through an invite link.
|
joins through an invite link.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
out as Web Push to an iPhone or a Mac.
|
||||||
open standards rather than private formats.
|
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||||
|
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||||
|
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||||
|
of only filling up.
|
||||||
|
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||||
|
rather than private formats.
|
||||||
|
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||||
|
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||||
|
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||||
|
signal and goes through when it comes back; a reload opens from what it last saw
|
||||||
|
rather than the browser's offline page. It installs as a PWA.
|
||||||
|
|
||||||
## What it looks like
|
## What it looks like
|
||||||
|
|
||||||
@@ -48,7 +66,19 @@ columns.*
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*What you have handed to somebody else, grouped by whoever has it.*
|
*What you have handed to somebody else, grouped by whoever has it — and each row
|
||||||
|
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||||
|
meet it.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
|
||||||
|
amber where the accent green usually is.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Six themes — three dark, three light — chosen on the settings page, with the
|
||||||
|
typeface and how tightly it sits set apart from them.*
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
|
|||||||
| `complete_task` | Tick one off. |
|
| `complete_task` | Tick one off. |
|
||||||
| `add_comment` | Comment on a task. |
|
| `add_comment` | Comment on a task. |
|
||||||
|
|
||||||
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
|
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||||
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
|
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||||
labels and recurrence in either language.
|
projects, labels and recurrence in either language.
|
||||||
|
|
||||||
## What it can reach
|
## What it can reach
|
||||||
|
|
||||||
|
|||||||
@@ -805,14 +805,36 @@ paths:
|
|||||||
"200": { $ref: "#/components/responses/Task" }
|
"200": { $ref: "#/components/responses/Task" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/tasks/{taskID}/snooze:
|
||||||
|
parameters:
|
||||||
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [tasks]
|
||||||
|
summary: Snooze a task until a time, or wake it.
|
||||||
|
description: |
|
||||||
|
Parks the task at the bottom of its list, greyed, until `until` passes —
|
||||||
|
nothing else about it moves. An empty `until` wakes it now.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
|
||||||
|
responses:
|
||||||
|
"200": { $ref: "#/components/responses/Task" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/tasks/quick-add:
|
/api/v1/tasks/quick-add:
|
||||||
post:
|
post:
|
||||||
tags: [tasks]
|
tags: [tasks]
|
||||||
summary: Create a task from one line of natural language.
|
summary: Create a task from one line of natural language.
|
||||||
description: |
|
description: |
|
||||||
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
|
||||||
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
|
||||||
vigtigt. An unknown `#project` is not an error — the task goes to the
|
labelled tax. An unknown `#project` is not an error — the task goes to the
|
||||||
Inbox rather than a thought being thrown away over a typo.
|
Inbox rather than a thought being thrown away over a typo.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1228,7 +1250,7 @@ paths:
|
|||||||
|
|
||||||
The one click that opens Google's consent screen already exists. What it
|
The one click that opens Google's consent screen already exists. What it
|
||||||
needs behind it is a registered client, and no app can conjure one: Google
|
needs behind it is a registered client, and no app can conjure one: Google
|
||||||
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||||
restricted scope, so an id shipped inside a public image would be both
|
restricted scope, so an id shipped inside a public image would be both
|
||||||
extractable from it and unusable without Google's security review of every
|
extractable from it and unusable without Google's security review of every
|
||||||
deployment that used it.
|
deployment that used it.
|
||||||
@@ -2343,7 +2365,12 @@ paths:
|
|||||||
tags: [views]
|
tags: [views]
|
||||||
summary: The next few days, empty ones included.
|
summary: The next few days, empty ones included.
|
||||||
parameters:
|
parameters:
|
||||||
- { name: days, in: query, schema: { type: integer, default: 7 } }
|
- {
|
||||||
|
name: days,
|
||||||
|
in: query,
|
||||||
|
description: "How many days to return, counting today. Above the maximum is capped, not refused.",
|
||||||
|
schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
|
||||||
|
}
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -3072,6 +3099,69 @@ paths:
|
|||||||
"204": { description: Deleted }
|
"204": { description: Deleted }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/notes/{noteID}/shares:
|
||||||
|
parameters:
|
||||||
|
- name: noteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
get:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Who a note is shared with, and who it can be shared with.
|
||||||
|
description: >
|
||||||
|
Owner only. Returns the people the note is already shared with, each with
|
||||||
|
their role, and the candidates the owner may add — everyone on the instance
|
||||||
|
who is not already on the note.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
shares:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/NoteShare" }
|
||||||
|
candidates:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Person" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
post:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Share a note with a person, or change the role they hold.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [user_id]
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
role: { type: string, enum: [viewer, editor] }
|
||||||
|
responses:
|
||||||
|
"204": { description: Shared }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/notes/{noteID}/shares/{userID}:
|
||||||
|
delete:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Take a person's access to a note away again.
|
||||||
|
parameters:
|
||||||
|
- name: noteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: userID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Unshared }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/notes/linking/{kind}/{targetID}:
|
/api/v1/notes/linking/{kind}/{targetID}:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -3907,6 +3997,14 @@ components:
|
|||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
updated_at: { type: string, format: date-time }
|
updated_at: { type: string, format: date-time }
|
||||||
|
shared_with_me:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
True when the reader reached this note through a direct share rather
|
||||||
|
than by owning it or its project. What puts it in the Delt med mig group.
|
||||||
|
owner:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/Person" }]
|
||||||
|
description: Who the note belongs to, present on a note shared with the reader.
|
||||||
links:
|
links:
|
||||||
type: array
|
type: array
|
||||||
description: What the body points at, read out of the text on the way in.
|
description: What the body points at, read out of the text on the way in.
|
||||||
@@ -3923,6 +4021,21 @@ components:
|
|||||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
|
|
||||||
|
Person:
|
||||||
|
type: object
|
||||||
|
description: A user, as much of one as a name and a colour.
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
avatar_color: { type: string }
|
||||||
|
|
||||||
|
NoteShare:
|
||||||
|
type: object
|
||||||
|
description: One person's direct grant on a note.
|
||||||
|
properties:
|
||||||
|
user: { $ref: "#/components/schemas/Person" }
|
||||||
|
role: { type: string, enum: [viewer, editor] }
|
||||||
|
|
||||||
Mailbox:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
description: One connected mailbox. The password and any token are never returned.
|
description: One connected mailbox. The password and any token are never returned.
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
One line in, a fully-formed task out.
|
One line in, a fully-formed task out.
|
||||||
|
|
||||||
```
|
```
|
||||||
betal moms i morgen kl 10 p1 #Firma @regnskab
|
file the VAT return tomorrow at 10am p1 #Accounts @tax
|
||||||
```
|
```
|
||||||
|
|
||||||
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
|
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
|
||||||
Firma project, labelled regnskab.
|
in the Accounts project, labelled tax.
|
||||||
|
|
||||||
The parts it understands are tinted underneath the text as you type, so you can see
|
The parts it understands are tinted underneath the text as you type, so you can see
|
||||||
it being read before you commit to it.
|
it being read before you commit to it.
|
||||||
@@ -16,8 +16,8 @@ it being read before you commit to it.
|
|||||||
|
|
||||||
| You type | It means |
|
| You type | It means |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
|
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
|
||||||
| `@regnskab` | Add that label. As many as you like. |
|
| `@tax` | Add that label. As many as you like. |
|
||||||
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
||||||
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
||||||
| `mandag`, `på fredag` | The next such weekday. |
|
| `mandag`, `på fredag` | The next such weekday. |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 220 KiB |
@@ -1,7 +1,8 @@
|
|||||||
# Self-hosting
|
# With Docker
|
||||||
|
|
||||||
verdande is one static binary and one directory. There is no database server, no
|
Running it yourself outside a panel: one static binary and one directory. There is
|
||||||
cache, no queue and no second process.
|
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||||
|
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
@@ -14,9 +16,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|||||||
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPStep returns the step a code matched, and it must be the same step across
|
||||||
|
// the tolerance window — that number is what makes a code single-use, so a code
|
||||||
|
// entered a little early or late has to spend the same step, not a new one.
|
||||||
|
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
|
||||||
|
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
|
||||||
|
code := codeAt(t, secret, now)
|
||||||
|
|
||||||
|
want, err := TOTPStep(secret, code, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the current code did not match: %v", err)
|
||||||
|
}
|
||||||
|
// Verified one step late — the code is still valid, and must report the step it
|
||||||
|
// was issued at, not the step it was checked at.
|
||||||
|
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("code rejected one step late: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := TOTPStep(secret, "000000", now); err == nil {
|
||||||
|
t.Error("a wrong code returned a step")
|
||||||
|
}
|
||||||
|
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
|
||||||
|
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// codeAt produces the code an authenticator app would display at that moment,
|
// codeAt produces the code an authenticator app would display at that moment,
|
||||||
// using the same settings VerifyTOTP checks against.
|
// using the same settings VerifyTOTP checks against.
|
||||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/boombuler/barcode/qr"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
)
|
)
|
||||||
@@ -49,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
|||||||
return key.Secret(), key.URL(), nil
|
return key.Secret(), key.URL(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||||
|
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||||
|
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||||
|
// needs whatever theme the page is in.
|
||||||
|
//
|
||||||
|
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||||
|
// this borrows a dependency rather than adding one.
|
||||||
|
func TOTPQRSVG(uri string) (string, error) {
|
||||||
|
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||||
|
}
|
||||||
|
b := code.Bounds()
|
||||||
|
n := b.Dx()
|
||||||
|
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||||
|
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||||
|
const quiet = 4
|
||||||
|
size := n + quiet*2
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||||
|
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
// Dark modules are black; the red channel is 0 for those and full for the
|
||||||
|
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||||
|
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||||
|
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(`"/></svg>`)
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||||
// code again before enrolment is confirmed.
|
// code again before enrolment is confirmed.
|
||||||
func TOTPURI(secret, issuer, account string) string {
|
func TOTPURI(secret, issuer, account string) string {
|
||||||
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPStep verifies a code and returns the counter of the step it matched, so the
|
||||||
|
// caller can refuse to accept the same step twice.
|
||||||
|
//
|
||||||
|
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
|
||||||
|
// window is not enough to stop a replay: a code seen in flight stays valid for the
|
||||||
|
// rest of its period, and the fix is to record which step was spent and never
|
||||||
|
// accept that step or an earlier one again. That needs the number, so this walks
|
||||||
|
// the same window ValidateCustom would — one step either side — and reports the
|
||||||
|
// counter of the match. The comparison is constant time for the same reason the
|
||||||
|
// password check is: a code is a secret being checked against a computed value.
|
||||||
|
func TOTPStep(secret, code string, now time.Time) (int64, error) {
|
||||||
|
if secret == "" {
|
||||||
|
return 0, ErrNoTOTPSecret
|
||||||
|
}
|
||||||
|
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||||
|
|
||||||
|
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
|
||||||
|
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
|
||||||
|
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
|
||||||
|
Period: totpPeriod,
|
||||||
|
Skew: 0,
|
||||||
|
Digits: totpDigits,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||||
|
return at.Unix() / totpPeriod, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
|
||||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||||
// and the hashes to store. Without these, losing a device means losing the account,
|
// and the hashes to store. Without these, losing a device means losing the account,
|
||||||
// and the only fix left is editing the database by hand.
|
// and the only fix left is editing the database by hand.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -80,6 +81,26 @@ type Config struct {
|
|||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
|
// TrustedProxies are the peers whose forwarded-for header may be believed.
|
||||||
|
//
|
||||||
|
// The rate limiter and the audit log key on the caller's address, and that
|
||||||
|
// address is only as honest as wherever it was read. Read it from a header that
|
||||||
|
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
|
||||||
|
// every request and to write someone else's IP into the log. So the header is
|
||||||
|
// believed only when the machine that *connected* is one of these — a reverse
|
||||||
|
// proxy or tunnel the operator put there — and ignored when the connection came
|
||||||
|
// straight off the internet.
|
||||||
|
//
|
||||||
|
// The default is "the peer is on a private network", which is exactly the shape
|
||||||
|
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
|
||||||
|
// host or the docker bridge, so its address is loopback or RFC1918. A directly
|
||||||
|
// exposed instance sees a public peer, trusts nothing, and keys on the real
|
||||||
|
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
|
||||||
|
// or "none" to trust no proxy at all.
|
||||||
|
TrustedProxies []netip.Prefix
|
||||||
|
// RealIPHeader is where the client address is read from, for a trusted peer.
|
||||||
|
RealIPHeader string
|
||||||
|
|
||||||
Dev bool
|
Dev bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
|
|||||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||||
|
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
|
||||||
|
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
|
||||||
|
// families' link-local. A proxy an operator stands up in front of this server is
|
||||||
|
// on one of these; a stranger connecting directly is on none.
|
||||||
|
var defaultTrustedProxies = mustPrefixes(
|
||||||
|
"127.0.0.0/8", "::1/128",
|
||||||
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"fc00::/7",
|
||||||
|
"169.254.0.0/16", "fe80::/10",
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTrustedProxies reads the override. Empty keeps the private-network default;
|
||||||
|
// the literal "none" trusts no proxy at all, for an instance that is exposed
|
||||||
|
// directly and must never believe a forwarded header.
|
||||||
|
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return defaultTrustedProxies, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(raw, "none") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
|
||||||
|
// naming one proxy does not have to know CIDR notation to do it.
|
||||||
|
if !strings.Contains(part, "/") {
|
||||||
|
addr, err := netip.ParseAddr(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
|
||||||
|
}
|
||||||
|
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := netip.ParsePrefix(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
|
||||||
|
}
|
||||||
|
out = append(out, p.Masked())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||||
|
out := make([]netip.Prefix, 0, len(cidrs))
|
||||||
|
for _, c := range cidrs {
|
||||||
|
out = append(out, netip.MustParsePrefix(c))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
||||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||||
// it asks for.
|
// it asks for.
|
||||||
//
|
//
|
||||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
// The one thing verdande writes back is the star. A starred message becomes a
|
||||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||||
// message does not delete the task, completing the task does not unstar the
|
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
// else is touched. Completing the task does not reach back to the mail; the mail
|
||||||
|
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
||||||
//
|
//
|
||||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||||
// asking for less is what makes the consent screen something a person can agree to.
|
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
||||||
|
// can do it — so the consent screen asks for more than it used to, and the trade
|
||||||
|
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
||||||
|
// than one that fills with stars nothing ever clears. Still not send, still not
|
||||||
|
// delete — modify is labels, and the only label verdande removes is the one that
|
||||||
|
// triggered the task.
|
||||||
//
|
//
|
||||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||||
// The names below are aliases so that every call site — and this package's own
|
// The names below are aliases so that every call site — and this package's own
|
||||||
@@ -27,8 +33,10 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/google"
|
"github.com/kristianwind/verdande/internal/google"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
// send, not delete. The single write is removing the STARRED label from a message
|
||||||
|
// that has become a task; see the package comment for why that costs modify.
|
||||||
|
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
||||||
|
|
||||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||||
|
|
||||||
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
|
|||||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unstar takes the star off the given messages — one call for all of them, the way
|
||||||
|
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
||||||
|
// not ten.
|
||||||
|
//
|
||||||
|
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
||||||
|
// label would need one, and verdande removes none. batchModify answers 204 with no
|
||||||
|
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
||||||
|
// an error on Google's side and nothing worth making a round trip to hear.
|
||||||
|
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
||||||
|
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
||||||
// falling back to the address when there is no display name.
|
// falling back to the address when there is no display name.
|
||||||
func SenderName(from string) string {
|
func SenderName(from string) string {
|
||||||
|
|||||||
@@ -1,16 +1,81 @@
|
|||||||
package gmail
|
package gmail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||||
// one the person reads on the consent screen. The flow itself is exercised in
|
// one the person reads on the consent screen. The flow itself is exercised in
|
||||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
if !strings.HasSuffix(Scope, "gmail.modify") {
|
||||||
|
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
||||||
|
}
|
||||||
|
// Modify is the ceiling. Send, or the full account, would be a different promise
|
||||||
|
// to the person on the consent screen than the one this feature needs.
|
||||||
|
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
||||||
|
if strings.Contains(Scope, wider) {
|
||||||
|
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unstar removes the star from a batch in one call, and it addresses the messages
|
||||||
|
// it was given and only the STARRED label.
|
||||||
|
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
AddLabelIDs []string `json:"addLabelIds"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unstar: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
||||||
|
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
||||||
|
}
|
||||||
|
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
||||||
|
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
||||||
|
}
|
||||||
|
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
||||||
|
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
||||||
|
}
|
||||||
|
if len(gotBody.AddLabelIDs) != 0 {
|
||||||
|
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
||||||
|
// there is nothing to say to it anyway.
|
||||||
|
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
||||||
|
t.Fatalf("Unstar(nil): %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("Unstar(nil) made a request; it should make none")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
|
|||||||
return json.NewDecoder(resp.Body).Decode(out)
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
||||||
|
// body coming back. A write call — modifying labels on a message — answers with
|
||||||
|
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
||||||
|
// decoded, and only when the caller asked for one.
|
||||||
|
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("google: %s", resp.Status)
|
||||||
|
}
|
||||||
|
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
func randomString(n int) (string, error) {
|
func randomString(n int) (string, error) {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||||
|
if err == nil {
|
||||||
|
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||||
|
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||||
|
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||||
|
// the step here makes it single-use: a second presentation of the same code,
|
||||||
|
// or an earlier one, finds the step already gone.
|
||||||
|
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||||
|
if cerr != nil {
|
||||||
|
s.internal(w, r, "consume totp step", cerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fresh {
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||||
|
"that verification code has already been used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A recovery code is accepted in the same field. Making somebody find a
|
// A recovery code is accepted in the same field. Making somebody find a
|
||||||
// different form while locked out of their account is a poor time to
|
// different form while locked out of their account is a poor time to
|
||||||
|
|||||||
@@ -372,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A TOTP code works once. A code seen in flight and replayed inside its window
|
||||||
|
// must be refused the second time, or the second factor is only as good as the
|
||||||
|
// thirty seconds an attacker has to reuse what they captured.
|
||||||
|
func TestTOTPCodeIsSingleUse(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
secret, _ := body["secret"].(string)
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||||
|
"code": codeFor(t, secret),
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One code, captured once and presented twice.
|
||||||
|
code := codeFor(t, secret)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second login, same code, same window.
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||||
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HSTS følger med, når instansen kører over HTTPS — og kun der.
|
||||||
|
//
|
||||||
|
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
|
||||||
|
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
|
||||||
|
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
|
||||||
|
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
|
||||||
|
func TestHSTSTracksHTTPS(t *testing.T) {
|
||||||
|
overHTTP := newTestServer(t) // BaseURL er http://localhost
|
||||||
|
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
|
||||||
|
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||||
|
t.Errorf("HSTS sat over ren HTTP: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
overHTTPS := newTestServerWith(t, func(c *config.Config) {
|
||||||
|
c.BaseURL = "https://todo.example.dk"
|
||||||
|
})
|
||||||
|
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
|
||||||
|
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
|
||||||
|
t.Error("ingen HSTS over HTTPS")
|
||||||
|
} else if !strings.Contains(got, "includeSubDomains") {
|
||||||
|
t.Errorf("HSTS uden includeSubDomains: %q", got)
|
||||||
|
}
|
||||||
|
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
|
||||||
|
// mikrofon og placering, som appen aldrig beder om.
|
||||||
|
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||||
|
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
|
||||||
|
}
|
||||||
|
// COOP isolerer browserkonteksten og står på hvert svar.
|
||||||
|
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
||||||
|
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Et bilag må ikke kunne blive læst som et dokument.
|
// Et bilag må ikke kunne blive læst som et dokument.
|
||||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||||
|
|||||||
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
// takes a context and a user rather than a request.
|
// takes a context and a user rather than a request.
|
||||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||||
//
|
//
|
||||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||||
// asking for less is what makes the consent screen something a person can agree
|
// leaves the query on its own and this list is a safety net rather than the only
|
||||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
// record. It still earns its place: it covers the window between making the task
|
||||||
// record has to live on this side, and a record on this side has to be bounded.
|
// and the star coming off, and it is the whole of the dedup for a label trigger,
|
||||||
|
// where the label is the person's and not ours to remove. A record on this side
|
||||||
|
// has to be bounded.
|
||||||
//
|
//
|
||||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||||
// which was not a bound but a way of forgetting.
|
// which was not a bound but a way of forgetting.
|
||||||
@@ -381,6 +383,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The star comes off every message this query returned that now has a task —
|
||||||
|
// the ones made this run and the ones a run before it already made. Gated to the
|
||||||
|
// triggers that are about the star: for a label trigger the star is the person's
|
||||||
|
// own and not ours to touch, and dedup there rests on the seen list alone.
|
||||||
|
//
|
||||||
|
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
|
||||||
|
// that would not come off. The likeliest reason it will not, right after this
|
||||||
|
// ships, is a connection still holding the old read-only grant — the write is a
|
||||||
|
// 403 until the account is reconnected, and this logs it rather than failing the
|
||||||
|
// sync. It self-heals: the mail stays starred, stays in the next query, and is
|
||||||
|
// tried again once the grant is there.
|
||||||
|
if box.Trigger == "starred" || box.Trigger == "both" {
|
||||||
|
unstar := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
unstar = append(unstar, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Unstar(ctx, unstar); err != nil {
|
||||||
|
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if created > 0 {
|
if created > 0 {
|
||||||
// The ids this run saw, on top of the ones already known.
|
// The ids this run saw, on top of the ones already known.
|
||||||
//
|
//
|
||||||
@@ -442,8 +467,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// The one click that opens Google's consent screen already exists; what it needs
|
// The one click that opens Google's consent screen already exists; what it needs
|
||||||
// behind it is a registered client, and that is not something any app can conjure:
|
// behind it is a registered client, and that is not something any app can conjure:
|
||||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||||
// a restricted scope, so an id shipped inside a public image would be both
|
// restricted scope, so an id shipped inside a public image would be both
|
||||||
// extractable from it and unusable without Google's security review of every
|
// extractable from it and unusable without Google's security review of every
|
||||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||||
// recreate the container to paste the result was not.
|
// recreate the container to paste the result was not.
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Gmail failure has to survive being reported.
|
// A Gmail failure has to survive being reported.
|
||||||
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|||||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A message that becomes a task has its star taken off — the write that the whole
|
||||||
|
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
||||||
|
// the way the IMAP side unflags what it imports.
|
||||||
|
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
||||||
|
var unstarred struct {
|
||||||
|
hit bool
|
||||||
|
body struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
||||||
|
unstarred.hit = true
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &unstarred.body)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
||||||
|
writeTestJSON(w, map[string]any{
|
||||||
|
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
||||||
|
"payload": map[string]any{"headers": []map[string]string{
|
||||||
|
{"name": "From", "value": "Anders <anders@example.dk>"},
|
||||||
|
{"name": "Subject", "value": "Møde på fredag"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||||
|
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GoogleTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A live access token, so the run goes straight to the list without a refresh.
|
||||||
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||||
|
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||||
|
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if created, _ := body["created"].(float64); created != 1 {
|
||||||
|
t.Fatalf("created = %v, want 1", body["created"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !unstarred.hit {
|
||||||
|
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
||||||
|
}
|
||||||
|
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
||||||
|
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
||||||
|
}
|
||||||
|
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
||||||
|
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
func mustParse(t *testing.T, raw string) *url.URL {
|
func mustParse(t *testing.T, raw string) *url.URL {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
|
|||||||
@@ -19,21 +19,38 @@ import (
|
|||||||
|
|
||||||
// Notes.
|
// Notes.
|
||||||
//
|
//
|
||||||
// Access follows the project, because that is where a note lives: a note in a
|
// Access has three ways in, and a note may have any of them at once. A note with
|
||||||
// shared project is readable by everybody who can read the project and writable
|
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||||
// by an editor. A note with no project is its author's alone — the same answer a
|
// note in a project is readable by everyone who can read the project and writable
|
||||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
// by an editor. And a note can be shared with a person directly, which grants that
|
||||||
|
// person a role of its own. The effective role is the most any of the three allow.
|
||||||
|
|
||||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||||
// whether they may change it. Split, because a viewer on a shared project can do
|
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||||
// the first and not the second.
|
// on a shared project or a shared note, may do the first and not the second.
|
||||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
// The owner and the project path, exactly as before.
|
||||||
if n.ProjectID == "" {
|
if n.ProjectID == "" {
|
||||||
return n.CreatedBy == user.ID
|
if n.CreatedBy == user.ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
// A direct share grants its role on top of whatever the above allow.
|
||||||
return err == nil
|
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||||
|
return shareRole.AtLeast(role)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||||
|
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||||
|
// change its text; giving away access is the owner's alone, the same shape a
|
||||||
|
// project takes where members edit but only the owner invites.
|
||||||
|
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||||
|
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "search notes", err)
|
s.internal(w, r, "search notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "notes in project", err)
|
s.internal(w, r, "notes in project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list archived notes", err)
|
s.internal(w, r, "list archived notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||||
}
|
}
|
||||||
|
|
||||||
// briefly replaces each note's body with the first of it.
|
// briefly replaces each note's body with the first of it.
|
||||||
@@ -206,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if body.ProjectID != nil {
|
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||||
// Moving it means being allowed on both sides: you cannot file somebody
|
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||||
// else's note into your project, nor yours into theirs.
|
// change the text; letting them also move the note would let them file
|
||||||
|
// somebody else's note into a project the owner is not in — and the owner,
|
||||||
|
// who is not a member there, would lose their own note. So the move is
|
||||||
|
// owner-only, and then only into a project the owner may write to.
|
||||||
|
if !s.ownsNote(r, n) {
|
||||||
|
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||||
|
return
|
||||||
|
}
|
||||||
if *body.ProjectID != "" {
|
if *body.ProjectID != "" {
|
||||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||||
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "delete note", err)
|
s.internal(w, r, "delete note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||||
|
// — may change its text, but throwing away the owner's note is the owner's to
|
||||||
|
// do. A project editor keeps the reach they have always had over a project's
|
||||||
|
// notes; a direct share does not extend that far.
|
||||||
|
mayDelete := s.ownsNote(r, n)
|
||||||
|
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||||
|
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||||
|
mayDelete = perr == nil
|
||||||
|
}
|
||||||
|
if n == nil || !mayDelete {
|
||||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||||
|
// the second, so two notes made in the same second have no order between them —
|
||||||
|
// and then which of two same-titled notes gets the plain name and which gets
|
||||||
|
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||||
|
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||||
|
sort.SliceStable(notes, func(i, j int) bool {
|
||||||
|
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||||
|
return notes[i].ID < notes[j].ID
|
||||||
|
}
|
||||||
|
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition",
|
w.Header().Set("Content-Disposition",
|
||||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// markShared fills in the two fields the list needs to show a note somebody else
|
||||||
|
// shared with the reader: that it is such a note, and whose it is.
|
||||||
|
//
|
||||||
|
// Done in one pass over the page rather than per note: one query asks which of
|
||||||
|
// these ids are shared with the reader, and one asks for the owners' names and
|
||||||
|
// colours. A note the reader owns is never "shared with me", however it was
|
||||||
|
// reached — the group is other people's notes, not one's own.
|
||||||
|
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
|
||||||
|
me := userFrom(r.Context()).ID
|
||||||
|
if len(notes) == 0 {
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0, len(notes))
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.CreatedBy != me {
|
||||||
|
ids = append(ids, n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
|
||||||
|
if err != nil {
|
||||||
|
// The list is worth more than the chip: a note without its "shared" mark
|
||||||
|
// still reads and still opens. Log nothing here — the store already did.
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := map[string]store.Person{}
|
||||||
|
if len(shared) > 0 {
|
||||||
|
ownerIDs := make([]string, 0, len(shared))
|
||||||
|
for _, n := range notes {
|
||||||
|
if shared[n.ID] && n.CreatedBy != "" {
|
||||||
|
ownerIDs = append(ownerIDs, n.CreatedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range notes {
|
||||||
|
if !shared[notes[i].ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notes[i].SharedWithMe = true
|
||||||
|
if p, ok := owners[notes[i].CreatedBy]; ok {
|
||||||
|
owner := p
|
||||||
|
notes[i].Owner = &owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListNoteShares is who a note is shared with. Owner only: the list of people
|
||||||
|
// who can see your note is itself something only you should see.
|
||||||
|
func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
|
||||||
|
if n == nil || !s.ownsNote(r, n) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list note shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]noteShareJSON, 0, len(shares))
|
||||||
|
already := map[string]bool{}
|
||||||
|
for _, sh := range shares {
|
||||||
|
out = append(out, noteShareJSON{
|
||||||
|
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||||
|
Role: string(sh.Role),
|
||||||
|
})
|
||||||
|
already[sh.User.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picker is filled from the same response, so the panel opens complete in
|
||||||
|
// one round-trip. People already on the note are left out — they are in the list
|
||||||
|
// above it, not the "add someone" menu.
|
||||||
|
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "share candidates", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cand := make([]personJSON, 0, len(candidates))
|
||||||
|
for _, p := range candidates {
|
||||||
|
if already[p.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||||
|
}
|
||||||
|
|
||||||
|
type noteShareJSON struct {
|
||||||
|
User personJSON `json:"user"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type shareNoteRequest struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||||
|
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req shareNoteRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.ownsNote(r, n) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := store.Role(req.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = store.RoleViewer
|
||||||
|
}
|
||||||
|
if role != store.RoleViewer && role != store.RoleEditor {
|
||||||
|
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserID == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The recipient must be a real account and not the sharer themselves; ShareNote
|
||||||
|
// refuses the owner, and a made-up id should not reach it.
|
||||||
|
if req.UserID == userFrom(r.Context()).ID {
|
||||||
|
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
||||||
|
s.storeError(w, r, "share note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnshareNote takes a person's access away again.
|
||||||
|
func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.ownsNote(r, n) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
|
||||||
|
s.internal(w, r, "unshare note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createNote makes a note as the current client and returns its id.
|
||||||
|
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
id, _ := out["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("create note returned no id")
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func userID(t *testing.T, ts *testServer, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up %s: %v", email, err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||||
|
// somebody, and a viewer can read it but not change it.
|
||||||
|
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||||
|
|
||||||
|
// Before sharing, Sofie cannot see it at all.
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share it as a viewer.
|
||||||
|
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("share: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||||
|
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||||
|
notes, _ := list["notes"].([]any)
|
||||||
|
var found map[string]any
|
||||||
|
for _, raw := range notes {
|
||||||
|
n := raw.(map[string]any)
|
||||||
|
if n["id"] == noteID {
|
||||||
|
found = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("the shared note is not in the recipient's list")
|
||||||
|
}
|
||||||
|
if found["shared_with_me"] != true {
|
||||||
|
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||||
|
}
|
||||||
|
ownerObj, _ := found["owner"].(map[string]any)
|
||||||
|
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||||
|
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A viewer reads it.
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// But cannot change it.
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An editor may change the text, but only the owner may delete the note or hand it
|
||||||
|
// to somebody else.
|
||||||
|
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("share as editor failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The editor writes.
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The editor cannot re-share it: giving access is the owner's alone.
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// The editor cannot delete it.
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||||
|
t.Error("an editor deleted the owner's note")
|
||||||
|
}
|
||||||
|
// The note still exists for the owner.
|
||||||
|
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsharing takes access away again, and only the owner can do it.
|
||||||
|
func TestUnshareRemovesAccess(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Hemmelig")
|
||||||
|
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||||
|
|
||||||
|
// A non-owner cannot unshare.
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner can.
|
||||||
|
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// And access is gone.
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The share list is owner-only and offers the instance's other people as
|
||||||
|
// candidates, minus those already on the note.
|
||||||
|
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
owner.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Plan")
|
||||||
|
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||||
|
|
||||||
|
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||||
|
shares, _ := out["shares"].([]any)
|
||||||
|
if len(shares) != 1 {
|
||||||
|
t.Fatalf("shares = %d, want 1", len(shares))
|
||||||
|
}
|
||||||
|
cands, _ := out["candidates"].([]any)
|
||||||
|
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, c := range cands {
|
||||||
|
names[c.(map[string]any)["name"].(string)] = true
|
||||||
|
}
|
||||||
|
if !names["Anders"] {
|
||||||
|
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||||
|
}
|
||||||
|
if names["Sofie"] {
|
||||||
|
t.Error("somebody already on the note is offered as a candidate")
|
||||||
|
}
|
||||||
|
if names["Kristian"] {
|
||||||
|
t.Error("the owner is offered as a candidate to share with themselves")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharing refuses a made-up person and the owner themselves.
|
||||||
|
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
me := userID(t, owner, "kristian@example.dk")
|
||||||
|
noteID := owner.createNote(t, "# Note")
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
||||||
|
t.Error("a note was shared at the owner role")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
target := "/projekt/" + projectID
|
||||||
|
if projectID == "" {
|
||||||
|
target = "/"
|
||||||
|
}
|
||||||
|
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||||
|
subject := s.vapidSubject()
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
err := push.Send(ctx, push.Subscription{
|
err := push.Send(ctx, push.Subscription{
|
||||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||||
}, payload, push.VAPID{
|
}, payload, push.VAPID{
|
||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: subject,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
out.Sent++
|
out.Sent++
|
||||||
|
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||||
|
s.log.Warn("mark push delivered", "err", delErr)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Warn, not Debug.
|
// Warn, not Debug.
|
||||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|||||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||||
|
//
|
||||||
|
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||||
|
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||||
|
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
|
||||||
|
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||||
|
// the person waiting for the notification could tell.
|
||||||
|
//
|
||||||
|
// So a real address is used when there is one, and otherwise the instance's own
|
||||||
|
// https address — which is a valid VAPID subject, points at something that exists,
|
||||||
|
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||||
|
// the https URL a 201.
|
||||||
|
func (s *Server) vapidSubject() string {
|
||||||
|
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||||
|
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||||
|
return "mailto:" + from
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||||
|
return s.cfg.BaseURL
|
||||||
|
}
|
||||||
|
return "mailto:verdande@example.invalid"
|
||||||
|
}
|
||||||
|
|
||||||
// --- subscription endpoints -----------------------------------------------------------
|
// --- subscription endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
type pushSubscriptionRequest struct {
|
type pushSubscriptionRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||||
|
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||||
|
// every push to an Apple device was refused. With no real address, the instance's
|
||||||
|
// own https URL is used instead — valid, real, and accepted.
|
||||||
|
func TestVAPIDSubject(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
baseURL string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"a real address is used as-is", "todo@example.dk", "https://verdande.example.dk", "mailto:todo@example.dk"},
|
||||||
|
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||||
|
if got := s.vapidSubject(); got != tc.want {
|
||||||
|
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
|
||||||
|
// and does nothing at all when the peer is not trusted.
|
||||||
|
//
|
||||||
|
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
|
||||||
|
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
|
||||||
|
// new value on every request and gets a fresh rate-limit bucket each time, and
|
||||||
|
// writes whatever address they like into the audit log. The one control that
|
||||||
|
// exists for when a password is already gone is keyed on this address, so it has
|
||||||
|
// to be an address the caller cannot choose.
|
||||||
|
//
|
||||||
|
// The gate is the peer. A header is read only when the machine that opened the
|
||||||
|
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
|
||||||
|
// put there. A connection straight off the internet is trusted for nothing, so its
|
||||||
|
// header is ignored and it is keyed on the address it actually came from. Behind a
|
||||||
|
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
|
||||||
|
// do) the value is then the real client and cannot be forged from outside.
|
||||||
|
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
|
trusted := cfg.TrustedProxies
|
||||||
|
header := cfg.RealIPHeader
|
||||||
|
if header == "" {
|
||||||
|
header = "X-Forwarded-For"
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ip := trustedClientIP(r, trusted, header); ip != "" {
|
||||||
|
// Port preserved off the original peer only for shape; downstream
|
||||||
|
// clientIP() splits it away, and the forwarded value carries none.
|
||||||
|
r.RemoteAddr = ip
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedClientIP returns the forwarded client address if the peer may be believed,
|
||||||
|
// or "" to leave RemoteAddr untouched.
|
||||||
|
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
|
||||||
|
peer := r.RemoteAddr
|
||||||
|
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||||
|
peer = host
|
||||||
|
}
|
||||||
|
peerAddr, err := netip.ParseAddr(peer)
|
||||||
|
if err != nil || !isTrusted(peerAddr, trusted) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := r.Header.Get(header)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The leftmost entry is the original client. That entry is only as trustworthy
|
||||||
|
// as the proxy that wrote it — but the trusted proxy is exactly the one that
|
||||||
|
// replaces a client-supplied header, so on the deployments this is written for
|
||||||
|
// it is the real client, and on a direct connection we never reach this line.
|
||||||
|
first := raw
|
||||||
|
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||||
|
first = raw[:i]
|
||||||
|
}
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
if _, err := netip.ParseAddr(first); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for _, p := range trusted {
|
||||||
|
if p.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The address the rate limiter keys on must be one the caller cannot choose. A
|
||||||
|
// forwarded header is believed only from a trusted proxy, and ignored from a
|
||||||
|
// stranger connecting directly — the difference between a control and a suggestion.
|
||||||
|
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := ""
|
||||||
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = clientIP(r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
xff string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// A private peer is a proxy the operator put there: believe it.
|
||||||
|
name: "trusted proxy is believed",
|
||||||
|
remoteAddr: "10.1.2.3:5000",
|
||||||
|
xff: "203.0.113.9",
|
||||||
|
want: "203.0.113.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A public peer connected straight to us; its header is a guess.
|
||||||
|
name: "public peer header ignored",
|
||||||
|
remoteAddr: "198.51.100.7:5000",
|
||||||
|
xff: "203.0.113.9",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// No header from a trusted proxy leaves the peer as the answer.
|
||||||
|
name: "trusted peer without header",
|
||||||
|
remoteAddr: "127.0.0.1:5000",
|
||||||
|
xff: "",
|
||||||
|
want: "127.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A spoofed value from a stranger cannot become the key.
|
||||||
|
name: "spoof from outside does not win",
|
||||||
|
remoteAddr: "198.51.100.7:5000",
|
||||||
|
xff: "127.0.0.1",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||||
|
r.RemoteAddr = tc.remoteAddr
|
||||||
|
if tc.xff != "" {
|
||||||
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if seen != tc.want {
|
||||||
|
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "none" turns the trust off entirely, for an instance exposed directly.
|
||||||
|
func TestRealIPTrustNone(t *testing.T) {
|
||||||
|
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := ""
|
||||||
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = clientIP(r)
|
||||||
|
}))
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
||||||
|
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if seen != "10.1.2.3" {
|
||||||
|
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
// Not middleware.RealIP: that believes a forwarded header from anyone, which
|
||||||
|
// lets a caller pick the address the rate limiter and audit log key on. This
|
||||||
|
// one reads the header only from a trusted proxy — see realip.go.
|
||||||
|
r.Use(realIP(cfg))
|
||||||
r.Use(requestLogger(log))
|
r.Use(requestLogger(log))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
@@ -345,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/", s.handleCreateNote)
|
r.Post("/", s.handleCreateNote)
|
||||||
r.Post("/import", s.handleImportNotes)
|
r.Post("/import", s.handleImportNotes)
|
||||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||||
|
// Sharing a note with a person directly — its own access list, beside
|
||||||
|
// the project path. Owner only; see notes_share_handlers.go.
|
||||||
|
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||||
|
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||||
|
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||||
r.Get("/{noteID}", s.handleGetNote)
|
r.Get("/{noteID}", s.handleGetNote)
|
||||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||||
@@ -543,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/complete", s.handleCompleteTask)
|
r.Post("/complete", s.handleCompleteTask)
|
||||||
r.Post("/reopen", s.handleReopenTask)
|
r.Post("/reopen", s.handleReopenTask)
|
||||||
r.Post("/move", s.handleMoveTask)
|
r.Post("/move", s.handleMoveTask)
|
||||||
|
r.Post("/snooze", s.handleSnoozeTask)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -665,6 +674,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Referrer-Policy", "same-origin")
|
h.Set("Referrer-Policy", "same-origin")
|
||||||
h.Set("Content-Security-Policy", s.csp)
|
h.Set("Content-Security-Policy", s.csp)
|
||||||
|
// The session lives in a __Host- cookie that only exists over HTTPS, so the
|
||||||
|
// whole security model already assumes TLS. HSTS is what makes that
|
||||||
|
// assumption hold against a network attacker: without it, the very first
|
||||||
|
// navigation can be stripped to http:// and the cookie handed over in the
|
||||||
|
// clear. Gated on the same condition as the cookie's Secure flag, so a
|
||||||
|
// plain-HTTP dev instance is never told to demand a certificate it cannot
|
||||||
|
// present. Two years, subdomains included; no preload directive, because
|
||||||
|
// asking to be hard-coded into browsers is a decision the operator makes
|
||||||
|
// for their domain, not one a self-hosted app makes for them.
|
||||||
|
if s.secureCookies() {
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
|
}
|
||||||
|
// verdande uses none of the powerful browser features, so the safest policy
|
||||||
|
// is to turn them all off: an injected element cannot ask for the camera,
|
||||||
|
// the microphone or the location if the page itself has already declined
|
||||||
|
// them. Named explicitly rather than with a wildcard because the wildcard
|
||||||
|
// form of this header is not universally understood, and a directive a
|
||||||
|
// browser ignores is not a control.
|
||||||
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
|
||||||
|
"payment=(), usb=(), interest-cohort=()")
|
||||||
|
// Isolate the browsing context: a window this page opened, or one that
|
||||||
|
// opened it, cannot keep a handle across the origin boundary. verdande
|
||||||
|
// never scripts another window, so there is nothing to lose and a whole
|
||||||
|
// class of cross-window attacks to close.
|
||||||
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,23 +16,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type taskJSON struct {
|
type taskJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
SectionID string `json:"section_id,omitempty"`
|
SectionID string `json:"section_id,omitempty"`
|
||||||
ParentID string `json:"parent_id,omitempty"`
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
DueDate string `json:"due_date,omitempty"`
|
DueDate string `json:"due_date,omitempty"`
|
||||||
DueDatetime string `json:"due_datetime,omitempty"`
|
DueDatetime string `json:"due_datetime,omitempty"`
|
||||||
DurationMin *int `json:"duration_min,omitempty"`
|
DurationMin *int `json:"duration_min,omitempty"`
|
||||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||||
RepeatText string `json:"recurrence_text,omitempty"`
|
RepeatText string `json:"recurrence_text,omitempty"`
|
||||||
AssigneeID string `json:"assignee_id,omitempty"`
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
// What the row can say without being opened. Sent with every task rather than
|
// What the row can say without being opened. Sent with every task rather than
|
||||||
// fetched when one is opened, because the point is that you see it in the list.
|
// fetched when one is opened, because the point is that you see it in the list.
|
||||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
|||||||
if t.CompletedAt != nil {
|
if t.CompletedAt != nil {
|
||||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
if t.SnoozedUntil != nil {
|
||||||
|
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||||
|
}
|
||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type snoozeRequest struct {
|
||||||
|
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||||
|
// but when the task wants to be seen — its due date and everything else stay put.
|
||||||
|
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req snoozeRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var until *time.Time
|
||||||
|
if strings.TrimSpace(req.Until) != "" {
|
||||||
|
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||||
|
if perr != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until = &parsed
|
||||||
|
}
|
||||||
|
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||||
|
s.storeError(w, r, "snooze task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verb := "task.snoozed"
|
||||||
|
if until == nil {
|
||||||
|
verb = "task.unsnoozed"
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||||
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,49 @@ func TestTodayAndUpcoming(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The horizon is the list's, and it is asked for per request: seven days is the
|
||||||
|
// default, thirty is what Kommende offers for "when does this month get busy", and
|
||||||
|
// anything past the cap is trimmed rather than refused — a client that asks for a
|
||||||
|
// year gets a month, not a 400.
|
||||||
|
func TestUpcomingHorizon(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "om tyve dage",
|
||||||
|
"due_date": userDate(t, 20),
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %v", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := func(query string) (int, int) {
|
||||||
|
t.Helper()
|
||||||
|
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
|
||||||
|
days, _ := view["days"].([]any)
|
||||||
|
var tasks int
|
||||||
|
for _, d := range days {
|
||||||
|
day, _ := d.(map[string]any)
|
||||||
|
list, _ := day["tasks"].([]any)
|
||||||
|
tasks += len(list)
|
||||||
|
}
|
||||||
|
return len(days), tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, found := count(""); n != 7 || found != 0 {
|
||||||
|
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
|
||||||
|
}
|
||||||
|
if n, found := count("?days=30"); n != 30 || found != 1 {
|
||||||
|
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
|
||||||
|
}
|
||||||
|
if n, _ := count("?days=365"); n != 31 {
|
||||||
|
t.Errorf("days=365 returned %d days, want it capped at 31", n)
|
||||||
|
}
|
||||||
|
if n, _ := count("?days=nonsense"); n != 7 {
|
||||||
|
t.Errorf("an unreadable days returned %d days, want the default 7", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchFindsAcrossProjects(t *testing.T) {
|
func TestSearchFindsAcrossProjects(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -1817,3 +1860,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||||
|
// it puts it back. The list order is what the row's dimming rides on.
|
||||||
|
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||||
|
aID, _ := a["id"].(string)
|
||||||
|
|
||||||
|
// Snooze Alfa two days out.
|
||||||
|
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||||
|
t.Error("the response does not carry snoozed_until")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alfa now sorts after Bravo, though it was created first.
|
||||||
|
order := func() []string {
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
var out []string
|
||||||
|
for _, raw := range list["tasks"].([]any) {
|
||||||
|
out = append(out, raw.(map[string]any)["content"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
got := order()
|
||||||
|
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||||
|
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake it, and it comes back above Bravo.
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||||
|
t.Errorf("waking left snoozed_until set: %q", s)
|
||||||
|
}
|
||||||
|
if got := order(); got[0] != "Alfa" {
|
||||||
|
t.Errorf("woken task did not return: order = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
type totpSetupResponse struct {
|
type totpSetupResponse struct {
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
|
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
||||||
|
// hand on a device that cannot scan.
|
||||||
|
QR string `json:"qr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "store totp secret", err)
|
s.internal(w, r, "store totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
qrSVG, err := auth.TOTPQRSVG(uri)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "render totp qr", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpConfirmResponse struct {
|
type totpConfirmResponse struct {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ type upcomingDay struct {
|
|||||||
Tasks []taskJSON `json:"tasks"`
|
Tasks []taskJSON `json:"tasks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUpcoming returns the next seven days, one entry per day.
|
// handleUpcoming returns the next `days` days, one entry per day, seven by default
|
||||||
|
// and at most a month.
|
||||||
//
|
//
|
||||||
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
|
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
|
||||||
// missing from the response because nothing is due would collapse the layout and
|
// missing from the response because nothing is due would collapse the layout and
|
||||||
@@ -65,10 +66,14 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
from := start.Format("2006-01-02")
|
from := start.Format("2006-01-02")
|
||||||
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
|
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
|
||||||
|
|
||||||
|
// The row limit follows the window rather than sitting at a constant, or a
|
||||||
|
// month asked for with the same ceiling as a week would truncate the last days
|
||||||
|
// silently — and a day cut off the end of the answer looks exactly like a day
|
||||||
|
// with nothing due.
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
DueFrom: from,
|
DueFrom: from,
|
||||||
DueBefore: to,
|
DueBefore: to,
|
||||||
Limit: 1000,
|
Limit: 200 * days,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "upcoming", err)
|
s.internal(w, r, "upcoming", err)
|
||||||
|
|||||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
|||||||
|
|
||||||
for _, t := range cal.Tasks {
|
for _, t := range cal.Tasks {
|
||||||
writeTodo(&b, t, cal.Domain)
|
writeTodo(&b, t, cal.Domain)
|
||||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||||
if t.DueDatetime != nil && !t.Completed {
|
// VTODO — which is all of them: Apple Calendar and Google both drop a
|
||||||
|
// subscribed to-do on the floor and show only events. A task with a clock
|
||||||
|
// time becomes a timed event; one with only a date, an all-day event. Once
|
||||||
|
// this was limited to timed tasks, so a date-only task — most of them —
|
||||||
|
// simply never appeared in the calendar it was subscribed into.
|
||||||
|
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
|
||||||
writeEvent(&b, t, cal.Domain)
|
writeEvent(&b, t, cal.Domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeEvent(b *strings.Builder, t Task, domain string) {
|
func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||||
start := *t.DueDatetime
|
|
||||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
|
||||||
// event collapses to an invisible sliver in most calendar grids.
|
|
||||||
minutes := 30
|
|
||||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
|
||||||
minutes = *t.DurationMin
|
|
||||||
}
|
|
||||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
|
||||||
|
|
||||||
b.WriteString("BEGIN:VEVENT\r\n")
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
// A distinct UID suffix: the event and the to-do describe the same task, and
|
||||||
// sharing a UID would make a client treat them as two versions of one item.
|
// sharing a UID would make a client treat them as two versions of one item.
|
||||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
||||||
writeLine(b, "DTSTART:"+utc(start))
|
|
||||||
writeLine(b, "DTEND:"+utc(end))
|
if t.DueDatetime != nil {
|
||||||
|
start := *t.DueDatetime
|
||||||
|
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||||
|
// event collapses to an invisible sliver in most calendar grids.
|
||||||
|
minutes := 30
|
||||||
|
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||||
|
minutes = *t.DurationMin
|
||||||
|
}
|
||||||
|
writeLine(b, "DTSTART:"+utc(start))
|
||||||
|
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
|
||||||
|
} else {
|
||||||
|
// A date with no time is an all-day event. DTEND is exclusive — a one-day
|
||||||
|
// event ends on the following date — which is what keeps it a single day in
|
||||||
|
// the grid rather than bleeding into the next.
|
||||||
|
day := strings.ReplaceAll(t.DueDate, "-", "")
|
||||||
|
writeLine(b, "DTSTART;VALUE=DATE:"+day)
|
||||||
|
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
|
||||||
|
}
|
||||||
|
|
||||||
writeLine(b, "SUMMARY:"+escape(t.Content))
|
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||||
if t.Description != "" {
|
if t.Description != "" {
|
||||||
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
||||||
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
|
|||||||
|
|
||||||
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||||
|
|
||||||
|
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
|
||||||
|
// DTEND. A malformed date is returned as its own compacted form rather than
|
||||||
|
// dropped, so a task never loses its event over a bad string.
|
||||||
|
func nextDay(date string) string {
|
||||||
|
d, err := time.Parse("2006-01-02", date)
|
||||||
|
if err != nil {
|
||||||
|
return strings.ReplaceAll(date, "-", "")
|
||||||
|
}
|
||||||
|
return d.AddDate(0, 0, 1).Format("20060102")
|
||||||
|
}
|
||||||
|
|
||||||
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
||||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||||
// summary at that character in every client.
|
// summary at that character in every client.
|
||||||
|
|||||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
|||||||
|
|
||||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||||
// or a feed is useless in the calendar most people actually keep.
|
// or a feed is useless in the calendar most people actually keep.
|
||||||
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
func TestDatedTasksBecomeEvents(t *testing.T) {
|
||||||
out := Render(sample())
|
out := Render(sample())
|
||||||
|
|
||||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||||
strings.Count(out, "BEGIN:VEVENT"))
|
// date and no event.
|
||||||
|
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
|
||||||
|
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
|
||||||
t.Error("the event has no start")
|
// The timed one keeps its clock and its 45-minute duration.
|
||||||
|
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||||
|
t.Error("the timed event's start or duration is wrong")
|
||||||
}
|
}
|
||||||
// 45 minutes of stated duration.
|
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
// they sit on their own day rather than bleeding into the next.
|
||||||
t.Error("the event's end does not reflect the task's duration")
|
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
|
||||||
|
t.Error("the date-only event is not a single all-day event")
|
||||||
|
}
|
||||||
|
// A recurring date-only task carries its rule onto the event.
|
||||||
|
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||||
|
t.Error("the recurring task's event has no RRULE")
|
||||||
}
|
}
|
||||||
// The event must not share the to-do's UID, or clients treat them as one item.
|
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||||
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
|
||||||
|
-- verifier must reject a code that has already been accepted, or a code observed in
|
||||||
|
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
|
||||||
|
-- for the rest of its thirty-second window.
|
||||||
|
--
|
||||||
|
-- totp_last_step records the counter of the most recently accepted code (Unix time
|
||||||
|
-- divided by the period). A login accepts a code only when its step is strictly
|
||||||
|
-- greater, so the same code cannot be spent twice and time cannot run backwards.
|
||||||
|
-- Null means no code has been accepted yet, which is every account until its owner
|
||||||
|
-- next signs in with one.
|
||||||
|
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Sharing a note with a person, rather than only by filing it in a shared project.
|
||||||
|
--
|
||||||
|
-- Until now a note was shared exactly one way: put it in a project, and everyone
|
||||||
|
-- who could read the project could read the note. That is the right tool when the
|
||||||
|
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
|
||||||
|
-- see this" — it makes you stand up a project to hand over a single page.
|
||||||
|
--
|
||||||
|
-- A note_shares row is that direct grant: this note, this person, this role. It
|
||||||
|
-- sits alongside the project path, not instead of it — a note can be loose, filed,
|
||||||
|
-- shared with people, or any combination, and access is the most that any of those
|
||||||
|
-- allow. Only the note's owner (its created_by) writes these rows.
|
||||||
|
--
|
||||||
|
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
|
||||||
|
-- is one idea of what a role means in this program and not two.
|
||||||
|
CREATE TABLE note_shares (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- One share per person per note: sharing again changes the role, it does not
|
||||||
|
-- add a second row that the first would then quietly disagree with.
|
||||||
|
PRIMARY KEY (note_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Which notes are shared with me" is asked on every visit to the notes page, to
|
||||||
|
-- build the Delt med mig group and to widen what the list may show. It is a lookup
|
||||||
|
-- by user_id, so that is what carries the index.
|
||||||
|
CREATE INDEX note_shares_by_user ON note_shares (user_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
|
||||||
|
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
|
||||||
|
-- then comes back on its own — no job wakes it, because "snoozed" is simply
|
||||||
|
-- snoozed_until being still in the future, read against the clock each time.
|
||||||
|
--
|
||||||
|
-- Null means not snoozed, which is every task until somebody snoozes one.
|
||||||
|
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"andreas": "Andreas",
|
||||||
|
"Andreas": "Andreas",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
" kristian ": "Kristian",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
"McDonald": "McDonald", // an existing inner capital is kept
|
||||||
|
"óscar": "Óscar", // Unicode initial
|
||||||
|
"æblegaard": "Æblegaard",
|
||||||
|
"": "",
|
||||||
|
" ": "",
|
||||||
|
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
|
||||||
|
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeName(in); got != want {
|
||||||
|
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserCapitalisesName(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
got, err := db.UserByEmail(ctx, "andreas@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUserNamesBackfill(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An account as it looked before the app capitalised names: made properly,
|
||||||
|
// then set back to lower case behind the store's back to stand in for an old row.
|
||||||
|
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
|
||||||
|
t.Fatalf("downgrade name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if fixed != 1 {
|
||||||
|
t.Fatalf("fixed = %d, want 1", fixed)
|
||||||
|
}
|
||||||
|
got, _ := db.UserByID(ctx, u.ID)
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: nothing left to do on a second pass.
|
||||||
|
again, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize again: %v", err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("second pass fixed = %d, want 0", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||||
|
// may do. The person is carried whole rather than by id, because every caller of
|
||||||
|
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||||
|
type NoteShare struct {
|
||||||
|
User Person `json:"user"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||||
|
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||||
|
// has already checked that they may.
|
||||||
|
//
|
||||||
|
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||||
|
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||||
|
// confuse the access check.
|
||||||
|
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||||
|
if !role.Valid() || role == RoleOwner {
|
||||||
|
return errors.New("store: a note can be shared as viewer or editor")
|
||||||
|
}
|
||||||
|
var createdBy sql.NullString
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdBy.String == userID {
|
||||||
|
return errors.New("store: a note is already the owner's")
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
noteID, userID, role, byUserID, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||||
|
// error: the end state the caller asked for — this person cannot see this note —
|
||||||
|
// is the same either way.
|
||||||
|
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||||
|
// name so the list under a note does not reshuffle every time it is opened.
|
||||||
|
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT u.id, u.name, u.avatar_color, s.role
|
||||||
|
FROM note_shares s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.note_id = ?
|
||||||
|
ORDER BY lower(u.name)`, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []NoteShare
|
||||||
|
for rows.Next() {
|
||||||
|
var s NoteShare
|
||||||
|
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||||
|
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||||
|
// role could not be read — so a caller does not have to treat "no share" as an
|
||||||
|
// error.
|
||||||
|
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||||
|
var role Role
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !role.Valid() {
|
||||||
|
// A role the database holds but this build does not understand is no access,
|
||||||
|
// never a guess — the same rule project roles follow.
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return role, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||||
|
// the user. It answers the Delt med mig question in one query rather than one per
|
||||||
|
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||||
|
// world to mark a single page.
|
||||||
|
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||||
|
out := map[string]bool{}
|
||||||
|
if len(noteIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
args := make([]any, 0, len(noteIDs)+1)
|
||||||
|
args = append(args, userID)
|
||||||
|
for _, id := range noteIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -37,6 +37,17 @@ type Note struct {
|
|||||||
// set by a caller: it is derived, and a caller that set it would be describing
|
// set by a caller: it is derived, and a caller that set it would be describing
|
||||||
// a note whose text says something else.
|
// a note whose text says something else.
|
||||||
Links []NoteLink `json:"links,omitempty"`
|
Links []NoteLink `json:"links,omitempty"`
|
||||||
|
|
||||||
|
// SharedWithMe is true when the person reading the list reached this note
|
||||||
|
// through a direct share rather than by owning it or being in its project. It
|
||||||
|
// is what puts the note in the Delt med mig group and shows whose note it is.
|
||||||
|
// Derived per request, so it is never stored and never set by a caller.
|
||||||
|
SharedWithMe bool `json:"shared_with_me,omitempty"`
|
||||||
|
|
||||||
|
// Owner is who the note belongs to — its created_by — as a name and a colour,
|
||||||
|
// for the chip on a note shared with the reader. Set only when it helps the
|
||||||
|
// list say something; nil the rest of the time.
|
||||||
|
Owner *Person `json:"owner,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoteLink is one reference from a note to something else.
|
// NoteLink is one reference from a note to something else.
|
||||||
@@ -263,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
|
|||||||
UNION
|
UNION
|
||||||
SELECT project_id FROM project_members WHERE user_id = ?
|
SELECT project_id FROM project_members WHERE user_id = ?
|
||||||
)
|
)
|
||||||
|
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||||
)
|
)
|
||||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotesLinking answers the backwards question: what points at this thing.
|
// NotesLinking answers the backwards question: what points at this thing.
|
||||||
|
|||||||
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
|||||||
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
||||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||||
// silently unsubscribing somebody's phone.
|
// silently unsubscribing somebody's phone.
|
||||||
|
// MarkPushDelivered records that a push went through: the failure count goes back
|
||||||
|
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
||||||
|
// few times and then recovered would keep those failures on its record and be one
|
||||||
|
// flaky night away from the ten that delete it — even though it is working.
|
||||||
|
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
||||||
|
time.Now().Unix(), endpoint)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||||
if permanent {
|
if permanent {
|
||||||
// Not scoped by user, and that is right here: this is the push service
|
// Not scoped by user, and that is right here: this is the push service
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type Task struct {
|
|||||||
AssigneeID string
|
AssigneeID string
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
CompletedBy string
|
CompletedBy string
|
||||||
|
// SnoozedUntil, when set and still in the future, parks the task at the bottom
|
||||||
|
// of its list, greyed, until it passes. Nil means the task is not snoozed.
|
||||||
|
SnoozedUntil *time.Time
|
||||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
@@ -219,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
@@ -313,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
|
||||||
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
|
|||||||
if f.CompletedOnly {
|
if f.CompletedOnly {
|
||||||
return "t.completed_at DESC, t.id DESC"
|
return "t.completed_at DESC, t.id DESC"
|
||||||
}
|
}
|
||||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
// A snoozed task sinks below everything else — the CASE is 1 while it is still
|
||||||
|
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
|
||||||
|
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
|
||||||
|
// strftime returns would sort under every number.
|
||||||
|
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
|
||||||
|
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
|
||||||
|
// snooze changes; the due date, the time, everything else is left alone — a snooze
|
||||||
|
// is about when you want to see it, not when it is due.
|
||||||
|
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
|
||||||
|
var v any
|
||||||
|
if until != nil {
|
||||||
|
v = until.Unix()
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
v, time.Now().Unix(), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanTask(rows *sql.Rows) (Task, error) {
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
// stays; it loses its author.
|
// stays; it loses its author.
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var dueDatetime, completedAt sql.NullInt64
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
var duration sql.NullInt64
|
var duration, snoozedUntil sql.NullInt64
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
&recurrence, &assignee, &completedAt, &completedBy,
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
&createdBy, &t.SortOrder, &created, &updated,
|
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t, err
|
return t, err
|
||||||
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
v := int(duration.Int64)
|
v := int(duration.Int64)
|
||||||
t.DurationMin = &v
|
t.DurationMin = &v
|
||||||
}
|
}
|
||||||
|
if snoozedUntil.Valid {
|
||||||
|
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||||
|
t.SnoozedUntil = &v
|
||||||
|
}
|
||||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
)
|
)
|
||||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(email))
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeName trims a display name and capitalises the first letter of every
|
||||||
|
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
|
||||||
|
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
|
||||||
|
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
|
||||||
|
// first letter of a word is ever raised, never lowered.
|
||||||
|
//
|
||||||
|
// A name is the person's to spell; the one thing the instance insists on is that
|
||||||
|
// it does not start in lower case, because a column of lower-case first names
|
||||||
|
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
|
||||||
|
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
|
||||||
|
func NormalizeName(name string) string {
|
||||||
|
fields := strings.Fields(name)
|
||||||
|
for i, f := range fields {
|
||||||
|
r := []rune(f)
|
||||||
|
r[0] = unicode.ToUpper(r[0])
|
||||||
|
fields[i] = string(r)
|
||||||
|
}
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
||||||
//
|
//
|
||||||
// The Inbox is created here rather than lazily on first use because every other
|
// The Inbox is created here rather than lazily on first use because every other
|
||||||
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
|
|||||||
// of them end up racing to create the second one.
|
// of them end up racing to create the second one.
|
||||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||||
u.Email = NormalizeEmail(u.Email)
|
u.Email = NormalizeEmail(u.Email)
|
||||||
|
u.Name = NormalizeName(u.Name)
|
||||||
if u.ID == "" {
|
if u.ID == "" {
|
||||||
u.ID = NewID()
|
u.ID = NewID()
|
||||||
}
|
}
|
||||||
@@ -205,9 +227,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
|
|||||||
// Person is the public half of a user: what somebody else is allowed to see when
|
// Person is the public half of a user: what somebody else is allowed to see when
|
||||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||||
type Person struct {
|
type Person struct {
|
||||||
ID string
|
ID string `json:"id"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
AvatarColor string
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
|
||||||
|
// that somebody a note is about to be shared with actually exists, without loading
|
||||||
|
// the whole account.
|
||||||
|
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
|
||||||
|
var p Person
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Person{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersForSharing is everyone on the instance except the caller — the people a note
|
||||||
|
// can be handed to.
|
||||||
|
//
|
||||||
|
// Deliberately wider than ListPeople, which is the project collaborators. The whole
|
||||||
|
// point of sharing a note directly is to do it without first standing up a shared
|
||||||
|
// project, so the picker cannot be limited to people you already share a project
|
||||||
|
// with. An instance's accounts are a closed, invited set to begin with — there is
|
||||||
|
// no open signup — so listing them to each other is not disclosure, it is the
|
||||||
|
// address book the feature needs.
|
||||||
|
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
people := []Person{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Person
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
people = append(people, p)
|
||||||
|
}
|
||||||
|
return people, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeopleByIDs looks up several users at once.
|
// PeopleByIDs looks up several users at once.
|
||||||
@@ -432,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
|||||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||||
// field. Neither is is_admin — an account cannot promote itself.
|
// field. Neither is is_admin — an account cannot promote itself.
|
||||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||||
|
name = NormalizeName(name)
|
||||||
res, err := db.ExecContext(ctx,
|
res, err := db.ExecContext(ctx,
|
||||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||||
name, timezone, locale, time.Now().Unix(), userID)
|
name, timezone, locale, time.Now().Unix(), userID)
|
||||||
@@ -444,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeUserNames capitalises the first letter of every existing user's name,
|
||||||
|
// for the accounts created before the app started doing it on the way in.
|
||||||
|
//
|
||||||
|
// Idempotent: a name already normalised is left untouched, so it costs one pass
|
||||||
|
// of names at each start and writes nothing once it has caught up. Like SealNotes
|
||||||
|
// it runs at boot rather than as a migration, because the rule lives in Go —
|
||||||
|
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
|
||||||
|
//
|
||||||
|
// `updated_at` is not touched: fixing the spelling of a name the person chose is
|
||||||
|
// the instance tidying up after itself, not the person editing their profile.
|
||||||
|
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type pending struct{ id, name string }
|
||||||
|
var todo []pending
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name string
|
||||||
|
if err := rows.Scan(&id, &name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n := NormalizeName(name); n != name {
|
||||||
|
todo = append(todo, pending{id, n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, p := range todo {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(todo), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||||
@@ -466,6 +580,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
|
||||||
|
// fresh. It answers false when the step has already been spent — the same code, or
|
||||||
|
// an earlier one, being presented a second time.
|
||||||
|
//
|
||||||
|
// The guard is in the statement, not in a read-then-write: two logins racing with
|
||||||
|
// the same stolen code must not both find the old value and both proceed. The row
|
||||||
|
// moves forward only when the new step is strictly greater, so exactly one of them
|
||||||
|
// updates a row and the other sees nothing change.
|
||||||
|
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET totp_last_step = ? WHERE id = ?
|
||||||
|
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
|
||||||
|
step, userID, step)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InboxID returns the user's Inbox project.
|
// InboxID returns the user's Inbox project.
|
||||||
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
||||||
var id string
|
var id string
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ extra_css:
|
|||||||
nav:
|
nav:
|
||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Setting it up:
|
- Setting it up:
|
||||||
- Self-hosting: self-hosting.md
|
|
||||||
- As a Rune: rune.md
|
- As a Rune: rune.md
|
||||||
|
- With Docker: self-hosting.md
|
||||||
- Configuration: configuration.md
|
- Configuration: configuration.md
|
||||||
- Using it:
|
- Using it:
|
||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
|
|||||||
@@ -856,8 +856,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
||||||
Self-host it
|
As a Rune
|
||||||
|
</a>
|
||||||
|
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||||
|
With Docker
|
||||||
</a>
|
</a>
|
||||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
@@ -928,13 +931,33 @@
|
|||||||
<div class="shots">
|
<div class="shots">
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/board.webp"
|
src="screenshots/calendar-light.webp"
|
||||||
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
|
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Board view — one column per section.</figcaption>
|
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-shared-light.webp"
|
||||||
|
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/board.webp"
|
||||||
|
alt="A project in board view: one column per section, with task cards showing due dates, labels, an arrow to whoever a task is delegated to, and a snoozed card greyed at the foot of its column."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Board view — one column per section. A delegated card carries an arrow to whoever has it; a snoozed one greys at the foot.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -969,12 +992,22 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/themes.webp"
|
src="screenshots/themes.webp"
|
||||||
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
alt="The settings page showing six themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
<figcaption>Six themes and four looks: how bright, and how it reads.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-charcoal.webp"
|
||||||
|
alt="Notes in the Charcoal theme: a warm near-black modelled on Apple Notes, the note list on the left and a note open on the right, with amber where the accent green usually is."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Charcoal — the newest theme, a warm near-black after Apple Notes, amber for the accent.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -989,12 +1022,12 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/delegated.webp"
|
src="screenshots/delegated.webp"
|
||||||
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them, each row carrying an arrow to that person."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
<figcaption>Waiting on others — grouped by who has it, each marked with an arrow to them.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -1017,6 +1050,75 @@
|
|||||||
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sub">Six themes, the same day</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Three dark and three light — a property of the screen you are sitting at, a
|
||||||
|
laptop in the sun and a monitor in a dark room wanting different answers. The
|
||||||
|
typeface and how tight it sits are chosen apart from them.
|
||||||
|
</p>
|
||||||
|
<div class="shots">
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-northern-lights.webp"
|
||||||
|
alt="The Today view in the Northern lights theme: dark, with a green accent."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Northern lights — dark, a green accent. The default.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-charcoal.webp"
|
||||||
|
alt="The Today view in the Charcoal theme: a warm near-black modelled on Apple Notes, with amber for the accent."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Charcoal — a warm near-black after Apple Notes, amber accent.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-dusk.webp"
|
||||||
|
alt="The Today view in the Dusk theme: dark and warm, with an amber accent."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Dusk — dark and warm.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-daylight.webp"
|
||||||
|
alt="The Today view in the Daylight theme: light and coolly neutral."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Daylight — light and coolly neutral.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-paper.webp"
|
||||||
|
alt="The Today view in the Paper theme: light and warm, like paper."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Paper — light and warm, like paper.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/theme-contrast.webp"
|
||||||
|
alt="The Today view in the Contrast theme: black on white, for bright light."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Contrast — black on white, for bright light.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="features">
|
<section id="features">
|
||||||
@@ -1050,8 +1152,8 @@
|
|||||||
<h3>Repeats, and says so in words</h3>
|
<h3>Repeats, and says so in words</h3>
|
||||||
<p>
|
<p>
|
||||||
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||||
screen. Reminders are absolute or relative, and go out to the devices
|
screen. Reminders are absolute or relative, and go out as Web Push to
|
||||||
you have allowed them on.
|
an iPhone or a Mac you have allowed them on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -1063,12 +1165,47 @@
|
|||||||
written about it. Archive what is done with it; the bin is for mistakes.
|
written about it. Archive what is done with it; the bin is for mistakes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Share a note, or a page</h3>
|
||||||
|
<p>
|
||||||
|
Hand a single note to a person — to read or to edit — without standing up
|
||||||
|
a project for it. Notes other people share with you gather under
|
||||||
|
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||||
|
instead and everyone who can read the project reads the note.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>How long it takes</h3>
|
||||||
|
<p>
|
||||||
|
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||||
|
foot of a task to make it longer or shorter, or drag its body to another
|
||||||
|
day or time — the length comes along.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Waiting on others</h3>
|
<h3>Waiting on others</h3>
|
||||||
<p>
|
<p>
|
||||||
What you have handed to somebody else, on its own page, grouped under
|
What you have handed to somebody else, on its own page, grouped under
|
||||||
the person who has it. The thing a shared list stops being able to tell
|
the person who has it — and marked where you meet it too, with an arrow
|
||||||
you the moment it is shared.
|
out of your hands, so a list reads at a glance as which tasks are yours
|
||||||
|
and which you are waiting on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Snooze it out of the way</h3>
|
||||||
|
<p>
|
||||||
|
Park a task to this evening, tomorrow, next week, or a moment you pick.
|
||||||
|
It greys and sinks to the bottom of the list until then, and comes back
|
||||||
|
by itself — without touching when it is actually due.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Holds through a drop</h3>
|
||||||
|
<p>
|
||||||
|
A tunnel, a lift, a base station handing over — the app does not go
|
||||||
|
blank when the signal does. A save you make rides out a few seconds of
|
||||||
|
no connection and goes through when it comes back, and a reload opens
|
||||||
|
from what it last saw rather than the browser's offline page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1105,9 +1242,10 @@
|
|||||||
<h3>Your mail, as an inbox</h3>
|
<h3>Your mail, as an inbox</h3>
|
||||||
<p>
|
<p>
|
||||||
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||||
it becomes a task. Forward one to your own address instead and the
|
it becomes a task — and the star or flag comes off once it has, so a
|
||||||
subject is parsed like anything else you type. One way: unstarring it
|
starred inbox empties as the tasks are made instead of only filling up.
|
||||||
later does nothing to the task you made.
|
Forward one to your own address instead and the subject is parsed like
|
||||||
|
anything else you type.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -1188,6 +1326,42 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||||
|
Notes, with amber for the accent. And the app now holds through a short
|
||||||
|
network drop: a save rides out a few seconds of no signal, and a reload
|
||||||
|
opens from what it last saw rather than the browser's offline page.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
A starred Gmail message loses its star once it has become a task, the way
|
||||||
|
a flagged IMAP mail already did — so a starred inbox empties itself as the
|
||||||
|
tasks are made instead of only filling up.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
Notes read as three lines, the last one the project they are filed under;
|
||||||
|
the calendar lightened to pastel days with today marked at the top; and
|
||||||
|
two-factor set-up shows a QR code to scan.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
A task you have delegated is marked where you meet it, not only on the
|
||||||
|
"Waiting on others" page. A task can be snoozed to the bottom of the list
|
||||||
|
until a moment you choose, and comes back by itself.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
Web Push reminders reach an iPhone and a Mac again, and every dated task
|
||||||
|
now shows up in a calendar you have subscribed to — not only the timed ones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
The calendar draws each task to its length: drag the foot to resize, or the
|
||||||
|
body to move it. A single note can be shared with one person, and a
|
||||||
|
<code>[[note]]</code> is a link you follow inline.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="ver">0.34</span>
|
<span class="ver">0.34</span>
|
||||||
Sections can be dragged into the order the work actually happens in. The
|
Sections can be dragged into the order the work actually happens in. The
|
||||||
@@ -1199,21 +1373,6 @@
|
|||||||
or drag in, a source view, and a copy button on code blocks. Search
|
or drag in, a source view, and a copy button on code blocks. Search
|
||||||
stopped answering by date and started answering by relevance.
|
stopped answering by date and started answering by relevance.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<span class="ver">0.32</span>
|
|
||||||
A calendar of its own as a menu item, with a read-only Google calendar
|
|
||||||
that can be laid over it.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.31</span>
|
|
||||||
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
|
||||||
task — not only Gmail.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.30</span>
|
|
||||||
An installation count that says exactly what it sends, and can be turned
|
|
||||||
off in one click.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
@@ -64,6 +64,60 @@ function watchForTrouble(page) {
|
|||||||
return trouble;
|
return trouble;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommendes liste kan se længere frem end en uge.
|
||||||
|
*
|
||||||
|
* Syv dage er stadig det, den åbner på — det er den vandring, der er værd at gå om
|
||||||
|
* morgenen. Men "hvornår bliver den her måned travl" er et andet spørgsmål, og
|
||||||
|
* månedsgitteret svarer på det med form frem for med rækkefølge. Derfor kan
|
||||||
|
* listen sættes til 14 og 30 dage, og valget bliver liggende: horisonten er et
|
||||||
|
* spørgsmål om, hvor langt man planlægger, ikke om hvilken skærm man sidder ved —
|
||||||
|
* men det er den samme slags valg som gitter eller liste, så det ligger samme sted.
|
||||||
|
*/
|
||||||
|
test('Kommende kan se en måned frem, og horisonten huskes', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill('skifte olie om 20 dage');
|
||||||
|
await box.press('Enter');
|
||||||
|
await expect(box).toHaveValue('');
|
||||||
|
|
||||||
|
await page.goto('/upcoming');
|
||||||
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||||
|
|
||||||
|
const days = page.locator('section:has(> h2)');
|
||||||
|
const task = page.getByText('skifte olie', { exact: true });
|
||||||
|
|
||||||
|
// Syv dage er udgangspunktet, og opgaven ligger uden for dem. At den er væk her
|
||||||
|
// er halvdelen af prøven: uden den kunne de tredive dage nedenfor være syv, der
|
||||||
|
// tilfældigvis viste den.
|
||||||
|
await expect(days).toHaveCount(7);
|
||||||
|
await expect(task).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '30 dage', exact: true }).click();
|
||||||
|
await expect(days).toHaveCount(30);
|
||||||
|
await expect(task).toBeVisible();
|
||||||
|
|
||||||
|
// Ugenummeret står kun, når strimlen er længere end en uge — ellers siger det
|
||||||
|
// ingenting, man ikke kunne se i forvejen.
|
||||||
|
await expect(days.first().getByText(/uge \d+/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Og valget overlever en genindlæsning.
|
||||||
|
await page.reload();
|
||||||
|
await expect(days).toHaveCount(30);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '30 dage', exact: true }),
|
||||||
|
'horisonten blev ikke husket'
|
||||||
|
).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '7 dage', exact: true }).click();
|
||||||
|
await expect(days).toHaveCount(7);
|
||||||
|
await expect(days.first().getByText(/uge \d+/i)).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -86,6 +140,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
|
||||||
|
// takes. The link is made at display time — the stored title is still plain text.
|
||||||
|
await box.fill('læs https://example.com/artikel i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
const row = page.locator('.row').filter({ hasText: 'læs' });
|
||||||
|
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
|
||||||
|
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
|
||||||
|
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||||
|
|
||||||
|
// Clicking the plain part of the title still opens the task; only the link is
|
||||||
|
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
|
||||||
|
await row.locator('.content').click({ position: { x: 3, y: 3 } });
|
||||||
|
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -127,6 +204,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill('Alfa i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// Snooze it to tomorrow from its drawer.
|
||||||
|
await page.getByText('Alfa', { exact: true }).click();
|
||||||
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
|
await drawer.getByRole('button', { name: 'I morgen' }).click();
|
||||||
|
|
||||||
|
// The row greys — it carries the snoozed class and a "slumret til" mark.
|
||||||
|
const alfa = page.locator('.row', { hasText: 'Alfa' });
|
||||||
|
await expect(alfa).toHaveClass(/snoozed/);
|
||||||
|
await expect(alfa.locator('.snooze-mark')).toBeVisible();
|
||||||
|
|
||||||
|
// The drawer now offers to wake it, and waking takes the grey off.
|
||||||
|
await drawer.getByRole('button', { name: 'Væk nu' }).click();
|
||||||
|
await expect(alfa).not.toHaveClass(/snoozed/);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -196,6 +299,38 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
|
||||||
|
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
|
||||||
|
await page.getByLabel('Menu', { exact: true }).selectOption('large');
|
||||||
|
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
|
||||||
|
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||||
|
|
||||||
|
const attrs = () =>
|
||||||
|
page.evaluate(() => [
|
||||||
|
document.documentElement.dataset.menuSize,
|
||||||
|
document.documentElement.dataset.textSize
|
||||||
|
]);
|
||||||
|
expect(await attrs()).toEqual(['large', 'xl']);
|
||||||
|
|
||||||
|
// They live in localStorage like the theme, so they survive a reload — and are
|
||||||
|
// applied before first paint, so the picker comes back showing the saved choice.
|
||||||
|
await page.reload();
|
||||||
|
expect(await attrs()).toEqual(['large', 'xl']);
|
||||||
|
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||||
|
|
||||||
|
// Put them back, so the shared account does not leak a size into other tests.
|
||||||
|
await page.getByLabel('Menu', { exact: true }).selectOption('default');
|
||||||
|
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
|
||||||
|
expect(await attrs()).toEqual([undefined, undefined]);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PWA's own files. Both of the bugs found last in this project were of this
|
* The PWA's own files. Both of the bugs found last in this project were of this
|
||||||
* kind — a manifest promising icons that were never generated — and they are
|
* kind — a manifest promising icons that were never generated — and they are
|
||||||
@@ -217,11 +352,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||||
|
|
||||||
// The service worker is what Web Push needs; without it a subscription cannot
|
// The service worker is what Web Push needs — and, since it also caches, what
|
||||||
// be created at all.
|
// carries the app through a short network drop. SvelteKit serves it under this
|
||||||
const worker = await request.get('/sw.js');
|
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||||
|
const worker = await request.get('/service-worker.js');
|
||||||
expect(worker.ok()).toBeTruthy();
|
expect(worker.ok()).toBeTruthy();
|
||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
// The source is minified, so the quotes around the event names are whatever the
|
||||||
|
// minifier chose — match either kind rather than one it might change under us.
|
||||||
|
const source = await worker.text();
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||||
|
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||||
|
* "you are offline" page.
|
||||||
|
*
|
||||||
|
* This is the whole point of the service worker's cache — not offline authoring,
|
||||||
|
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||||
|
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||||
|
* side of the same drop; this covers the read side.
|
||||||
|
*/
|
||||||
|
test.describe('med service worker', () => {
|
||||||
|
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||||
|
// test is about the worker, so it turns it back on for its own context.
|
||||||
|
test.use({ serviceWorkers: 'allow' });
|
||||||
|
|
||||||
|
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||||
|
// First load, online: the worker installs, and once it controls the page the
|
||||||
|
// shell and the reads it serves are in the cache.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait until the worker actually controls this page. Until it does, a reload
|
||||||
|
// still goes to the network and the test proves nothing — the first load
|
||||||
|
// registers the worker but the page that triggered the install is not itself
|
||||||
|
// controlled until it activates and claims.
|
||||||
|
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
// A controlled reload, still online: now the navigation and its API reads pass
|
||||||
|
// through the worker and land in the cache, ready for the drop.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// The network is gone.
|
||||||
|
await context.setOffline(true);
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
// The shell came from the cache — the sidebar is here, not a browser error
|
||||||
|
// page — and so did the task list the app renders into it.
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// Never leave the shared context offline for whatever test runs next.
|
||||||
|
await context.setOffline(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,9 +479,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||||
|
|
||||||
// The name is a link to the group's own page; the chevron beside it is a
|
// The name is a link to the group's own page; the folder icon beside it is a
|
||||||
// separate button that folds. Two intentions, two targets — the single one they
|
// separate button that folds. Two intentions, two targets — the single one they
|
||||||
// used to share could only ever serve the smaller.
|
// used to share could only ever serve the smaller, and neither wears an arrow.
|
||||||
//
|
//
|
||||||
// `exact` on the link, because a group's name is a user-chosen word that can
|
// `exact` on the link, because a group's name is a user-chosen word that can
|
||||||
// turn up inside another control's name, and Playwright matches an accessible
|
// turn up inside another control's name, and Playwright matches an accessible
|
||||||
@@ -302,19 +491,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||||
|
|
||||||
// A group's name must be readable in full, and must start where a project's
|
// A group's name must be readable in full, and must line up with the other
|
||||||
// name starts.
|
// headings — not with the projects filed under it.
|
||||||
//
|
//
|
||||||
// Both halves are here because both were once broken at the same time and for
|
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
|
||||||
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
|
||||||
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
// as "ARBE". The alignment half changed meaning with the redesign: a group now
|
||||||
// sat before the name, pushing a group 22px further in than the projects it
|
// carries a section icon just like "Projekter" above it, and belongs to that
|
||||||
// contains — so a group read as if it were filed under them. Every assertion
|
// heading tier rather than to the project rows below.
|
||||||
// in this file passed through both, because both buttons still worked.
|
|
||||||
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||||
const link = el.querySelector('h2 a');
|
const link = el.querySelector('h2 a');
|
||||||
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
// The leading mark is the section icon in its own fold button, the same
|
||||||
// The last child of the link is the name itself; the dot is a span before it.
|
// folder/funnel/tag glyph the system headings carry — a group and a section
|
||||||
|
// read identically, and the icon sits before the name, not inside the link.
|
||||||
|
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
|
||||||
return {
|
return {
|
||||||
clipped: link.scrollWidth > link.clientWidth + 1,
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||||
left: name.left,
|
left: name.left,
|
||||||
@@ -326,17 +516,17 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||||
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||||
|
|
||||||
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
// A group is a heading, not a project row: it belongs to the same tier as
|
||||||
// mark, and it is the only thing in them that means the same on both sides.
|
// "Projekter" and the other system sections — icon, bold name, no arrow — and
|
||||||
// Comparing the group's inner link with the project's outer box reported the
|
// starts where they start, not where the projects filed under it do. Measured
|
||||||
// row's own padding as a misalignment.
|
// icon to icon, the one mark both headings share.
|
||||||
const projectLeft = await sidebar
|
const sectionLeft = await sidebar
|
||||||
.getByRole('link', { name: 'Regnskab' })
|
.getByRole('heading', { name: 'Projekter' })
|
||||||
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
.evaluate((el) => el.querySelector('.head-icon').getBoundingClientRect().left);
|
||||||
expect(
|
expect(
|
||||||
Math.abs(shape.left - projectLeft),
|
Math.abs(shape.left - sectionLeft),
|
||||||
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
`gruppen starter ${Math.round(shape.left - sectionLeft)}px fra sektionsoverskrifterne`
|
||||||
).toBeLessThanOrEqual(2);
|
).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||||
// that heading is a drop target at all: with every project filed away there
|
// that heading is a drop target at all: with every project filed away there
|
||||||
@@ -359,18 +549,13 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
|
|
||||||
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
// The fold control is the bare folder icon now — no chevron, and no --surface
|
||||||
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
// field behind it, so a folded group leaves no pale square beside its name on
|
||||||
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
// the sunken bar.
|
||||||
// ingenting at male henover den med.
|
const foldBg = await sidebar
|
||||||
const chevron = await sidebar
|
.locator('.folder-head .fold-icon')
|
||||||
.locator('.folder-head .chevron-button')
|
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
.evaluate((el) => ({
|
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||||
background: getComputedStyle(el).backgroundColor,
|
|
||||||
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
|
||||||
}));
|
|
||||||
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
|
||||||
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
|
||||||
|
|
||||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||||
|
|
||||||
@@ -386,11 +571,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// button on click.
|
// button on click.
|
||||||
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
||||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Arbejde', exact: true })).toBeVisible();
|
||||||
const painted = await sidebar
|
|
||||||
.locator('.folder-head .group-dot')
|
|
||||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
|
||||||
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
|
||||||
|
|
||||||
// The heading itself is *not* indented — only what is filed under it. A heading
|
// The heading itself is *not* indented — only what is filed under it. A heading
|
||||||
// that starts further in than the rows above it says the heading is inside
|
// that starts further in than the rows above it says the heading is inside
|
||||||
@@ -404,10 +585,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||||
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
||||||
|
|
||||||
// A project inside a group starts further in than a row outside one. Measured
|
// A project sits at the same left margin whether or not it is in a group — one
|
||||||
// rather than asserted on a class, because indentation is a fact about where
|
// straight edge down the sidebar, with the bold heading doing the grouping the
|
||||||
// the row's contents land — and measured at the *content* edge, not the box:
|
// indent used to. Measured at the *content* edge, not the box, because what the
|
||||||
// the indent is padding, so the element's own left edge does not move.
|
// eye follows is where the text lands.
|
||||||
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
||||||
const contentLeft = (el) =>
|
const contentLeft = (el) =>
|
||||||
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
||||||
@@ -415,7 +596,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
||||||
sidebar.locator('.views a').first().evaluate(contentLeft)
|
sidebar.locator('.views a').first().evaluate(contentLeft)
|
||||||
]);
|
]);
|
||||||
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
|
expect(
|
||||||
|
Math.abs(inside - outside),
|
||||||
|
'et projekt i en gruppe står ikke i flugt med de faste visninger'
|
||||||
|
).toBeLessThan(2);
|
||||||
|
|
||||||
await sidebar
|
await sidebar
|
||||||
.getByRole('link', { name: 'Regnskab' })
|
.getByRole('link', { name: 'Regnskab' })
|
||||||
@@ -489,8 +673,13 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
||||||
await expect(page.locator('.toast')).toHaveCount(0);
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
// A load that fails says so, and offers to try again.
|
// A load that fails with a server error says so at once and offers to try again.
|
||||||
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
// A 500 is the server answering, not a dropped connection, so it is not a blip to
|
||||||
|
// wait out — it is not retried, and the message is immediate. (A dropped
|
||||||
|
// connection now retries for a few seconds first; that is the self-heal test.)
|
||||||
|
await page.route('**/api/v1/projects/*', (r) =>
|
||||||
|
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
|
||||||
|
);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
||||||
@@ -501,6 +690,52 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A save made during a short drop is not lost to it.
|
||||||
|
*
|
||||||
|
* This is the whole of what "offline" means here: not a cache to work in with no
|
||||||
|
* server, but a save that rides out a few seconds of no connection and goes through
|
||||||
|
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
|
||||||
|
* no reverted change once it heals. A drop that outlasts the retry window is a real
|
||||||
|
* outage and takes the normal error path; that is not this test.
|
||||||
|
*/
|
||||||
|
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for the app to finish loading before cutting the line — `goto` resolves at
|
||||||
|
// the load event, but the data load runs after hydration, and aborting that would
|
||||||
|
// test a cold start, not a blip mid-use.
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await expect(box).toBeVisible();
|
||||||
|
|
||||||
|
// The app is loaded and online. Now the connection drops — every call fails, the
|
||||||
|
// way it does in a tunnel, rather than one endpoint erroring.
|
||||||
|
let down = true;
|
||||||
|
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
|
||||||
|
|
||||||
|
// A task captured during the drop. Its save fails on the network and starts
|
||||||
|
// retrying rather than giving up.
|
||||||
|
await box.fill('under et udfald');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// The quiet pill, once the retry has lasted long enough to be worth showing.
|
||||||
|
await expect(page.locator('.netstatus')).toBeVisible();
|
||||||
|
|
||||||
|
// The drop ends. The retry goes through on its own and the pill leaves — no
|
||||||
|
// error, nothing toasted as failed.
|
||||||
|
down = false;
|
||||||
|
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
|
||||||
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
|
// And the save landed: an undated quick capture goes to the Inbox, and there it
|
||||||
|
// is, saved across the drop rather than lost to it.
|
||||||
|
await page.getByRole('link', { name: /Indbakke/ }).click();
|
||||||
|
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task can be dragged onto another day, and onto another project.
|
* A task can be dragged onto another day, and onto another project.
|
||||||
*
|
*
|
||||||
@@ -876,6 +1111,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
const andreas = await context.newPage();
|
const andreas = await context.newPage();
|
||||||
await andreas.goto(link);
|
await andreas.goto(link);
|
||||||
|
// Typed in lower case on purpose: the instance capitalises a name on the way
|
||||||
|
// in, so from here on this person is "Andreas" everywhere they are shown.
|
||||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
@@ -894,7 +1131,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await projectAction(page, /Delt/);
|
await projectAction(page, /Delt/);
|
||||||
await expect(page.getByText('Ejer')).toBeVisible();
|
await expect(page.getByText('Ejer')).toBeVisible();
|
||||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
await page.getByLabel('Rolle for Andreas').selectOption('editor');
|
||||||
|
|
||||||
await andreas.reload();
|
await andreas.reload();
|
||||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||||
@@ -907,11 +1144,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||||
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
|
||||||
// And not on a task that is nobody's.
|
// And not on a task that is nobody's.
|
||||||
await page.getByLabel('Ny opgave').fill('min egen');
|
await page.getByLabel('Ny opgave').fill('min egen');
|
||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
@@ -1202,8 +1439,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
||||||
await expect(band.getByText('vande blomster')).toBeHidden();
|
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||||
await expect(cell.locator('.tevent')).toHaveCount(1);
|
//
|
||||||
await expect(band.locator('.event')).toHaveCount(1);
|
// Named, not counted. A raw `.tevent` count here assumes this day holds nothing
|
||||||
|
// but this test's own events — and it does not: the suite shares one database,
|
||||||
|
// so an earlier test that drags a task across a month boundary can leave a timed
|
||||||
|
// task on this very day, and a timed task is a `.tevent` too. Counting them all
|
||||||
|
// made this pass alone and fail in the full run, on whatever date the arithmetic
|
||||||
|
// happened to land the two tests on the same cell. Asserting the meeting stayed
|
||||||
|
// and the task left is the thing the test actually means, and it is true however
|
||||||
|
// full somebody else left the day.
|
||||||
|
await expect(cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' })).toHaveCount(1);
|
||||||
|
await expect(cell.locator('.tevent').filter({ hasText: 'vande blomster' })).toHaveCount(0);
|
||||||
|
await expect(band.locator('.event').filter({ hasText: 'Feriedag' })).toHaveCount(1);
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -1215,6 +1462,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
* thing this view was before anything was laid over it — and a Google account that
|
* thing this view was before anything was laid over it — and a Google account that
|
||||||
* is not there must not take it down with it.
|
* is not there must not take it down with it.
|
||||||
*/
|
*/
|
||||||
|
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// A timed task today at ten, half an hour long. Today, so the week opens on it
|
||||||
|
// without paging, and ten is inside the hours the grid draws by default.
|
||||||
|
const today = await page.evaluate(() => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
const id = await page.evaluate(async (day) => {
|
||||||
|
const r = await fetch('/api/v1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: 'Møde med Anders',
|
||||||
|
due_date: day,
|
||||||
|
due_time: '10:00',
|
||||||
|
duration_min: 30
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
}, today);
|
||||||
|
|
||||||
|
await page.goto('/kalender');
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
|
||||||
|
const chip = page
|
||||||
|
.locator(`[data-date="${today}"] .tevent.task`)
|
||||||
|
.filter({ hasText: 'Møde med Anders' });
|
||||||
|
await expect(chip).toBeVisible();
|
||||||
|
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
// Drag the foot of the task down a good stretch. It should grow, both on screen
|
||||||
|
// and in what the server stores — a resize is a change of length, not of time.
|
||||||
|
const box = await chip.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.evaluate(async (tid) => {
|
||||||
|
const r = await fetch('/api/v1/tasks/' + tid, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||||
|
});
|
||||||
|
return (await r.json()).duration_min;
|
||||||
|
}, id)
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(30);
|
||||||
|
|
||||||
|
// And the chip is taller than it was, so the length is something you can see.
|
||||||
|
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
|
||||||
|
|
||||||
|
// The time did not move: it is still a ten o'clock task.
|
||||||
|
await expect(chip.locator('.at')).toHaveText('10:00');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/kalender');
|
await page.goto('/kalender');
|
||||||
@@ -1366,9 +1676,18 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
|||||||
// Into the group, then onto its page — which is what the heading is a link to
|
// Into the group, then onto its page — which is what the heading is a link to
|
||||||
// now. The chevron beside it still folds; they are two intentions and the old
|
// now. The chevron beside it still folds; they are two intentions and the old
|
||||||
// single target could only serve the smaller one.
|
// single target could only serve the smaller one.
|
||||||
await sidebar
|
//
|
||||||
.getByRole('link', { name: 'Drejebænk' })
|
// The drop is retried until the project actually nests under the group: a native
|
||||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||||
|
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||||
|
// reason that has nothing to do with what the test is about.
|
||||||
|
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||||
|
await expect(async () => {
|
||||||
|
await sidebar
|
||||||
|
.getByRole('link', { name: 'Drejebænk' })
|
||||||
|
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||||
|
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||||
|
}).toPass({ timeout: 10000 });
|
||||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||||
@@ -1819,7 +2138,12 @@ test('en passkey kan registreres og logges ind med', async ({ browser, browserNa
|
|||||||
// Now sign out and back in with it. No email typed: the device knows which
|
// Now sign out and back in with it. No email typed: the device knows which
|
||||||
// account its key belongs to, which is also why this page cannot be used to
|
// account its key belongs to, which is also why this page cannot be used to
|
||||||
// find out who has an account here.
|
// find out who has an account here.
|
||||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
// Signing out asks first now: one click arms it, a second within a few seconds
|
||||||
|
// does it. The whole account row is the button; its label changes to the prompt.
|
||||||
|
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
|
||||||
|
await signOut.click();
|
||||||
|
await expect(signOut).toContainText('Tryk igen for at logge ud');
|
||||||
|
await signOut.click();
|
||||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||||
// Playwright matches an accessible name loosely unless told otherwise.
|
// Playwright matches an accessible name loosely unless told otherwise.
|
||||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||||
@@ -2430,8 +2754,9 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
await page.keyboard.type('Fælles aftale om leveringen');
|
await page.keyboard.type('Fælles aftale om leveringen');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
// Shared by being filed in a project. Not an access list of its own.
|
// Filing it in a project — the controls live in the Del popover in the footer.
|
||||||
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
|
await page.locator('.sharewrap > button').click();
|
||||||
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
||||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||||
|
|
||||||
// And it is really there, not merely labelled: the project's own page shows it.
|
// And it is really there, not merely labelled: the project's own page shows it.
|
||||||
@@ -2441,7 +2766,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
// Taken back out, it is the author's alone again.
|
// Taken back out, it is the author's alone again.
|
||||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||||
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
await page.locator('.sharewrap > button').click();
|
||||||
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
||||||
await expect(page.getByText('din igen')).toBeVisible();
|
await expect(page.getByText('din igen')).toBeVisible();
|
||||||
|
|
||||||
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||||
@@ -2450,6 +2776,68 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
// Somebody to share with: a second account on the instance, made the ordinary
|
||||||
|
// way. The candidate list is drawn when the note opens, so they must exist
|
||||||
|
// before it does.
|
||||||
|
await page.goto('/indstillinger/brugere');
|
||||||
|
await page.getByLabel('E-mailadresse').fill('sofie@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||||
|
const link = await page.locator('.link-out').textContent();
|
||||||
|
|
||||||
|
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const sofie = await sofieCtx.newPage();
|
||||||
|
await sofie.goto(link);
|
||||||
|
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
|
||||||
|
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await sofie.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
|
||||||
|
// The owner writes a note and hands it to Sofie, as a reader.
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||||
|
await page.keyboard.type('Ferieplan for hele holdet');
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
|
// Sharing lives in a popover behind the Del button in the footer.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
const share = page.locator('.sharepanel');
|
||||||
|
await expect(share).toBeVisible();
|
||||||
|
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||||
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
|
||||||
|
// She is on the note now, with the role she was given.
|
||||||
|
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('select')).toHaveValue('viewer');
|
||||||
|
|
||||||
|
// And it reaches her: her own notes list grows a Delt med mig group with it in.
|
||||||
|
//
|
||||||
|
// Navigated on the origin the invite signed her in on. VERDANDE_BASE_URL is
|
||||||
|
// localhost while the suite drives 127.0.0.1, and a session cookie set on one
|
||||||
|
// host is not sent to the other — a bare "/noter" would land her logged out.
|
||||||
|
const noterURL = new URL('/noter', link).href;
|
||||||
|
await sofie.goto(noterURL);
|
||||||
|
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
|
||||||
|
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
|
||||||
|
// A viewer's note carries no star or archive control — it is not hers to put away.
|
||||||
|
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
|
||||||
|
await expect(sofieRow.locator('.star')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Taken back, it leaves her list.
|
||||||
|
await row.getByRole('button', { name: 'Fjern adgang' }).click();
|
||||||
|
await expect(row).toBeHidden();
|
||||||
|
await sofie.goto(noterURL);
|
||||||
|
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
|
||||||
|
|
||||||
|
await sofieCtx.close();
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
@@ -2487,6 +2875,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
// One note to point at, then another to write the link in.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Rejseplan til Berlin');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Mine planer');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Se ');
|
||||||
|
await page.keyboard.type('[[Rejse');
|
||||||
|
|
||||||
|
// The picker offers the note by title, and choosing it turns the text into a
|
||||||
|
// real inline link — the title, not the brackets.
|
||||||
|
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
|
||||||
|
await expect(inline).toBeVisible();
|
||||||
|
|
||||||
|
// It survives the round-trip through Markdown and a reload.
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||||
|
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||||
|
|
||||||
|
// And it is a link you can follow: clicking it opens the note it names.
|
||||||
|
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
|
||||||
|
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
@@ -2910,10 +3339,11 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
|||||||
await page.keyboard.type('Tagsten og lægter');
|
await page.keyboard.type('Tagsten og lægter');
|
||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
|
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||||
// nogen har valgt det bagefter.
|
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||||
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
await page.locator('.sharewrap > button').click();
|
||||||
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
|
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||||
|
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||||
expect(shared).toContain('Tagprojekt');
|
expect(shared).toContain('Tagprojekt');
|
||||||
|
|
||||||
// Og den står på projektets egen side.
|
// Og den står på projektets egen side.
|
||||||
@@ -2979,6 +3409,96 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Kvartalsplan');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
|
||||||
|
// The Arkivér button in the note's footer puts it away: it leaves the list.
|
||||||
|
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
|
||||||
|
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// It is in the archive, where the same button now offers to bring it back.
|
||||||
|
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||||
|
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
|
||||||
|
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
|
||||||
|
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// And back in the ordinary list.
|
||||||
|
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||||
|
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Titel');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
|
||||||
|
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
|
||||||
|
// med at være kode.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('kode linje');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('almindelig tekst');
|
||||||
|
|
||||||
|
// Koden står i sin blok; teksten efter står uden for den.
|
||||||
|
await expect(ed.locator('pre')).toHaveText('kode linje');
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('linje et');
|
||||||
|
await page.keyboard.press('Shift+Enter');
|
||||||
|
await page.keyboard.type('linje to');
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
|
||||||
|
|
||||||
|
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
|
||||||
|
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||||
|
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||||
|
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Rundtur');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('docker pull');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('og så videre i almindelig skrift');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||||
|
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
@@ -3052,7 +3572,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
|||||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
// The selection bar's Arkivér, not the open note's own in the footer.
|
||||||
|
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Off by default. The app now ships a caching service worker, and once it
|
||||||
|
// controls a page Playwright's page.route() no longer sees the fetches it
|
||||||
|
// makes — so a test that forces a 500 to check the error path gets the real
|
||||||
|
// 200 through the worker instead, and reads as a broken feature. The one test
|
||||||
|
// that needs the real worker turns it back on for its own context.
|
||||||
|
serviceWorkers: 'block',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
// Pinned, because the app reads both. The sign-up form sends
|
// Pinned, because the app reads both. The sign-up form sends
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ const SHOTS = [
|
|||||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||||
|
// Charcoal, the newest theme — a warm near-black modelled on Apple Notes, with
|
||||||
|
// amber where the green usually is. The notes list shows a theme's surfaces, text
|
||||||
|
// and one accent at once, so that is where the landing page carries it up close.
|
||||||
|
{ name: 'notes-charcoal', path: '/noter', theme: 'charcoal', prepare: openMeetingNote },
|
||||||
|
// The same Today view in each of the six themes, for a gallery that shows the
|
||||||
|
// whole range at a glance. One view throughout, so the only thing that changes
|
||||||
|
// from picture to picture is the theme itself. Names are the themes' own.
|
||||||
|
{ name: 'theme-northern-lights', path: '/', theme: 'dark' },
|
||||||
|
{ name: 'theme-charcoal', path: '/', theme: 'charcoal' },
|
||||||
|
{ name: 'theme-dusk', path: '/', theme: 'dusk' },
|
||||||
|
{ name: 'theme-daylight', path: '/', theme: 'light' },
|
||||||
|
{ name: 'theme-paper', path: '/', theme: 'paper' },
|
||||||
|
{ name: 'theme-contrast', path: '/', theme: 'contrast' },
|
||||||
|
// The new work, and in the light theme, which is what most people want to look
|
||||||
|
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||||
|
// somebody shared into the "Shared with me" group.
|
||||||
|
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
|
||||||
|
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
|
||||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||||
// Scrolled, because the themes are the second panel on the page and a picture
|
// Scrolled, because the themes are the second panel on the page and a picture
|
||||||
@@ -114,6 +132,13 @@ async function showNoteSource(page) {
|
|||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSharedNote(page) {
|
||||||
|
// The note Mette shared in, so the "Shared with me" group and its owner chip are
|
||||||
|
// what the picture is of rather than something the group happens to sit above.
|
||||||
|
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
|
||||||
async function showThemes(page) {
|
async function showThemes(page) {
|
||||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||||
@@ -340,6 +365,23 @@ async function handOver(ids) {
|
|||||||
await api(`/tasks/${ids.detail}/comments`, {
|
await api(`/tasks/${ids.detail}/comments`, {
|
||||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A note of Mette's own, handed to the owner — so their Notes page has a
|
||||||
|
// "Shared with me" group with a real note in it, marked as hers. This is the
|
||||||
|
// other half of sharing: not the project path, but a page passed to a person.
|
||||||
|
const note = await api('/notes', {
|
||||||
|
body: `# Packaging quotes
|
||||||
|
|
||||||
|
Three back, one still to come:
|
||||||
|
|
||||||
|
- **Nordfelt** — 79 øre a bag at 2,000, plates included
|
||||||
|
- **Kolding Karton** — 84 øre, faster, kinder about small runs
|
||||||
|
- Bruun still owes me a number
|
||||||
|
|
||||||
|
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
|
||||||
|
});
|
||||||
|
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
|
||||||
|
|
||||||
return { colleague: me.id };
|
return { colleague: me.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +415,8 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
).padStart(2, '0')}`;
|
).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const me = await api('/auth/me', null, 'GET');
|
||||||
|
|
||||||
// Two headings over the projects, which is what the sidebar grew for.
|
// Two headings over the projects, which is what the sidebar grew for.
|
||||||
const work = await api('/project-groups', { name: 'Work' });
|
const work = await api('/project-groups', { name: 'Work' });
|
||||||
const home = await api('/project-groups', { name: 'Home' });
|
const home = await api('/project-groups', { name: 'Home' });
|
||||||
@@ -420,6 +464,17 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||||
|
|
||||||
|
// A handful given a time and a length, so the calendar's week draws them to
|
||||||
|
// scale rather than as a row of identical blocks — a stand-up, a long
|
||||||
|
// planning block, a call, a review. Spread across the day and the days, so
|
||||||
|
// the picture is a week that has a shape.
|
||||||
|
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
|
||||||
|
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
|
||||||
|
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
|
||||||
|
|
||||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||||
// them rather than a row of empty cells.
|
// them rather than a row of empty cells.
|
||||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||||
@@ -441,6 +496,17 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
const made = {};
|
const made = {};
|
||||||
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
||||||
|
|
||||||
|
// One task snoozed, so the board and the list show a card parked at the bottom
|
||||||
|
// of its section — greyed, with a "snoozed until" mark — set aside rather than
|
||||||
|
// forgotten, and without touching when it is due.
|
||||||
|
const snoozeUntil = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 2);
|
||||||
|
d.setHours(9, 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
})();
|
||||||
|
await api(`/tasks/${made['Write the job advert'].id}/snooze`, { until: snoozeUntil });
|
||||||
|
|
||||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||||
// a feature that does not work.
|
// a feature that does not work.
|
||||||
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
||||||
@@ -560,6 +626,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
|||||||
await api('/users', { email: 'anders@example.dk' });
|
await api('/users', { email: 'anders@example.dk' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
owner: me.id,
|
||||||
work: launch.id,
|
work: launch.id,
|
||||||
detail: detail.id,
|
detail: detail.id,
|
||||||
invite: invite.link,
|
invite: invite.link,
|
||||||
|
|||||||
@@ -86,6 +86,12 @@
|
|||||||
--text-xl: 1.375rem;
|
--text-xl: 1.375rem;
|
||||||
--text-2xl: 1.75rem;
|
--text-2xl: 1.75rem;
|
||||||
|
|
||||||
|
/* The sidebar's own text size, its own knob (Settings → Appearance) so the menu
|
||||||
|
can be made easier to read without dragging the whole list with it. Default is
|
||||||
|
a step up from the old --text-sm the nav used, at medium weight, so the menu
|
||||||
|
items read as menu items. [data-menu-size] on <html> overrides it. */
|
||||||
|
--menu-size: 0.9375rem;
|
||||||
|
|
||||||
/* Spacing on a 4px grid. */
|
/* Spacing on a 4px grid. */
|
||||||
--s1: 0.25rem;
|
--s1: 0.25rem;
|
||||||
--s2: 0.5rem;
|
--s2: 0.5rem;
|
||||||
@@ -204,6 +210,52 @@
|
|||||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
|
||||||
|
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
|
||||||
|
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
|
||||||
|
than sharing --surface-raised with the list. The accent turns amber, and the
|
||||||
|
selected menu item, selected note and section headings each get a fallback token
|
||||||
|
so this theme can hit its colours without touching any other. */
|
||||||
|
[data-theme='charcoal'] {
|
||||||
|
--ground: #1e1e1e;
|
||||||
|
--surface: #2a3033;
|
||||||
|
--surface-raised: #242b2e; /* the note list */
|
||||||
|
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
|
||||||
|
--line: #34393b;
|
||||||
|
--line-strong: #414749;
|
||||||
|
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-muted: #dcdcdc;
|
||||||
|
--ink-faint: #9ba0a2;
|
||||||
|
|
||||||
|
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
|
||||||
|
dark text rides on top of it. */
|
||||||
|
--accent: #ffce35;
|
||||||
|
--accent-hover: #ffd75a;
|
||||||
|
--accent-sunken: #ffce35;
|
||||||
|
--accent-ink: #1e1e1e;
|
||||||
|
|
||||||
|
/* Per-element, and only here — every other theme falls back to the shared token
|
||||||
|
in the var() calls that read these. */
|
||||||
|
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
|
||||||
|
--note-on-bg: #a78c26; /* a selected note in the list — gold */
|
||||||
|
--note-on-ink: #1e1e1e; /* dark text on the gold */
|
||||||
|
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
|
||||||
|
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
|
||||||
|
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
|
||||||
|
|
||||||
|
--p1: #cf6b6b;
|
||||||
|
--p2: #d69f5c;
|
||||||
|
--p3: #6b9ecf;
|
||||||
|
--p4: var(--ink-faint);
|
||||||
|
|
||||||
|
--danger: #cf6b6b;
|
||||||
|
--danger-sunken: #33201f;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||||
|
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Papir — warm light, closer to paper than to a screen. */
|
/* Papir — warm light, closer to paper than to a screen. */
|
||||||
[data-theme='paper'] {
|
[data-theme='paper'] {
|
||||||
--ground: #f6f1e7;
|
--ground: #f6f1e7;
|
||||||
@@ -369,9 +421,13 @@ button {
|
|||||||
søgefelt. */
|
søgefelt. */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A translucent wash of the accent, not a solid block and not the old near-invisible
|
||||||
|
tint. --accent-sunken sat a hair off the editor's own surface in dark, so a
|
||||||
|
selection could not be seen; a solid accent could, but read as a heavy slab. Mixing
|
||||||
|
the accent part-way into transparent lays a clear green film over the text while it
|
||||||
|
keeps its own colour — visible on white and on the dark surfaces alike. */
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent-sunken);
|
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
||||||
@@ -516,3 +572,41 @@ button {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Body text size — its own control in Settings → Appearance, for reading the list
|
||||||
|
and the notes larger (or smaller) without touching the menu. It re-scales the
|
||||||
|
shared type tokens the same way a look does; placed after the looks so that an
|
||||||
|
explicit size choice wins the tokens they both set. The menu has its own knob
|
||||||
|
below and does not move with this one. */
|
||||||
|
[data-text-size='small'] {
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-sm: 0.75rem;
|
||||||
|
--text-lg: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-text-size='large'] {
|
||||||
|
--text-base: 1.0625rem;
|
||||||
|
--text-sm: 0.9375rem;
|
||||||
|
--text-lg: 1.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-text-size='xl'] {
|
||||||
|
--text-base: 1.1875rem;
|
||||||
|
--text-sm: 1.0625rem;
|
||||||
|
--text-lg: 1.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu (sidebar) font size — independent of the body size above, because the two
|
||||||
|
are separate questions: a menu you can read at a glance, and a list you can read
|
||||||
|
a paragraph of. */
|
||||||
|
[data-menu-size='small'] {
|
||||||
|
--menu-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-menu-size='large'] {
|
||||||
|
--menu-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-menu-size='xl'] {
|
||||||
|
--menu-size: 1.1875rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
// face and again in another, which is a visible jump.
|
// face and again in another, which is a visible jump.
|
||||||
const look = localStorage.getItem('verdande:look');
|
const look = localStorage.getItem('verdande:look');
|
||||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||||
|
|
||||||
|
// The font-size choices change layout too, so they belong before first
|
||||||
|
// paint for the same reason: otherwise the sidebar and the list are drawn
|
||||||
|
// once at one size and again at another.
|
||||||
|
const menuSize = localStorage.getItem('verdande:menu-size');
|
||||||
|
if (menuSize && menuSize !== 'default') document.documentElement.dataset.menuSize = menuSize;
|
||||||
|
const textSize = localStorage.getItem('verdande:text-size');
|
||||||
|
if (textSize && textSize !== 'default') document.documentElement.dataset.textSize = textSize;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Private browsing can refuse localStorage; the dark default stands.
|
// Private browsing can refuse localStorage; the dark default stands.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,57 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { t } from './i18n.svelte.js';
|
import { t } from './i18n.svelte.js';
|
||||||
|
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a request keeps retrying a network failure before giving up.
|
||||||
|
*
|
||||||
|
* This is the whole of the "offline" feature and its whole limit: it rides out a
|
||||||
|
* blip of a few seconds so a save is not lost to it, and then — because the app is
|
||||||
|
* a live view of a shared list, not a local database — it stops and says so rather
|
||||||
|
* than holding an edit for a network that is not coming back. Fifteen seconds is
|
||||||
|
* long enough for a handover or a tunnel and short enough that a real outage fails
|
||||||
|
* while the user is still looking at what they did.
|
||||||
|
*/
|
||||||
|
const RETRY_WINDOW_MS = 15000;
|
||||||
|
|
||||||
|
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
|
||||||
|
* wakes the instant the OS reports a link again, so most blips end on the first. */
|
||||||
|
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `send` (a fetch), retrying only network failures for a short window.
|
||||||
|
*
|
||||||
|
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
|
||||||
|
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
|
||||||
|
* a 400 just asks the same wrong question again. An abort is the caller changing
|
||||||
|
* its mind (a superseded search), so it is left to propagate.
|
||||||
|
*
|
||||||
|
* Retrying is not free of risk: a create whose request reached the server but whose
|
||||||
|
* response was lost would be made twice. For a drop of seconds that is rare, and the
|
||||||
|
* alternative — losing the save every time the network hiccups — is the thing this
|
||||||
|
* exists to stop. A server-side idempotency key would remove even that; it is a
|
||||||
|
* larger change noted for later.
|
||||||
|
*/
|
||||||
|
async function withRetry(send, noRetry = false) {
|
||||||
|
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
|
||||||
|
let attempt = 0;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await send();
|
||||||
|
connectivity.reachable();
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'AbortError') throw e;
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
connectivity.lost();
|
||||||
|
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||||
|
}
|
||||||
|
connectivity.retrying();
|
||||||
|
await sleepOrOnline(backoff(attempt++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key for each code the API can return.
|
* The key for each code the API can return.
|
||||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
|||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let res;
|
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||||
try {
|
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||||
res = await fetch(`/api/v1${path}`, init);
|
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||||
} catch (e) {
|
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||||
// A network failure is not an HTTP status; give it one the app can handle
|
|
||||||
// the same way as everything else.
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(field, file);
|
form.append(field, file);
|
||||||
|
|
||||||
let res;
|
const res = await withRetry(() =>
|
||||||
try {
|
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
);
|
||||||
} catch {
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let payload = null;
|
let payload = null;
|
||||||
@@ -310,6 +354,7 @@ export const api = {
|
|||||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||||
|
|
||||||
quickAdd: (text, projectId, sectionId) =>
|
quickAdd: (text, projectId, sectionId) =>
|
||||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||||
@@ -405,6 +450,12 @@ export const api = {
|
|||||||
deleteNote: (id) => del(`/notes/${id}`),
|
deleteNote: (id) => del(`/notes/${id}`),
|
||||||
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||||
|
|
||||||
|
// Sharing a note with people directly — its own access list, beside the project
|
||||||
|
// path. Owner only; the list call also returns who it can still be shared with.
|
||||||
|
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||||
|
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
||||||
|
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
||||||
|
|
||||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||||
// there is no instance-wide registration behind these the way Gmail has one.
|
// there is no instance-wide registration behind these the way Gmail has one.
|
||||||
mailboxes: () => get('/mailboxes'),
|
mailboxes: () => get('/mailboxes'),
|
||||||
|
|||||||
@@ -286,6 +286,48 @@
|
|||||||
/** The cell lit up under the pointer. */
|
/** The cell lit up under the pointer. */
|
||||||
let over = $state(null);
|
let over = $state(null);
|
||||||
|
|
||||||
|
// A resize in progress: which task, and where its foot is being dragged to, in
|
||||||
|
// minutes since midnight. Kept apart from the stored length so the task can
|
||||||
|
// follow the handle live and only be written when the drag ends.
|
||||||
|
let resizing = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging the foot of a timed task changes how long it is.
|
||||||
|
*
|
||||||
|
* Pointer events rather than the HTML5 drag the body uses: the two must not be
|
||||||
|
* the same gesture, or every attempt to make a task longer would instead pick it
|
||||||
|
* up and move it. stopPropagation keeps the handle's press from starting the
|
||||||
|
* move, and the column it lives in is what the minutes are measured against —
|
||||||
|
* the same box onDropAt reads a drop time from.
|
||||||
|
*/
|
||||||
|
function startResize(event, task, date) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const col = event.currentTarget.closest('.daycol');
|
||||||
|
if (!col || !task.due_datetime) return;
|
||||||
|
const at = new Date(task.due_datetime);
|
||||||
|
const from = at.getHours() * 60 + at.getMinutes();
|
||||||
|
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const box = col.getBoundingClientRect();
|
||||||
|
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
|
||||||
|
const minutes = hours.from * 60 + share * hours.minutes;
|
||||||
|
// Quarter-hour steps, at least a quarter long, never past midnight.
|
||||||
|
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
|
||||||
|
resizing = { ...resizing, to: snapped };
|
||||||
|
};
|
||||||
|
const onUp = async () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
const current = resizing;
|
||||||
|
resizing = null;
|
||||||
|
if (current) await app.resize(current.id, current.to - current.from);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
function onDragOver(event, date) {
|
function onDragOver(event, date) {
|
||||||
if (!carries(event, TASK)) return;
|
if (!carries(event, TASK)) return;
|
||||||
accept(event);
|
accept(event);
|
||||||
@@ -394,7 +436,16 @@
|
|||||||
if (!task.due_datetime) continue;
|
if (!task.due_datetime) continue;
|
||||||
const at = new Date(task.due_datetime);
|
const at = new Date(task.due_datetime);
|
||||||
const from = at.getHours() * 60 + at.getMinutes();
|
const from = at.getHours() * 60 + at.getMinutes();
|
||||||
out.push({ kind: 'task', ref: task, from, to: from + 30 });
|
// The task's own length, not a fixed half hour. A quarter-hour floor so a
|
||||||
|
// zero-minute task is still something to see and grab, and the end of the
|
||||||
|
// day is the ceiling. While a resize is in flight this task follows the
|
||||||
|
// handle instead of its stored length.
|
||||||
|
let to =
|
||||||
|
resizing && resizing.id === task.id
|
||||||
|
? resizing.to
|
||||||
|
: from + (task.duration_min || 30);
|
||||||
|
to = Math.min(Math.max(to, from + 15), 24 * 60);
|
||||||
|
out.push({ kind: 'task', ref: task, from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||||
@@ -547,12 +598,13 @@
|
|||||||
</svelte:element>
|
</svelte:element>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
|
||||||
<!-- Draggable as well as clickable: moving something to another day is the
|
<!-- Draggable as well as clickable: moving something to another day is the
|
||||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||||
of movement before it calls a press a drag, so the click survives. -->
|
of movement before it calls a press a drag, so the click survives. -->
|
||||||
<button
|
<button
|
||||||
class={kind}
|
class={kind}
|
||||||
|
class:resizing={resizing?.id === task.id}
|
||||||
data-priority={task.priority}
|
data-priority={task.priority}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||||
@@ -562,6 +614,16 @@
|
|||||||
>
|
>
|
||||||
{#if at}<span class="at">{at}</span>{/if}
|
{#if at}<span class="at">{at}</span>{/if}
|
||||||
{task.content}
|
{task.content}
|
||||||
|
<!-- Only a task standing at a time can be made longer, and only where the
|
||||||
|
grid has room to show it — the timed column, which passes the date. -->
|
||||||
|
{#if date && task.due_datetime}
|
||||||
|
<span
|
||||||
|
class="resize"
|
||||||
|
title={t('cal.resize')}
|
||||||
|
aria-hidden="true"
|
||||||
|
onpointerdown={(e) => startResize(e, task, date)}
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -589,6 +651,7 @@
|
|||||||
<div
|
<div
|
||||||
class="allday"
|
class="allday"
|
||||||
data-allday={iso(date)}
|
data-allday={iso(date)}
|
||||||
|
class:today={iso(date) === todayISO}
|
||||||
class:over={over === iso(date)}
|
class:over={over === iso(date)}
|
||||||
ondragover={(e) => onDragOver(e, date)}
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
ondragleave={() => (over = null)}
|
ondragleave={() => (over = null)}
|
||||||
@@ -632,7 +695,7 @@
|
|||||||
{#if item.kind === 'event'}
|
{#if item.kind === 'event'}
|
||||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||||
{:else}
|
{:else}
|
||||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -814,12 +877,16 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* White, not the sidebar's sunken ground: a calendar reads lightest when the
|
||||||
|
day itself is the brightest thing on the screen and the events are the soft
|
||||||
|
colour laid on it — the opposite of the grey grid it was, where the events
|
||||||
|
had to shout to be seen at all. */
|
||||||
.corner,
|
.corner,
|
||||||
.head,
|
.head,
|
||||||
.gutter,
|
.gutter,
|
||||||
.allday,
|
.allday,
|
||||||
.daycol {
|
.daycol {
|
||||||
background: var(--ground);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
@@ -827,6 +894,16 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Today's whole column, faintly, so the eye finds it before it reads a number —
|
||||||
|
the marker on the date above is the label, this is the glow it sits in. Kept
|
||||||
|
under the events, so a pastel block still reads as its own calendar's colour
|
||||||
|
rather than as this wash tinted. */
|
||||||
|
.head.today,
|
||||||
|
.allday.today,
|
||||||
|
.daycol.today {
|
||||||
|
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
.head .wd {
|
.head .wd {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -839,9 +916,19 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The marker at the top the day is named by: today's number in a filled disc,
|
||||||
|
the same one the month grid gives today, so "this is today" is one glance in
|
||||||
|
either view rather than a colour you have to already know to read. */
|
||||||
.head.today .num {
|
.head.today .num {
|
||||||
color: var(--accent);
|
color: var(--accent-ink);
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gutter {
|
.gutter {
|
||||||
@@ -915,6 +1002,10 @@
|
|||||||
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
|
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
|
||||||
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
|
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
|
||||||
luft, der gør to kasser til to kasser. */
|
luft, der gør to kasser til to kasser. */
|
||||||
|
/* A soft wash of the calendar's own colour rather than a grey block edged in it:
|
||||||
|
each calendar keeps a dusty, unmistakable nuance, and a screen of them reads
|
||||||
|
as colour laid on white instead of a grid of grey bars. The left edge stays,
|
||||||
|
a shade stronger than the fill, so the block still has a spine. */
|
||||||
.tevent {
|
.tevent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
@@ -922,21 +1013,48 @@
|
|||||||
padding: 1px var(--s1);
|
padding: 1px var(--s1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-left: 3px solid var(--event-colour, var(--line-strong));
|
border-left: 3px solid var(--event-colour, var(--line-strong));
|
||||||
background: var(--surface-sunken);
|
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
|
||||||
color: var(--ink-muted);
|
color: var(--ink);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A task is a card you can pick up, not an event you cannot — so on the white
|
||||||
|
column it is an outlined card, where an event is a solid wash. The priority
|
||||||
|
colour rides the left edge, as it does everywhere else. */
|
||||||
.tevent.task {
|
.tevent.task {
|
||||||
background: var(--surface-raised);
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
border-left-color: var(--line-strong);
|
border-left-color: var(--line-strong);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The grip along the foot of a task, for dragging its length. Out of the way
|
||||||
|
until the task is hovered or being resized, so it never competes with the
|
||||||
|
words above it. */
|
||||||
|
.tevent.task .resize {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 7px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tevent.task:hover .resize,
|
||||||
|
.tevent.task.resizing .resize {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--line-strong));
|
||||||
|
}
|
||||||
|
.tevent.task.resizing {
|
||||||
|
cursor: ns-resize;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
.tevent.task[data-priority='1'] {
|
.tevent.task[data-priority='1'] {
|
||||||
border-left-color: var(--p1);
|
border-left-color: var(--p1);
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1134,8 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 1px var(--s1);
|
padding: 1px var(--s1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-raised);
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
border-left: 2px solid var(--line-strong);
|
border-left: 2px solid var(--line-strong);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1035,7 +1154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip:hover {
|
.chip:hover {
|
||||||
background: var(--surface);
|
background: var(--surface-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An event reads as a different kind of thing at a glance, before any of the
|
/* An event reads as a different kind of thing at a glance, before any of the
|
||||||
@@ -1055,8 +1174,8 @@
|
|||||||
padding: 1px var(--s1);
|
padding: 1px var(--s1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-left: 3px solid var(--event-colour);
|
border-left: 3px solid var(--event-colour);
|
||||||
background: var(--surface-sunken);
|
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||||
color: var(--ink-muted);
|
color: var(--ink);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1071,7 +1190,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.event:hover {
|
.event:hover {
|
||||||
background: var(--surface);
|
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,7 +1199,7 @@
|
|||||||
every calendar anybody has used. */
|
every calendar anybody has used. */
|
||||||
.event.allday {
|
.event.allday {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,20 +1301,18 @@
|
|||||||
padding: 1px var(--s1);
|
padding: 1px var(--s1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-left: 2px solid var(--line-strong);
|
border-left: 2px solid var(--line-strong);
|
||||||
background: var(--surface-raised);
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar.week .chip[data-priority='1'] {
|
.calendar.week .chip[data-priority='1'] {
|
||||||
border-left-color: var(--p1);
|
border-left-color: var(--p1);
|
||||||
background: var(--surface-raised);
|
|
||||||
}
|
}
|
||||||
.calendar.week .chip[data-priority='2'] {
|
.calendar.week .chip[data-priority='2'] {
|
||||||
border-left-color: var(--p2);
|
border-left-color: var(--p2);
|
||||||
background: var(--surface-raised);
|
|
||||||
}
|
}
|
||||||
.calendar.week .chip[data-priority='3'] {
|
.calendar.week .chip[data-priority='3'] {
|
||||||
border-left-color: var(--p3);
|
border-left-color: var(--p3);
|
||||||
background: var(--surface-raised);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Readable again in the week, for the reason the task chips are: a
|
/* Readable again in the week, for the reason the task chips are: a
|
||||||
@@ -1207,12 +1324,12 @@
|
|||||||
padding: 1px var(--s1);
|
padding: 1px var(--s1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-left: 3px solid var(--event-colour);
|
border-left: 3px solid var(--event-colour);
|
||||||
background: var(--surface-sunken);
|
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar.week .event.allday {
|
.calendar.week .event.allday {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar.week .at {
|
.calendar.week .at {
|
||||||
|
|||||||