Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b041b32aa2 | ||
|
|
61634a6bb5 | ||
|
|
ab77024ec9 | ||
|
|
48509e3073 | ||
|
|
916a0c0224 | ||
|
|
ff75302cf8 | ||
|
|
08d703e3af | ||
|
|
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 |
@@ -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. |
|
||||||
|
|||||||
@@ -74,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
|
||||||
@@ -3099,10 +3126,20 @@ paths:
|
|||||||
candidates:
|
candidates:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Person" }
|
items: { $ref: "#/components/schemas/Person" }
|
||||||
|
follows:
|
||||||
|
description: >
|
||||||
|
The notes this one points at with [[links]], which are shared
|
||||||
|
alongside it. Owner's own notes only.
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Share a note with a person, or change the role they hold.
|
summary: Share a note with a person, or change the role they hold.
|
||||||
|
description: >
|
||||||
|
The notes this one points at with [[links]] are shared with the same person
|
||||||
|
and the same role, so a link in a shared note leads somewhere. Only notes
|
||||||
|
the sharer owns follow, and the response lists them.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3114,7 +3151,16 @@ paths:
|
|||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
role: { type: string, enum: [viewer, editor] }
|
||||||
responses:
|
responses:
|
||||||
"204": { description: Shared }
|
"200":
|
||||||
|
description: Shared
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
follows:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
@@ -4008,6 +4054,23 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
user: { $ref: "#/components/schemas/Person" }
|
user: { $ref: "#/components/schemas/Person" }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
role: { type: string, enum: [viewer, editor] }
|
||||||
|
linked:
|
||||||
|
description: >
|
||||||
|
True when the person has this note because another shared note points
|
||||||
|
at it, rather than because it was shared with them on its own. Such a
|
||||||
|
grant is taken back by removing the link or the share it came from, not
|
||||||
|
by removing it here.
|
||||||
|
type: boolean
|
||||||
|
via:
|
||||||
|
description: The title of the shared note that points at this one.
|
||||||
|
type: string
|
||||||
|
|
||||||
|
NoteFollow:
|
||||||
|
type: object
|
||||||
|
description: A note that is shared alongside another because that one links to it.
|
||||||
|
properties:
|
||||||
|
note_id: { type: string }
|
||||||
|
title: { type: string }
|
||||||
|
|
||||||
Mailbox:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
@@ -4497,12 +4560,22 @@ components:
|
|||||||
|
|
||||||
Notification:
|
Notification:
|
||||||
type: object
|
type: object
|
||||||
|
description: >
|
||||||
|
Something somebody else did that you need to know about. `kind` says what
|
||||||
|
happened and the client composes the sentence from it, so the bell follows
|
||||||
|
the reader's language; `title` is the server's own Danish wording, used for
|
||||||
|
Web Push, which has no dictionary to look words up in.
|
||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
actor_name: { type: string }
|
actor_name: { type: string }
|
||||||
project_id: { type: string }
|
project_id: { type: string }
|
||||||
task_id: { type: string }
|
task_id: { type: string }
|
||||||
kind: { type: string }
|
note_id:
|
||||||
|
description: Set on a notification about a note — a shared note somebody else edited.
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [assigned, note.changed, comment]
|
||||||
title: { type: string }
|
title: { type: string }
|
||||||
body: { type: string }
|
body: { type: string }
|
||||||
read: { type: boolean }
|
read: { type: boolean }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,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"
|
||||||
)
|
)
|
||||||
@@ -50,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -204,9 +204,49 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "create note", err)
|
s.internal(w, r, "create note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// En ny note kan være den, en delt note hele tiden har peget på: linket er
|
||||||
|
// skrevet som en titel, og titlen fandtes ikke før nu.
|
||||||
|
//
|
||||||
|
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
|
||||||
|
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
|
||||||
|
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
|
||||||
|
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
|
||||||
|
// nok til at nå at taste den første linje ind i den forrige note, som så blev
|
||||||
|
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||||
|
if strings.TrimSpace(n.Body) != "" {
|
||||||
|
s.followLinks(r, n)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusCreated, n)
|
writeJSON(w, http.StatusCreated, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// followLinks holder de udledte delinger i takt med teksten.
|
||||||
|
//
|
||||||
|
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
|
||||||
|
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
|
||||||
|
// samme sted hen for begge.
|
||||||
|
//
|
||||||
|
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
|
||||||
|
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
|
||||||
|
// gemning talte med, kunne den, man har delt en note med, skrive
|
||||||
|
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
|
||||||
|
//
|
||||||
|
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
|
||||||
|
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
|
||||||
|
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
|
||||||
|
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
|
||||||
|
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
|
||||||
|
//
|
||||||
|
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
|
||||||
|
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
|
||||||
|
func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
||||||
|
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
|
||||||
|
s.log.Error("sync linked shares", "err", err, "note", n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||||
var body noteBody
|
var body noteBody
|
||||||
if err := decodeJSON(w, r, &body); err != nil {
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
@@ -242,6 +282,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
|
before := n.Body
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -249,13 +290,47 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
n.Pinned = *body.Pinned
|
n.Pinned = *body.Pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
|
||||||
|
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
|
||||||
|
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
|
||||||
|
// køre, når svaret kan være blevet et andet, og ellers ikke.
|
||||||
|
wasLinking := store.LinksIn(before)
|
||||||
|
wasTitled := n.Title
|
||||||
|
|
||||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||||
s.internal(w, r, "update note", err)
|
s.internal(w, r, "update note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
|
||||||
|
// det, andres links peger på, uden at et eneste link er rørt.
|
||||||
|
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
||||||
|
s.followLinks(r, n)
|
||||||
|
}
|
||||||
|
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
|
||||||
|
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
||||||
|
// det ville lære folk at overse dem alle sammen.
|
||||||
|
if before != n.Body {
|
||||||
|
s.notifyNoteChanged(r, n)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, n)
|
writeJSON(w, http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameLinks(a, b []store.NoteLink) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
in := make(map[store.NoteLink]bool, len(a))
|
||||||
|
for _, l := range a {
|
||||||
|
in[l] = true
|
||||||
|
}
|
||||||
|
for _, l := range b {
|
||||||
|
if !in[l] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,6 +354,9 @@ 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
|
||||||
}
|
}
|
||||||
|
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
|
||||||
|
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
|
||||||
|
s.followLinks(r, n)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +391,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"`)
|
||||||
|
|||||||
@@ -77,12 +77,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list note shares", err)
|
s.internal(w, r, "list note shares", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
|
||||||
|
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
|
||||||
|
// med den her. To udregninger af det samme er to, der kan blive uenige.
|
||||||
|
//
|
||||||
|
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
|
||||||
|
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
|
||||||
|
// siden.
|
||||||
|
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "sync linked shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
via := map[string]string{}
|
||||||
|
for _, l := range following {
|
||||||
|
if l.NoteID == n.ID {
|
||||||
|
via[l.UserID] = l.Via
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]noteShareJSON, 0, len(shares))
|
out := make([]noteShareJSON, 0, len(shares))
|
||||||
already := map[string]bool{}
|
already := map[string]bool{}
|
||||||
for _, sh := range shares {
|
for _, sh := range shares {
|
||||||
|
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
|
||||||
|
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
|
||||||
|
// deling, den kom fra. En knap, der lover det første og gør det andet, er
|
||||||
|
// værre end ingen knap.
|
||||||
out = append(out, noteShareJSON{
|
out = append(out, noteShareJSON{
|
||||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||||
Role: string(sh.Role),
|
Role: string(sh.Role),
|
||||||
|
Linked: via[sh.User.ID] != "",
|
||||||
|
Via: via[sh.User.ID],
|
||||||
})
|
})
|
||||||
already[sh.User.ID] = true
|
already[sh.User.ID] = true
|
||||||
}
|
}
|
||||||
@@ -103,12 +128,41 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"shares": out,
|
||||||
|
"candidates": cand,
|
||||||
|
"follows": followsOf(following, n.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
|
||||||
|
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
|
||||||
|
// følger med" er det samme, hvem det så end deles med.
|
||||||
|
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []followJSON{}
|
||||||
|
for _, l := range all {
|
||||||
|
if l.ViaID != noteID || seen[l.NoteID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[l.NoteID] = true
|
||||||
|
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type followJSON struct {
|
||||||
|
NoteID string `json:"note_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type noteShareJSON struct {
|
type noteShareJSON struct {
|
||||||
User personJSON `json:"user"`
|
User personJSON `json:"user"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
|
||||||
|
// fordi nogen har delt netop den. `via` er den note, der peger.
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
Via string `json:"via,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type shareNoteRequest struct {
|
type shareNoteRequest struct {
|
||||||
@@ -159,7 +213,15 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storeError(w, r, "share note", err)
|
s.storeError(w, r, "share note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
||||||
|
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
||||||
|
followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "sync linked shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUnshareNote takes a person's access away again.
|
// handleUnshareNote takes a person's access away again.
|
||||||
@@ -177,5 +239,12 @@ func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "unshare note", err)
|
s.internal(w, r, "unshare note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
|
||||||
|
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
|
||||||
|
// bliver stående, og en, der ikke kan, forsvinder.
|
||||||
|
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
|
||||||
|
s.internal(w, r, "sync linked shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
|||||||
// Share it as a viewer.
|
// Share it as a viewer.
|
||||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("share: status %d", resp.StatusCode)
|
t.Fatalf("share: status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
|||||||
|
|
||||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("share as editor failed")
|
t.Fatalf("share as editor failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,104 @@ func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
|||||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||||
}
|
}
|
||||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
|
||||||
t.Error("a note was shared at the owner role")
|
t.Error("a note was shared at the owner role")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// De noter, en delt note peger på, følger med — og holder op igen.
|
||||||
|
//
|
||||||
|
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
|
||||||
|
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
|
||||||
|
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
|
||||||
|
// må følge med, og vejen tilbage.
|
||||||
|
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
|
||||||
|
deep := owner.createNote(t, "# Leveringstider\nfire dage")
|
||||||
|
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
|
||||||
|
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
|
||||||
|
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
|
||||||
|
|
||||||
|
reachable := func(id string) bool {
|
||||||
|
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("share: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
|
||||||
|
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
|
||||||
|
// til at overskue.
|
||||||
|
follows, _ := out["follows"].([]any)
|
||||||
|
if len(follows) != 2 {
|
||||||
|
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reachable(linked) {
|
||||||
|
t.Error("noten, den delte note peger på, fulgte ikke med")
|
||||||
|
}
|
||||||
|
if !reachable(deep) {
|
||||||
|
t.Error("kæden stoppede efter ét led")
|
||||||
|
}
|
||||||
|
if reachable(apart) {
|
||||||
|
t.Error("en note, ingen peger på, blev delt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
|
||||||
|
// samme sted hen for begge.
|
||||||
|
senere := owner.createNote(t, "# Rabatter\nti procent")
|
||||||
|
if reachable(senere) {
|
||||||
|
t.Fatal("en helt ny note var delt, før noget pegede på den")
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("kunne ikke skrive videre i den delte note")
|
||||||
|
}
|
||||||
|
if !reachable(senere) {
|
||||||
|
t.Error("et link skrevet efter delingen fulgte ikke med")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
|
||||||
|
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("unshare: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if reachable(linked) || reachable(deep) || reachable(senere) {
|
||||||
|
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
|
||||||
|
//
|
||||||
|
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
|
||||||
|
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
|
||||||
|
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
|
||||||
|
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
|
||||||
|
noteID := owner.createNote(t, "# Fælles\nintet link her")
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("share as editor failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hun skriver linket selv, i den note hun har fået lov at rette i.
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("medredaktøren kunne ikke skrive")
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
|
||||||
|
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type notificationJSON struct {
|
|||||||
ActorName string `json:"actor_name,omitempty"`
|
ActorName string `json:"actor_name,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
NoteID string `json:"note_id,omitempty"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
for _, n := range list {
|
for _, n := range list {
|
||||||
out = append(out, notificationJSON{
|
out = append(out, notificationJSON{
|
||||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||||
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
||||||
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||||
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Detached from the request: a push round trip to Google or Mozilla has no
|
// Detached from the request: a push round trip to Google or Mozilla has no
|
||||||
@@ -102,6 +104,66 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyAssigned tells somebody a task is now theirs.
|
||||||
|
//
|
||||||
|
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
|
||||||
|
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
|
||||||
|
// hvad de lige har gjort.
|
||||||
|
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
|
||||||
|
if t == nil || t.AssigneeID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
|
||||||
|
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
|
// it — folded into one line per note while it is unread.
|
||||||
|
//
|
||||||
|
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
||||||
|
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
||||||
|
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
||||||
|
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
|
||||||
|
if n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := actor.Name + " rettede en note"
|
||||||
|
body := n.Title
|
||||||
|
for _, userID := range audience {
|
||||||
|
if userID == actor.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Fold first, and only write a row when there was nothing to fold into.
|
||||||
|
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
|
||||||
|
// hvor klokken siger to.
|
||||||
|
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("fold note notification", "err", err, "note", n.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if folded {
|
||||||
|
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
|
||||||
|
// op og siger nu et andet navn. Uden det her opdager en åben fane først
|
||||||
|
// den sammenlagte rettelse ved næste genindlæsning.
|
||||||
|
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||||
|
Kind: "note.changed", Title: title, Body: body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||||
// which has the schedule but not the keys.
|
// which has the schedule but not the keys.
|
||||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||||
@@ -148,15 +210,32 @@ 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 = "/"
|
||||||
|
}
|
||||||
|
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
|
||||||
|
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
|
||||||
|
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
|
||||||
|
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
|
||||||
|
// stadig en besked.
|
||||||
|
unread, err := s.db.UnreadNotificationCount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("unread count for push", "err", err)
|
||||||
|
}
|
||||||
|
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
|
||||||
|
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 +336,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,196 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifications reads somebody's bell.
|
||||||
|
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
|
||||||
|
t.Helper()
|
||||||
|
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list notifications: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
list, _ := out["notifications"].([]any)
|
||||||
|
unread, _ := out["unread"].(float64)
|
||||||
|
return list, unread
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(t *testing.T, list []any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("ingen beskeder")
|
||||||
|
}
|
||||||
|
n, _ := list[0].(map[string]any)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
|
||||||
|
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||||
|
projectID, _ := project["id"].(string)
|
||||||
|
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
|
||||||
|
// medlem med det samme.
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("invitér: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, task := owner.do(t, "POST", "/api/v1/tasks",
|
||||||
|
map[string]any{"project_id": projectID, "content": "ring til revisor"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("opret opgave: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
taskID, _ := task["id"].(string)
|
||||||
|
|
||||||
|
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
|
||||||
|
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
|
||||||
|
before, _ := notifications(t, other)
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("tildel: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, _ := notifications(t, other)
|
||||||
|
if len(list) != len(before)+1 {
|
||||||
|
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
|
||||||
|
}
|
||||||
|
n := firstOf(t, list)
|
||||||
|
if n["kind"] != "assigned" {
|
||||||
|
t.Errorf("kind = %v, want assigned", n["kind"])
|
||||||
|
}
|
||||||
|
if n["task_id"] != taskID {
|
||||||
|
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
|
||||||
|
}
|
||||||
|
if n["actor_name"] != "Kristian" {
|
||||||
|
t.Errorf("actor_name = %v", n["actor_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
|
||||||
|
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
|
||||||
|
// ændring, gør — og så bliver klokken ubrugelig på en dag.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ret: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if now, _ := notifications(t, other); len(now) != len(before)+1 {
|
||||||
|
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den, der tildeler sig selv, får ingenting.
|
||||||
|
me := userID(t, owner, "kristian@example.dk")
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
|
||||||
|
// så længe den ikke er læst.
|
||||||
|
func TestEditingASharedNoteNotifiesTheOthers(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, "# Aftale om levering\nfirst")
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("del noten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren skriver. Sofie skal høre det.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
list, unread := notifications(t, other)
|
||||||
|
if unread != 1 || len(list) != 1 {
|
||||||
|
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
||||||
|
}
|
||||||
|
n := firstOf(t, list)
|
||||||
|
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
||||||
|
t.Errorf("besked = %v", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
|
||||||
|
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
|
||||||
|
// ens linjer i klokken.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten igen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 {
|
||||||
|
t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
|
||||||
|
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatal("markér som læst")
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten efter læsningen")
|
||||||
|
}
|
||||||
|
if list, unread := notifications(t, other); len(list) != 2 || unread != 1 {
|
||||||
|
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 2 og 1", len(list), unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den anden vej: Sofie skriver, ejeren hører det.
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("Sofie retter")
|
||||||
|
}
|
||||||
|
list, _ = notifications(t, owner)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
|
||||||
|
}
|
||||||
|
if firstOf(t, list)["actor_name"] != "Sofie" {
|
||||||
|
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("stjernemarkér")
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, other); len(list) != 2 {
|
||||||
|
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En note, ingen deler, er ingen at give besked.
|
||||||
|
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Min egen\nintet")
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, other); len(list) != 0 {
|
||||||
|
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.Labels = req.Labels
|
t.Labels = req.Labels
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||||
|
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
|
||||||
|
// ind i "det her er dit nu" giver besked og den anden ikke.
|
||||||
|
s.notifyAssigned(r, t)
|
||||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
|
||||||
|
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
|
||||||
|
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
|
||||||
|
// det hver gang nogen retter en overskrift.
|
||||||
|
wasAssigned := ""
|
||||||
|
if req.AssigneeID != nil {
|
||||||
|
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
||||||
|
wasAssigned = before.AssigneeID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u := store.TaskUpdate{
|
u := store.TaskUpdate{
|
||||||
Description: req.Description, Priority: req.Priority,
|
Description: req.Description, Priority: req.Priority,
|
||||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||||
@@ -320,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||||
|
s.notifyAssigned(r, t)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,3 +720,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") {
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type Payload struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
|
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
|
||||||
|
//
|
||||||
|
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
|
||||||
|
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
|
||||||
|
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
|
||||||
|
// er serverens, og serveren ved det i forvejen.
|
||||||
|
Unread int `json:"unread,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var b64 = base64.RawURLEncoding
|
var b64 = base64.RawURLEncoding
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/ecdh"
|
"crypto/ecdh"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
|
|||||||
}
|
}
|
||||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
|
||||||
|
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
|
||||||
|
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
|
||||||
|
// ikke lykkedes.
|
||||||
|
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
|
||||||
|
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(with), `"unread":3`) {
|
||||||
|
t.Errorf("payload = %s, want the unread count in it", with)
|
||||||
|
}
|
||||||
|
|
||||||
|
without, err := json.Marshal(Payload{Title: "hej"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(without), "unread") {
|
||||||
|
t.Errorf("payload = %s, want no count when there is none to send", without)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
|
-- De noter, en delt note peger på, følger med delingen.
|
||||||
|
--
|
||||||
|
-- Et `[[link]]` i en note, der er delt, pegede indtil nu på noget, modtageren
|
||||||
|
-- ikke havde fået. Linket stod der, kunne ses, og førte ingen steder hen — meldt
|
||||||
|
-- af den første, der prøvede at læse en delt note, og med rette: den, der deler
|
||||||
|
-- en note, deler det, noten *siger*, og et link er en del af det, den siger.
|
||||||
|
--
|
||||||
|
-- Rækken er den samme som en almindelig deling. `linked` er den ene forskel: den
|
||||||
|
-- siger, at rækken ikke er valgt af nogen, men udledt af, hvad noten peger på.
|
||||||
|
-- Det gør to ting muligt, som en boolsk værdi ellers sjældent gør sig fortjent
|
||||||
|
-- til. Fladen kan sige *hvorfor* en person har den note — "den fulgte med" er en
|
||||||
|
-- anden sætning end "du delte den". Og udledningen kan regnes forfra hver gang:
|
||||||
|
-- de udledte rækker må slettes og skrives igen efter hver ændring, mens de
|
||||||
|
-- valgte aldrig røres. Uden skellet ville en oprydning enten lade forældede
|
||||||
|
-- delinger blive stående eller fjerne dem, ejeren selv havde sat.
|
||||||
|
--
|
||||||
|
-- Kun ejerens egne noter følger med. Et link peger på en *titel*, og titler kan
|
||||||
|
-- skrives af enhver, der må rette i noten — så hvis et link kunne hente hvad som
|
||||||
|
-- helst, ville en medredaktør kunne skrive `[[Bankoplysninger]]` og læse med.
|
||||||
|
-- Opslaget sker derfor kun blandt de noter, ejeren selv ejer, og deling af dem
|
||||||
|
-- har ejeren allerede ret til.
|
||||||
|
ALTER TABLE note_shares ADD COLUMN linked INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- En besked kan handle om en note.
|
||||||
|
--
|
||||||
|
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
|
||||||
|
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
|
||||||
|
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
|
||||||
|
-- for at opdage det.
|
||||||
|
--
|
||||||
|
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
|
||||||
|
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
|
||||||
|
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
|
||||||
|
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
|
||||||
|
-- — kan et generisk par slet ikke have.
|
||||||
|
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
|
||||||
|
-- er nøjagtig det: min, ulæst, om denne note.
|
||||||
|
CREATE INDEX notifications_unread_by_note
|
||||||
|
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,3 +133,280 @@ func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []stri
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- de noter, en delt note peger på ---------------------------------------------
|
||||||
|
|
||||||
|
// LinkedShare is one note that followed a share: which note, what it is called,
|
||||||
|
// and which of the owner's shared notes brought it. The panel shows all three —
|
||||||
|
// "Prisliste came along with Aftale om levering" is a sentence somebody can act
|
||||||
|
// on, where a bare id is not.
|
||||||
|
type LinkedShare struct {
|
||||||
|
NoteID string `json:"note_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ViaID string `json:"via_id"`
|
||||||
|
Via string `json:"via"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hvor langt en deling må brede sig.
|
||||||
|
//
|
||||||
|
// En note, der peger på en note, der peger på en note, er stadig én ting at læse,
|
||||||
|
// og den skal følge med. Men noter kan pege i ring og i vifte, og "del denne ene
|
||||||
|
// note" må ikke kunne blive til fire hundrede. Loftet er ikke en optimering: det
|
||||||
|
// er den grænse, hvor en deling holder op med at være noget, ejeren kan overskue.
|
||||||
|
const linkedShareLimit = 100
|
||||||
|
|
||||||
|
// SyncLinkedShares regner de udledte delinger forfra for alt, hvad én person ejer.
|
||||||
|
//
|
||||||
|
// Kaldt efter enhver af ejerens egne handlinger, der kan flytte svaret: en deling,
|
||||||
|
// en fjernet deling, og en gemning, der har ændret på det, noten peger på. Regnet
|
||||||
|
// forfra frem for rettet til, fordi det er den samme kode hver gang og derfor det
|
||||||
|
// samme resultat hver gang — en tilføjelse og en fjernelse er ikke to veje gennem
|
||||||
|
// koden, men det samme kald med et andet udgangspunkt.
|
||||||
|
//
|
||||||
|
// Kun *direkte* delinger er udgangspunkt. En note i et delt projekt er delt af
|
||||||
|
// projektet, og projektets folk kan se projektets noter; det er en anden vej ind,
|
||||||
|
// med sine egne regler, og den blander sig ikke her.
|
||||||
|
//
|
||||||
|
// Returnerer, hvad der nu følger med, så kalderen kan sige det. Det er meningen at
|
||||||
|
// det bliver sagt: en deling, der stille tager tre noter mere med, er ikke til at
|
||||||
|
// overskue, uanset hvor rigtigt den gør det.
|
||||||
|
func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedShare, error) {
|
||||||
|
if ownerID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Har personen ikke delt noget, er der intet at regne — og det er de fleste,
|
||||||
|
// det meste af tiden. Uden det her ville hvert eneste klik i notelisten åbne
|
||||||
|
// hver eneste note for at finde ud af, at svaret er tomt: fladen spørger til
|
||||||
|
// delingerne, hver gang man vælger en note, man selv ejer.
|
||||||
|
//
|
||||||
|
// Ét spørgsmål dækker begge halvdele. Er der ingen rækker overhovedet, er der
|
||||||
|
// hverken en deling at brede ud eller en udledt række at rydde op efter.
|
||||||
|
var anyShare int
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM note_shares s
|
||||||
|
JOIN notes n ON n.id = s.note_id
|
||||||
|
WHERE n.created_by = ?)`, ownerID).Scan(&anyShare); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if anyShare == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejerens egne noter, åbnet. Titlerne er forseglet i basen, og et link peger på
|
||||||
|
// en titel — så der er ikke noget opslag at lave i SQL. Målt andetsteds i denne
|
||||||
|
// fil: tolv hundrede noter koster nogle og tredive millisekunder at åbne, og det
|
||||||
|
// her kører kun, når ejeren selv har gjort noget.
|
||||||
|
mine, err := db.notesWhere(ctx, `WHERE created_by = ? AND deleted_at IS NULL`, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byTitle := map[string]string{}
|
||||||
|
title := map[string]string{}
|
||||||
|
for _, n := range mine {
|
||||||
|
title[n.ID] = n.Title
|
||||||
|
key := strings.ToLower(strings.TrimSpace(n.Title))
|
||||||
|
// Den første vinder, og listen kommer sorteret med den senest rørte først:
|
||||||
|
// to noter med samme titel er sjældent, og når det sker, er den, man skrev
|
||||||
|
// sidst, den, man mente.
|
||||||
|
if key != "" {
|
||||||
|
if _, taken := byTitle[key]; !taken {
|
||||||
|
byTitle[key] = n.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hvad hver note peger på, i ét spørgsmål frem for ét pr. note.
|
||||||
|
links := map[string][]string{}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT l.note_id, l.target_id
|
||||||
|
FROM note_links l
|
||||||
|
JOIN notes n ON n.id = l.note_id
|
||||||
|
WHERE l.kind = 'note' AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var from, target string
|
||||||
|
if err := rows.Scan(&from, &target); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
links[from] = append(links[from], target)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// De delinger, ejeren selv har sat.
|
||||||
|
type grant struct{ noteID, userID string }
|
||||||
|
direct := map[grant]Role{}
|
||||||
|
rows, err = db.QueryContext(ctx, `
|
||||||
|
SELECT s.note_id, s.user_id, s.role
|
||||||
|
FROM note_shares s
|
||||||
|
JOIN notes n ON n.id = s.note_id
|
||||||
|
WHERE s.linked = 0 AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var g grant
|
||||||
|
var role Role
|
||||||
|
if err := rows.Scan(&g.noteID, &g.userID, &role); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if role.Valid() {
|
||||||
|
direct[g] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vandringen. Pr. person, fordi det er personen, delingen handler om: to
|
||||||
|
// forskellige mennesker kan have to forskellige noter og derfor to forskellige
|
||||||
|
// vifter ud fra dem.
|
||||||
|
wanted := map[grant]LinkedShare{}
|
||||||
|
perUser := map[string][]grant{}
|
||||||
|
for g := range direct {
|
||||||
|
perUser[g.userID] = append(perUser[g.userID], g)
|
||||||
|
}
|
||||||
|
for userID, roots := range perUser {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
type step struct {
|
||||||
|
noteID string
|
||||||
|
via string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medredaktør først, læser bagefter, og hver runde tømt for sig.
|
||||||
|
//
|
||||||
|
// En note kan nås fra to delte noter med hver sin rolle, og så skal den
|
||||||
|
// stærkeste gælde. Én kø med det hele i ville afgøre det på afstand frem for
|
||||||
|
// på rolle: et barnebarn af en redigerbar note ville tabe til et barn af en
|
||||||
|
// læsbar, fordi det stod længere bagude i køen. To runder gør reglen til
|
||||||
|
// rækkefølgen — er noten allerede nået som redigerbar, ser den anden runde
|
||||||
|
// den ikke.
|
||||||
|
for _, role := range []Role{RoleEditor, RoleViewer} {
|
||||||
|
var queue []step
|
||||||
|
for _, g := range roots {
|
||||||
|
if direct[g] != role || seen[g.noteID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[g.noteID] = true
|
||||||
|
queue = append(queue, step{noteID: g.noteID, via: g.noteID})
|
||||||
|
}
|
||||||
|
for len(queue) > 0 && len(seen) <= linkedShareLimit {
|
||||||
|
cur := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, target := range links[cur.noteID] {
|
||||||
|
id, ok := byTitle[strings.ToLower(strings.TrimSpace(target))]
|
||||||
|
// Et link, der ikke rammer en af ejerens egne noter, er enten
|
||||||
|
// dødt eller peger på en andens note. Ingen af delene er ejerens
|
||||||
|
// at dele.
|
||||||
|
if !ok || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
// Rollen arves fra den note, man kom fra. En medredaktør på en
|
||||||
|
// note bliver medredaktør på det, den peger på: teksten er én
|
||||||
|
// ting at arbejde i, og et link midt i den er ikke en grænse.
|
||||||
|
if _, isDirect := direct[grant{noteID: id, userID: userID}]; !isDirect {
|
||||||
|
wanted[grant{noteID: id, userID: userID}] = LinkedShare{
|
||||||
|
NoteID: id,
|
||||||
|
Title: title[id],
|
||||||
|
ViaID: cur.via,
|
||||||
|
Via: title[cur.via],
|
||||||
|
Role: role,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue = append(queue, step{noteID: id, via: cur.via})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skrevet i én transaktion: mellem sletningen og skrivningen står basen med
|
||||||
|
// færre delinger, end den skal, og det er ikke et vindue nogen skal kunne læse i.
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM note_shares
|
||||||
|
WHERE linked = 1
|
||||||
|
AND note_id IN (SELECT id FROM notes WHERE created_by = ?)`, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for g, l := range wanted {
|
||||||
|
// DO NOTHING ved sammenstød: en direkte deling på den samme note er
|
||||||
|
// ejerens eget valg og har forrang. Den kan ikke nå herned — den er
|
||||||
|
// sorteret fra ovenfor — men reglen skal stå dér, hvor rækken skrives,
|
||||||
|
// ikke kun dér, hvor den udregnes.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at, linked)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)
|
||||||
|
ON CONFLICT (note_id, user_id) DO NOTHING`,
|
||||||
|
g.noteID, g.userID, l.Role, ownerID, time.Now().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]LinkedShare, 0, len(wanted))
|
||||||
|
for _, l := range wanted {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].UserID != out[j].UserID {
|
||||||
|
return out[i].UserID < out[j].UserID
|
||||||
|
}
|
||||||
|
return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteAudience is everybody who can see a note: its owner, the people it is shared
|
||||||
|
// with — chosen or followed along a link — and the members of the project it is
|
||||||
|
// filed in.
|
||||||
|
//
|
||||||
|
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
|
||||||
|
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
|
||||||
|
// delt direkte, og to beskeder om den samme rettelse er én for mange.
|
||||||
|
//
|
||||||
|
// The note's own row is joined rather than passed in, so a caller cannot ask about
|
||||||
|
// a note that has been deleted and get an audience for it anyway.
|
||||||
|
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT user_id FROM note_shares WHERE note_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
|
||||||
|
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
|
||||||
|
WHERE n.id = ? AND n.deleted_at IS NULL`,
|
||||||
|
noteID, noteID, noteID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Notification struct {
|
|||||||
ActorName string
|
ActorName string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
TaskID string
|
TaskID string
|
||||||
|
NoteID string
|
||||||
Kind string
|
Kind string
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
@@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
|||||||
n.CreatedAt = time.Now().UTC()
|
n.CreatedAt = time.Now().UTC()
|
||||||
|
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
|
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
|
||||||
title, body, created_at)
|
title, body, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
||||||
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BumpUnreadNoteNotification folds a second change to the same note into the first,
|
||||||
|
// and says whether it found one to fold into.
|
||||||
|
//
|
||||||
|
// A note is saved on a typing pause, so writing a row per save would turn one
|
||||||
|
// person editing one note for five minutes into a column of twenty identical
|
||||||
|
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
|
||||||
|
// der rørte den sidst.
|
||||||
|
//
|
||||||
|
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
|
||||||
|
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
|
||||||
|
// gentagelse af noget, man allerede har set.
|
||||||
|
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE notifications
|
||||||
|
SET created_at = ?, actor_id = ?, title = ?, body = ?
|
||||||
|
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
|
||||||
|
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
||||||
n.kind, n.title, n.body, n.read_at, n.created_at
|
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
WHERE n.user_id = ?
|
WHERE n.user_id = ?
|
||||||
@@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
|||||||
out := []Notification{}
|
out := []Notification{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Notification
|
var n Notification
|
||||||
var actorID, projectID, taskID sql.NullString
|
var actorID, projectID, taskID, noteID sql.NullString
|
||||||
var readAt sql.NullInt64
|
var readAt sql.NullInt64
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
||||||
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
&taskID, ¬eID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
|
n.ActorID, n.ProjectID = actorID.String, projectID.String
|
||||||
|
n.TaskID, n.NoteID = taskID.String, noteID.String
|
||||||
if readAt.Valid {
|
if readAt.Valid {
|
||||||
v := time.Unix(readAt.Int64, 0).UTC()
|
v := time.Unix(readAt.Int64, 0).UTC()
|
||||||
n.ReadAt = &v
|
n.ReadAt = &v
|
||||||
@@ -204,6 +230,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()
|
||||||
}
|
}
|
||||||
@@ -473,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)
|
||||||
@@ -485,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
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -1225,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');
|
||||||
@@ -1376,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();
|
||||||
@@ -1829,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();
|
||||||
@@ -2440,7 +2754,8 @@ 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.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
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();
|
||||||
|
|
||||||
@@ -2451,6 +2766,7 @@ 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.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
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();
|
||||||
|
|
||||||
@@ -2487,10 +2803,12 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
|||||||
await page.keyboard.type('Ferieplan for hele holdet');
|
await page.keyboard.type('Ferieplan for hele holdet');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
const share = page.locator('.people-share');
|
// 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 expect(share).toBeVisible();
|
||||||
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||||
await page.getByRole('button', { name: 'Del', exact: true }).click();
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
|
||||||
// She is on the note now, with the role she was given.
|
// She is on the note now, with the role she was given.
|
||||||
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||||
@@ -2520,6 +2838,238 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note-links i en delt note.
|
||||||
|
*
|
||||||
|
* Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting.
|
||||||
|
* Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen
|
||||||
|
* ikke passede — og i en delt note er det første resultat næsten altid noten selv,
|
||||||
|
* fordi den nævner den titel, den linker til. Så åbnede linket den note, man
|
||||||
|
* allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt
|
||||||
|
* noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten
|
||||||
|
* findes, den er bare ikke delt med.
|
||||||
|
*/
|
||||||
|
test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({
|
||||||
|
browser,
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.goto('/indstillinger/brugere');
|
||||||
|
await page.getByLabel('E-mailadresse').fill('link@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||||
|
const link = await page.locator('.link-out').textContent();
|
||||||
|
|
||||||
|
const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const gæst = await gæstCtx.newPage();
|
||||||
|
await gæst.goto(link);
|
||||||
|
await gæst.getByLabel('Navn', { exact: true }).fill('Linnea');
|
||||||
|
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await gæst.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
const noterURL = new URL('/noter', link).href;
|
||||||
|
|
||||||
|
// Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den
|
||||||
|
// delte peger på den anden.
|
||||||
|
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('Kristians prisblad');
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||||
|
await page.keyboard.type('Faelles aftale om afhentning\n');
|
||||||
|
await page.keyboard.type('[[Kristians prisblad]]');
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
|
// Gæsten har en note af sin egen, som hun kan pege på.
|
||||||
|
await gæst.goto(noterURL);
|
||||||
|
await gæst.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await gæst.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||||
|
await gæst.keyboard.type('Linneas egne maal');
|
||||||
|
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
|
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
const share = page.locator('.sharepanel');
|
||||||
|
await expect(share).toBeVisible();
|
||||||
|
await share.locator('.addshare select').first().selectOption({ label: 'Linnea' });
|
||||||
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.locator('select').selectOption('editor');
|
||||||
|
|
||||||
|
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
|
||||||
|
await gæst.goto(noterURL);
|
||||||
|
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||||
|
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(body).toContainText('Kristians prisblad');
|
||||||
|
await body.click();
|
||||||
|
await gæst.keyboard.press('Control+End');
|
||||||
|
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
||||||
|
await body.blur();
|
||||||
|
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
||||||
|
|
||||||
|
await gæst.goto(noterURL);
|
||||||
|
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||||
|
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
|
||||||
|
// Hendes eget link fører derhen, det siger.
|
||||||
|
await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click();
|
||||||
|
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||||
|
'Linneas egne maal'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
|
||||||
|
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
|
||||||
|
// allerede stod i — hvilket ser ud som ingenting.
|
||||||
|
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||||
|
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
|
||||||
|
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
|
||||||
|
// Stadig i den delte note: linket flyttede hende ingen steder hen.
|
||||||
|
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||||
|
'Faelles aftale om afhentning'
|
||||||
|
);
|
||||||
|
|
||||||
|
await gæstCtx.close();
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delingen tager de noter med, noten peger på.
|
||||||
|
*
|
||||||
|
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
|
||||||
|
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
|
||||||
|
* det, den *siger*, og et link er en del af det.
|
||||||
|
*/
|
||||||
|
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.goto('/indstillinger/brugere');
|
||||||
|
await page.getByLabel('E-mailadresse').fill('med@example.dk');
|
||||||
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||||
|
const link = await page.locator('.link-out').textContent();
|
||||||
|
|
||||||
|
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const gæst = await ctx.newPage();
|
||||||
|
await gæst.goto(link);
|
||||||
|
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
|
||||||
|
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
|
await gæst.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
const noterURL = new URL('/noter', link).href;
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
|
||||||
|
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
|
||||||
|
for (const text of [
|
||||||
|
'Leveringstider for pakker',
|
||||||
|
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
|
||||||
|
'Bankoplysninger som ingen skal se',
|
||||||
|
'Aftale om levering\nse [[Prisliste i kroner]]'
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
const share = page.locator('.sharepanel');
|
||||||
|
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
|
||||||
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
|
||||||
|
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
|
||||||
|
await expect(page.locator('.toast')).toContainText('de 2 noter');
|
||||||
|
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
|
||||||
|
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
|
||||||
|
|
||||||
|
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
|
||||||
|
await gæst.goto(noterURL);
|
||||||
|
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||||
|
await gæst
|
||||||
|
.getByRole('textbox', { name: 'Notens tekst' })
|
||||||
|
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
|
||||||
|
.click();
|
||||||
|
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(hos).toContainText('Prisliste i kroner');
|
||||||
|
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
|
||||||
|
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||||
|
'Leveringstider for pakker'
|
||||||
|
);
|
||||||
|
// Men kun det, noten peger på. Resten er stadig ejerens alene.
|
||||||
|
await expect(
|
||||||
|
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
|
||||||
|
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
|
||||||
|
// knap med den samme titel på.
|
||||||
|
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
|
||||||
|
'følger med'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Taget tilbage følger den med tilbage.
|
||||||
|
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
await page
|
||||||
|
.locator('.sharepanel .sharelist li')
|
||||||
|
.filter({ hasText: 'Mikkel' })
|
||||||
|
.getByRole('button', { name: 'Fjern adgang' })
|
||||||
|
.click();
|
||||||
|
await gæst.goto(noterURL);
|
||||||
|
await expect(
|
||||||
|
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `</>` er kode, ikke kilde.
|
||||||
|
*
|
||||||
|
* Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet,
|
||||||
|
* og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet
|
||||||
|
* program, så det er knappen, der skal give efter, ikke brugeren.
|
||||||
|
*/
|
||||||
|
test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const body = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await body.click();
|
||||||
|
await page.keyboard.type('Kodenote\n');
|
||||||
|
await page.keyboard.type('kør kommandoen npm først');
|
||||||
|
|
||||||
|
// Markér ét ord og gør det til kode.
|
||||||
|
await page.keyboard.press('Shift+Home');
|
||||||
|
await page.keyboard.press('Shift+ArrowRight');
|
||||||
|
await page.getByRole('button', { name: 'Kode', exact: true }).click();
|
||||||
|
await expect(body.locator('code')).toHaveCount(1);
|
||||||
|
|
||||||
|
// Noten er stadig rig tekst — kildevisningen er ikke slået til.
|
||||||
|
await expect(page.locator('textarea.source')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||||
|
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||||
|
await body.blur();
|
||||||
|
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||||
|
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||||
|
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||||
|
|
||||||
|
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
|
||||||
}) => {
|
}) => {
|
||||||
@@ -2557,6 +3107,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');
|
||||||
@@ -2981,7 +3572,8 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
|||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||||
// uden at nogen har valgt det bagefter.
|
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||||
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||||
expect(shared).toContain('Tagprojekt');
|
expect(shared).toContain('Tagprojekt');
|
||||||
@@ -3049,6 +3641,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']);
|
||||||
@@ -3122,7 +3804,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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 58 KiB |
@@ -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 }),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc.
|
||||||
|
*
|
||||||
|
* Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det
|
||||||
|
* halve af pointen med at få besked er at få den, mens man laver noget andet, og
|
||||||
|
* det eneste sted, appen kan sige noget dér, er på sit eget ikon.
|
||||||
|
*
|
||||||
|
* Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved
|
||||||
|
* hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`-
|
||||||
|
* lytteren i service-worker.js, som får tallet med i beskeden.
|
||||||
|
*
|
||||||
|
* Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der
|
||||||
|
* lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en
|
||||||
|
* eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør
|
||||||
|
* ingenting. Firefox kan ikke endnu.
|
||||||
|
*/
|
||||||
|
export function supported() {
|
||||||
|
return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sætter mærket til `n`, eller fjerner det ved nul.
|
||||||
|
*
|
||||||
|
* Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken,
|
||||||
|
* der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren
|
||||||
|
* siger nej.
|
||||||
|
*/
|
||||||
|
export function setBadge(n) {
|
||||||
|
if (!supported()) return;
|
||||||
|
const count = Number(n) || 0;
|
||||||
|
try {
|
||||||
|
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
|
||||||
|
done?.catch?.(() => {});
|
||||||
|
} catch {
|
||||||
|
// Nogle browsere kaster i stedet for at afvise. Det er det samme svar.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */
|
||||||
|
export function clearBadge() {
|
||||||
|
setBadge(0);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
let { note, onchange, onsave } = $props();
|
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -120,11 +120,204 @@
|
|||||||
|
|
||||||
function setStyle(style) {
|
function setStyle(style) {
|
||||||
stylesOpen = false;
|
stylesOpen = false;
|
||||||
|
editor?.focus();
|
||||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||||
if (style.key === 'numbered') return apply('insertOrderedList');
|
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||||
|
|
||||||
|
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
|
||||||
|
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
|
||||||
|
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||||
|
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||||
|
// hand, replacing the element rather than reformatting inside it.
|
||||||
|
const block = currentBlock();
|
||||||
|
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
||||||
|
replaceBlock(block, style.tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
apply('formatBlock', style.tag);
|
apply('formatBlock', style.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a block element with one of another tag, keeping its contents and the
|
||||||
|
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||||
|
* <pre>, which it either nests or ignores.
|
||||||
|
*/
|
||||||
|
function replaceBlock(block, tag) {
|
||||||
|
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||||
|
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||||
|
// on the next render for any <pre> that still needs one.
|
||||||
|
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
|
||||||
|
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
while (block.firstChild) el.appendChild(block.firstChild);
|
||||||
|
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||||
|
block.replaceWith(el);
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline kode — `sådan her`.
|
||||||
|
*
|
||||||
|
* Den knap fandtes ikke, og `</>`, som i ethvert andet program betyder netop
|
||||||
|
* kode, sad på kildevisningen med teksten "Vis som kode" på. Så markerede man et
|
||||||
|
* ord og trykkede på den, og hele noten skiftede til Markdown. Det blev meldt
|
||||||
|
* som en fejl, og det var det også: knappen sagde ét og gjorde et andet. Nu gør
|
||||||
|
* `</>` det, tegnet siger, og kilden har fået sit eget mærke.
|
||||||
|
*
|
||||||
|
* Skrevet i hånden, fordi execCommand ikke har en kommando for kode. Markeringen
|
||||||
|
* læses som ren tekst — det er hele meningen med kode, at det ikke er formateret
|
||||||
|
* — og lægges tilbage inde i et <code>, som konverteringen allerede kender
|
||||||
|
* begge veje.
|
||||||
|
*/
|
||||||
|
function codeAround() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
let node = selection?.anchorNode;
|
||||||
|
while (node && node !== editor) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'CODE') return node;
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markerer ordet, markøren står i. Bruges når der ikke er markeret noget. */
|
||||||
|
function selectWordAtCaret() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const node = selection?.anchorNode;
|
||||||
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
let start = selection.anchorOffset;
|
||||||
|
let end = start;
|
||||||
|
while (start > 0 && !/\s/.test(text[start - 1])) start--;
|
||||||
|
while (end < text.length && !/\s/.test(text[end])) end++;
|
||||||
|
if (start === end) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectContents(node) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCode() {
|
||||||
|
editor?.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection?.rangeCount) return;
|
||||||
|
|
||||||
|
// Inde i en kodeblok er alting allerede kode; en <code> derinde kan hverken
|
||||||
|
// ses eller skrives ned, for en hegnet blok er tekst hele vejen igennem.
|
||||||
|
const block = currentBlock();
|
||||||
|
if (block?.tagName === 'PRE') return;
|
||||||
|
|
||||||
|
const existing = codeAround();
|
||||||
|
if (existing) {
|
||||||
|
// Ud igen: teksten bliver stående, mærkerne forsvinder. Markeringen lægges
|
||||||
|
// om den, så et tryk mere sætter den tilbage — knappen er en kontakt.
|
||||||
|
const text = document.createTextNode(existing.textContent ?? '');
|
||||||
|
existing.replaceWith(text);
|
||||||
|
selectContents(text);
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uden en markering er det ordet, markøren står i. Det er det, ⌘B gør i et
|
||||||
|
// tekstprogram, og bedre end et tomt <code>, der ikke kan ses og ikke kan
|
||||||
|
// skrives ned.
|
||||||
|
if (selection.isCollapsed) selectWordAtCaret();
|
||||||
|
const live = window.getSelection();
|
||||||
|
const text = live.toString();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// Bygget som en node frem for med `insertHTML`.
|
||||||
|
//
|
||||||
|
// Chromium renser den HTML, den får dér, gennem den samme vej som en
|
||||||
|
// indsætning: `<code>` kom ud som et `<span>` med skrifttypen og farven
|
||||||
|
// skrevet ind i sig, og et span er ikke noget, htmlToMarkdown kan skrive ned
|
||||||
|
// som kode. Målt — mærket forsvandt hver gang, og noten så rigtig ud, indtil
|
||||||
|
// den blev gemt.
|
||||||
|
const range = live.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const code = document.createElement('code');
|
||||||
|
code.textContent = text;
|
||||||
|
range.insertNode(code);
|
||||||
|
|
||||||
|
// Markøren efter mærket, ikke inde i det: ellers bliver det næste ord, man
|
||||||
|
// skriver, også til kode.
|
||||||
|
const after = document.createRange();
|
||||||
|
after.setStartAfter(code);
|
||||||
|
after.collapse(true);
|
||||||
|
live.removeAllRanges();
|
||||||
|
live.addRange(after);
|
||||||
|
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps out of a code block on Enter.
|
||||||
|
*
|
||||||
|
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
|
||||||
|
* staying inside it — which means a monospace last line makes every line after
|
||||||
|
* it monospace too, with no way back to body by typing. Plain Enter now leaves
|
||||||
|
* the block: the tail after the caret becomes a paragraph below the <pre>, and
|
||||||
|
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
|
||||||
|
* break still writes multi-line code inside the block.
|
||||||
|
*/
|
||||||
|
function exitPre(pre) {
|
||||||
|
pre.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
// Everything from the caret to the end of the block leaves with the new line.
|
||||||
|
const tail = document.createRange();
|
||||||
|
tail.setStart(range.endContainer, range.endOffset);
|
||||||
|
tail.setEnd(pre, pre.childNodes.length);
|
||||||
|
const frag = tail.extractContents();
|
||||||
|
|
||||||
|
// A break at the head of what we carried is the empty line return was pressed
|
||||||
|
// on; it should not open the paragraph with a blank row.
|
||||||
|
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
|
||||||
|
|
||||||
|
const p = document.createElement('p');
|
||||||
|
if (frag.childNodes.length) p.appendChild(frag);
|
||||||
|
// The split can leave an empty text node behind, and a block with no element
|
||||||
|
// and no text is not a caret host in Chromium — the next keystroke falls back
|
||||||
|
// into the block above. An empty paragraph needs a <br> to be stood in.
|
||||||
|
if (!p.textContent && !p.querySelector('br, img')) {
|
||||||
|
p.replaceChildren(document.createElement('br'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same break seen from the other side, left dangling in the <pre>.
|
||||||
|
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
|
||||||
|
|
||||||
|
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
|
||||||
|
if (!pre.textContent.trim()) pre.replaceWith(p);
|
||||||
|
else pre.after(p);
|
||||||
|
|
||||||
|
const caret = document.createRange();
|
||||||
|
caret.setStart(p, 0);
|
||||||
|
caret.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(caret);
|
||||||
|
}
|
||||||
|
|
||||||
/** What the caret is standing in, for the toolbar's pressed states. */
|
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||||
function readState() {
|
function readState() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -135,7 +328,11 @@
|
|||||||
underline: document.queryCommandState('underline'),
|
underline: document.queryCommandState('underline'),
|
||||||
strike: document.queryCommandState('strikeThrough'),
|
strike: document.queryCommandState('strikeThrough'),
|
||||||
bullet: document.queryCommandState('insertUnorderedList'),
|
bullet: document.queryCommandState('insertUnorderedList'),
|
||||||
numbered: document.queryCommandState('insertOrderedList')
|
numbered: document.queryCommandState('insertOrderedList'),
|
||||||
|
// Læst af DOM'en og ikke af execCommand: der findes ingen kommando
|
||||||
|
// for kode, hvilket er hele grunden til at knappen er skrevet i
|
||||||
|
// hånden herunder.
|
||||||
|
code: !!codeAround()
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Some browsers throw on queryCommandState with no selection. The toolbar
|
// Some browsers throw on queryCommandState with no selection. The toolbar
|
||||||
@@ -302,9 +499,26 @@
|
|||||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||||
|
|
||||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
|
||||||
if (!match) return null;
|
// A note link, [[, is the same idea as a project tag and gets the same
|
||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
// completion — checked first because it is the longer opener. The term runs
|
||||||
|
// to the caret and is anything up to the closing bracket, which has not been
|
||||||
|
// typed yet.
|
||||||
|
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||||
|
if (note) {
|
||||||
|
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
|
if (project) {
|
||||||
|
return {
|
||||||
|
kind: 'project',
|
||||||
|
node,
|
||||||
|
start: selection.anchorOffset - project[1].length,
|
||||||
|
term: project[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,46 +562,87 @@
|
|||||||
// pixels for hvert bogstav, mens listen snævres ind.
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
// Alle der passer, ikke de seks første.
|
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||||
//
|
|
||||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
|
||||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
|
||||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
|
||||||
// noget — kun at ens eget projekt ikke var der.
|
|
||||||
//
|
|
||||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
|
||||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
|
||||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
|
||||||
const matches = app.projects.filter(
|
|
||||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
matches.sort((a, b) => {
|
|
||||||
const an = a.name.toLowerCase();
|
|
||||||
const bn = b.name.toLowerCase();
|
|
||||||
const ap = an.startsWith(term);
|
|
||||||
const bp = bn.startsWith(term);
|
|
||||||
if (ap !== bp) return ap ? -1 : 1;
|
|
||||||
return an.localeCompare(bn, 'da');
|
|
||||||
});
|
|
||||||
suggestions = matches;
|
|
||||||
chosen = 0;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole name. */
|
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||||
function accept(project) {
|
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||||
|
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||||
|
function projectMatches(term) {
|
||||||
|
return app.projects
|
||||||
|
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||||
|
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||||
|
// current note is left out — a note that links to itself is a loop nobody meant
|
||||||
|
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||||
|
function noteMatches(term) {
|
||||||
|
return notes
|
||||||
|
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||||
|
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rank(a, b, term) {
|
||||||
|
const al = a.toLowerCase();
|
||||||
|
const bl = b.toLowerCase();
|
||||||
|
const ap = al.startsWith(term);
|
||||||
|
const bp = bl.startsWith(term);
|
||||||
|
if (ap !== bp) return ap ? -1 : 1;
|
||||||
|
return al.localeCompare(bl, 'da');
|
||||||
|
}
|
||||||
|
|
||||||
|
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||||
|
|
||||||
|
// A click on a note link follows it; a click anywhere else re-colours the code,
|
||||||
|
// as it did before. The link is contenteditable="false", so the click is a click
|
||||||
|
// and not a caret landing in the middle of a word.
|
||||||
|
function onEditorClick(event) {
|
||||||
|
const link = event.target.closest?.('.notelink');
|
||||||
|
if (link) {
|
||||||
|
event.preventDefault();
|
||||||
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
colourCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) return;
|
if (!partial) return;
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(partial.node, partial.start);
|
|
||||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
// A trailing space, because the next thing typed is a word and not more of
|
if (item.kind === 'note') {
|
||||||
// the tag — and without it the suggestions come straight back.
|
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||||
document.execCommand('insertText', false, project.name + ' ');
|
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||||
|
// become one when the note is next opened. A space after, as its own text,
|
||||||
|
// so the caret leaves the atomic link and the next word is a word.
|
||||||
|
range.setStart(partial.node, partial.start - 2);
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
document.execCommand(
|
||||||
|
'insertHTML',
|
||||||
|
false,
|
||||||
|
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
|
||||||
|
);
|
||||||
|
document.execCommand('insertText', false, ' ');
|
||||||
|
} else {
|
||||||
|
range.setStart(partial.node, partial.start);
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
// A trailing space, or the suggestions come straight back for the tag.
|
||||||
|
document.execCommand('insertText', false, `${item.label} `);
|
||||||
|
}
|
||||||
|
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
}
|
}
|
||||||
@@ -470,6 +725,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
// A code block is the one place the browser keeps the caret inside on
|
||||||
|
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||||
|
// monospace last line traps everything typed after it in monospace.
|
||||||
|
const block = currentBlock();
|
||||||
|
if (block && block.tagName === 'PRE') {
|
||||||
|
event.preventDefault();
|
||||||
|
exitPre(block);
|
||||||
|
changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// After the split, not before it.
|
// After the split, not before it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
keepTitleFirst();
|
keepTitleFirst();
|
||||||
@@ -485,6 +750,9 @@
|
|||||||
if (key === 'u') {
|
if (key === 'u') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
apply('underline');
|
apply('underline');
|
||||||
|
} else if (key === 'e') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleCode();
|
||||||
} else if (key === 'x' && event.shiftKey) {
|
} else if (key === 'x' && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
apply('strikeThrough');
|
apply('strikeThrough');
|
||||||
@@ -632,19 +900,50 @@
|
|||||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||||
<s>S</s>
|
<s>S</s>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- `</>` hører til her, mellem de andre mærker, fordi kode er et mærke som
|
||||||
|
fed og kursiv. Den sad på kildevisningen, og det var en fælde: tegnet
|
||||||
|
betyder kode overalt ellers, så folk markerede et ord og fik hele noten
|
||||||
|
om til Markdown. -->
|
||||||
|
<button
|
||||||
|
class:on={active.code}
|
||||||
|
onclick={toggleCode}
|
||||||
|
title="⌘E"
|
||||||
|
aria-pressed={!!active.code}
|
||||||
|
aria-label={t('notes.code')}
|
||||||
|
>
|
||||||
|
<span class="codemark"></></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<span class="sep" aria-hidden="true"></span>
|
<span class="sep" aria-hidden="true"></span>
|
||||||
|
|
||||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
||||||
anden måde at se det hele på. -->
|
anden måde at se det hele på — og med Markdown-mærket på, som er præcis
|
||||||
|
det, den viser. Et tegn, ingen forveksler med et format. -->
|
||||||
<button
|
<button
|
||||||
class="src"
|
class="src"
|
||||||
class:on={asSource}
|
class:on={asSource}
|
||||||
onclick={toggleSource}
|
onclick={toggleSource}
|
||||||
aria-pressed={asSource}
|
aria-pressed={asSource}
|
||||||
aria-label={t('notes.showSource')}
|
aria-label={t('notes.showSource')}
|
||||||
title={t('notes.showSource')}></></button
|
title={t('notes.showSource')}
|
||||||
>
|
>
|
||||||
|
<svg viewBox="0 0 208 128" aria-hidden="true">
|
||||||
|
<rect
|
||||||
|
x="5"
|
||||||
|
y="5"
|
||||||
|
width="198"
|
||||||
|
height="118"
|
||||||
|
rx="14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="12"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zM155 98l-30-33h20V30h20v35h20z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if asSource}
|
{#if asSource}
|
||||||
@@ -678,7 +977,7 @@
|
|||||||
readState();
|
readState();
|
||||||
readSuggestions();
|
readSuggestions();
|
||||||
}}
|
}}
|
||||||
onclick={colourCode}
|
onclick={onEditorClick}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -698,7 +997,7 @@
|
|||||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||||
>
|
>
|
||||||
{#each suggestions as project, i (project.id)}
|
{#each suggestions as item, i (item.kind + item.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class:on={i === chosen}
|
class:on={i === chosen}
|
||||||
@@ -706,12 +1005,17 @@
|
|||||||
aria-selected={i === chosen}
|
aria-selected={i === chosen}
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
accept(project);
|
accept(item);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
{#if item.kind === 'note'}
|
||||||
></span>
|
<span class="dot note" aria-hidden="true">[[</span>
|
||||||
#{project.name}
|
{item.label}
|
||||||
|
{:else}
|
||||||
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
#{item.label}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -864,6 +1168,18 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A note suggestion wears the brackets that open it where a project wears its
|
||||||
|
colour, so the two kinds are told apart at a glance in the same list. */
|
||||||
|
.suggestions .dot.note {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.suggestions button.on {
|
.suggestions button.on {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -959,11 +1275,24 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.src {
|
/* Kodeknappen bærer sit eget tegn i monospace — `</>` sat med brødskriften
|
||||||
|
ligner en tastefejl frem for et mærke. */
|
||||||
|
.codemark {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src svg {
|
||||||
|
width: 17px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||||
a title that is a title, and body text that is comfortable to read for a page
|
a title that is a title, and body text that is comfortable to read for a page
|
||||||
at a time. */
|
at a time. */
|
||||||
@@ -1023,6 +1352,18 @@
|
|||||||
margin: var(--s2) 0;
|
margin: var(--s2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A note link reads as a link: the accent colour, underlined on hover, and a
|
||||||
|
pointer so it is plainly something to click rather than to edit. */
|
||||||
|
.page :global(a.notelink) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.page :global(a.notelink:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.page :global(blockquote) {
|
.page :global(blockquote) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s3);
|
padding-left: var(--s3);
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Klokken.
|
||||||
|
*
|
||||||
|
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
|
||||||
|
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
|
||||||
|
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
|
||||||
|
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
|
||||||
|
* delt projekt, er en klokke, folk holder op med at se på.
|
||||||
|
*
|
||||||
|
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
|
||||||
|
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
|
||||||
|
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
|
||||||
|
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
|
||||||
|
*/
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let wrap = $state(null);
|
||||||
|
|
||||||
|
const KINDS = {
|
||||||
|
assigned: 'notif.assigned',
|
||||||
|
'note.changed': 'notif.noteChanged',
|
||||||
|
comment: 'notif.comment'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
|
||||||
|
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
|
||||||
|
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
|
||||||
|
if (minutes < 1) return t('notif.justNow');
|
||||||
|
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
|
||||||
|
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor en besked fører hen.
|
||||||
|
*
|
||||||
|
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
|
||||||
|
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
|
||||||
|
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
|
||||||
|
* man går hen.
|
||||||
|
*/
|
||||||
|
function follow(n) {
|
||||||
|
open = false;
|
||||||
|
if (!n.read) app.markRead(n.id);
|
||||||
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outside(event) {
|
||||||
|
if (open && wrap && !wrap.contains(event.target)) open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onclick={outside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open) open = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap" bind:this={wrap}>
|
||||||
|
<button
|
||||||
|
class="bell"
|
||||||
|
class:on={open}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={app.unread
|
||||||
|
? t('notif.titleWithCount', { n: String(app.unread) })
|
||||||
|
: t('notif.title')}
|
||||||
|
title={t('notif.title')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10.3 21a2 2 0 003.4 0" />
|
||||||
|
</svg>
|
||||||
|
{#if app.unread}
|
||||||
|
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
|
||||||
|
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
|
||||||
|
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
||||||
|
<header>
|
||||||
|
<span>{t('notif.title')}</span>
|
||||||
|
{#if app.unread}
|
||||||
|
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if app.notifications.length}
|
||||||
|
<ul>
|
||||||
|
{#each app.notifications as n (n.id)}
|
||||||
|
<li>
|
||||||
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
|
<span class="what">{headline(n)}</span>
|
||||||
|
{#if n.body}<span class="about">{n.body}</span>{/if}
|
||||||
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="none">{t('notif.empty')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bell:hover,
|
||||||
|
.bell.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.bell svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 15px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent, #fff);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
width: min(340px, calc(100vw - var(--s3) * 2));
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.allread {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.allread:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
|
||||||
|
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
|
||||||
|
kunne se hvad der var én besked. */
|
||||||
|
.row.unread .what {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.row.unread .what::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ago {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3) var(--s2);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
|
import { clearBadge } from '$lib/badge.js';
|
||||||
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
@@ -31,9 +32,39 @@
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
|
// Mærket på ikonet er den forriges ulæste. Det står i Dock'en, også når
|
||||||
|
// ingen er logget ind, så det skal væk sammen med resten af dem.
|
||||||
|
clearBadge();
|
||||||
|
// Empty the offline data cache before leaving: the service worker keeps the
|
||||||
|
// last-seen task and note lists so a network drop does not blank the app, and
|
||||||
|
// the next person to sign in on this browser must not find them.
|
||||||
|
try {
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||||
|
await caches?.delete('verdande-api');
|
||||||
|
} catch {
|
||||||
|
// No service worker / no Cache API (older browser, private mode): nothing
|
||||||
|
// was cached, so nothing to clear.
|
||||||
|
}
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signing out is one click from a button the pointer passes over on its way to
|
||||||
|
// everything below it, and it drops you at the login screen — so it asks first.
|
||||||
|
// A second click within a few seconds confirms; anything else lets it lapse. Not
|
||||||
|
// a dialog: a two-step button is lighter and stays where the eye already is.
|
||||||
|
let confirmSignOut = $state(false);
|
||||||
|
let signOutTimer;
|
||||||
|
function onSignOutClick() {
|
||||||
|
if (confirmSignOut) {
|
||||||
|
clearTimeout(signOutTimer);
|
||||||
|
signOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmSignOut = true;
|
||||||
|
clearTimeout(signOutTimer);
|
||||||
|
signOutTimer = setTimeout(() => (confirmSignOut = false), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
let filters = $state([]);
|
let filters = $state([]);
|
||||||
let labels = $state([]);
|
let labels = $state([]);
|
||||||
|
|
||||||
@@ -487,6 +518,7 @@
|
|||||||
it, which is the second time that drop has earned its test. -->
|
it, which is the second time that drop has earned its test. -->
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
data-view={item.key}
|
||||||
class:active={current === item.href}
|
class:active={current === item.href}
|
||||||
class:receiving={item.key === 'today' && overToday}
|
class:receiving={item.key === 'today' && overToday}
|
||||||
onclick={onnavigate}
|
onclick={onnavigate}
|
||||||
@@ -529,6 +561,52 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if item.key === 'today'}
|
||||||
|
<!-- A star, the way Things marks Today: the one view you open first. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 2l1.7 3.6 3.9.5-2.9 2.7.7 3.9L8 10.9 4.6 13.4l.7-3.9L2.4 6.1l3.9-.5z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if item.key === 'upcoming'}
|
||||||
|
<!-- A calendar with a day marked — the Calendar view carries the plain
|
||||||
|
grid, so the marked day keeps the two apart. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<rect
|
||||||
|
x="2.2"
|
||||||
|
y="3.2"
|
||||||
|
width="11.6"
|
||||||
|
height="10.6"
|
||||||
|
rx="1.4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<rect x="8.8" y="8.6" width="2.6" height="2.6" rx="0.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else if item.key === 'delegated'}
|
||||||
|
<!-- A person: the tasks you are waiting on someone else for. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="5.4" r="2.5" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
<path
|
||||||
|
d="M3.4 13.4c0-2.6 2.1-4.2 4.6-4.2s4.6 1.6 4.6 4.2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -570,6 +648,7 @@
|
|||||||
{#snippet projectRow(project, sortable)}
|
{#snippet projectRow(project, sortable)}
|
||||||
<a
|
<a
|
||||||
href="/projekt/{project.id}"
|
href="/projekt/{project.id}"
|
||||||
|
data-view={project.is_inbox ? 'inbox' : undefined}
|
||||||
class:sortable
|
class:sortable
|
||||||
class:active={current === `/projekt/${project.id}`}
|
class:active={current === `/projekt/${project.id}`}
|
||||||
class:dragging={draggingId === project.id}
|
class:dragging={draggingId === project.id}
|
||||||
@@ -593,7 +672,29 @@
|
|||||||
? onTaskDrop(e, project)
|
? onTaskDrop(e, project)
|
||||||
: sortable && onDrop(e, project)}
|
: sortable && onDrop(e, project)}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
{#if project.is_inbox}
|
||||||
|
<!-- A tray, not a coloured dot: the Inbox is a fixed view that happens to
|
||||||
|
be a project, and it reads as one of the views above, not as the first
|
||||||
|
of the projects below. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.3 9.2L4 3.4a1.1 1.1 0 011-.8h6a1.1 1.1 0 011 .8l1.7 5.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.3 9.2v2.9A1.3 1.3 0 003.6 13.4h8.8a1.3 1.3 0 001.3-1.3V9.2h-3.3l-1 1.7H6.6l-1-1.7z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
{projectName(project)}
|
{projectName(project)}
|
||||||
<!-- Open tasks, not people. The same grey number meant three things in this
|
<!-- Open tasks, not people. The same grey number meant three things in this
|
||||||
one column — people at a project, projects at a group, tasks at a label
|
one column — people at a project, projects at a group, tasks at a label
|
||||||
@@ -612,28 +713,84 @@
|
|||||||
<!--
|
<!--
|
||||||
A fixed heading that folds, the same way a project group's does.
|
A fixed heading that folds, the same way a project group's does.
|
||||||
|
|
||||||
One snippet for all four rather than four copies of a chevron and an aria
|
One snippet for all four rather than four copies of an icon and an aria
|
||||||
attribute: they have to behave identically, and the group heading is already
|
attribute: they have to behave identically, and the group heading is already
|
||||||
the proof that they drift otherwise — it grew a fold, a colour and two
|
the proof that they drift otherwise — it grew a fold, a colour and two
|
||||||
actions while these stayed plain text.
|
actions while these stayed plain text.
|
||||||
|
|
||||||
The whole heading is the target, not the chevron. At this size a lone chevron
|
The whole heading is the target, and the icon is the mark: no arrow beside it.
|
||||||
is something you miss, and the name is where the eye already is.
|
At this size an extra glyph is clutter, and the name is where the eye already
|
||||||
|
is.
|
||||||
-->
|
-->
|
||||||
|
<!-- One monochrome mark per kind of heading, so a section and a group read as the
|
||||||
|
same kind of thing — a folder of projects, a funnel of filters, a tag of
|
||||||
|
labels — instead of one being a faint label and the other a coloured dot. -->
|
||||||
|
{#snippet sectionIcon(key)}
|
||||||
|
{#if key === 'filters'}
|
||||||
|
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.2 3.4h11.6L9.3 8.8v3.4L6.7 10.9V8.8z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if key === 'labels'}
|
||||||
|
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M7.7 2.3H13v5.3l-5.3 5.3-5.3-5.3z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="10.2" cy="4.9" r="0.95" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else if key === 'shared'}
|
||||||
|
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<circle cx="6.2" cy="5.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
<path
|
||||||
|
d="M2.6 12.8c0-2.1 1.7-3.3 3.6-3.3s3.6 1.2 3.6 3.3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11 9.6c1.5.2 2.6 1.3 2.6 3.2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="11.2" cy="5.6" r="1.7" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Projekter and every group: a folder of projects. -->
|
||||||
|
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet foldHeading(key, label)}
|
{#snippet foldHeading(key, label)}
|
||||||
|
<!-- No chevron: a system section has no page and nothing to rename, so the
|
||||||
|
whole heading is one button that folds it. The icon and the bold name are
|
||||||
|
the whole control — an arrow beside them was only ever saying "clickable"
|
||||||
|
twice. -->
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<button
|
<button
|
||||||
onclick={() => app.toggleSection(key)}
|
onclick={() => app.toggleSection(key)}
|
||||||
aria-expanded={!app.sectionCollapsed(key)}
|
aria-expanded={!app.sectionCollapsed(key)}
|
||||||
>
|
>
|
||||||
<svg
|
{@render sectionIcon(key)}
|
||||||
class="chevron"
|
|
||||||
class:collapsed={app.sectionCollapsed(key)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -656,7 +813,7 @@
|
|||||||
and with every project filed there would be no loose row left to aim at. -->
|
and with every project filed there would be no loose row left to aim at. -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="group-head"
|
class="group-head sticky-head"
|
||||||
class:over={overGroup === ''}
|
class:over={overGroup === ''}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (!carries(e, PROJECT)) return;
|
if (!carries(e, PROJECT)) return;
|
||||||
@@ -801,31 +958,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- The button sits inside the heading, not around it, the same way a
|
<!-- No chevron: the folder icon itself folds the group, and the name
|
||||||
project's title does: a group *is* a heading in the sidebar, and
|
beside it opens the group's own page. Two intentions, two targets,
|
||||||
wrapping an h2 in a button both invalidates the markup and takes
|
but no arrow to clutter the rail — the icon a section carries is
|
||||||
the heading away from a screen reader. The whole heading folds —
|
the same one that folds it here. The button sits inside the
|
||||||
at this size a lone chevron is a target you miss, and the name is
|
heading, not around it: a group *is* a heading, and wrapping an h2
|
||||||
where the eye already is. -->
|
in a button both invalidates the markup and takes the heading from
|
||||||
<!-- The chevron folds; the name opens the group's own page. Two
|
a screen reader. -->
|
||||||
targets in one heading, because they are two different intentions
|
|
||||||
and the old single one could only ever serve the smaller. -->
|
|
||||||
<button
|
<button
|
||||||
class="chevron-button"
|
class="fold-icon"
|
||||||
onclick={() => app.toggleGroup(group.id)}
|
onclick={() => app.toggleGroup(group.id)}
|
||||||
aria-expanded={!group.collapsed}
|
aria-expanded={!group.collapsed}
|
||||||
aria-label={group.collapsed
|
aria-label={group.collapsed
|
||||||
? t('sidebar.unfold', { name: group.name })
|
? t('sidebar.unfold', { name: group.name })
|
||||||
: t('sidebar.fold', { name: group.name })}
|
: t('sidebar.fold', { name: group.name })}
|
||||||
>
|
>
|
||||||
<svg
|
{@render sectionIcon('group')}
|
||||||
class="chevron"
|
|
||||||
class:collapsed={group.collapsed}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<!-- Navigation waits two tenths of a second for a second click. A
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
@@ -846,11 +994,6 @@
|
|||||||
}, 200);
|
}, 200);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
class="dot group-dot"
|
|
||||||
style="background: {colorVar(group.color)}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{group.name}
|
{group.name}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -938,12 +1081,12 @@
|
|||||||
{#if !app.connected}
|
{#if !app.connected}
|
||||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="user" onclick={signOut}>
|
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
|
||||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||||
</span>
|
</span>
|
||||||
<span class="user-name">{app.user?.name}</span>
|
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
|
||||||
<span class="signout">{t('nav.signOut')}</span>
|
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -979,14 +1122,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
padding: var(--s4) var(--s3);
|
/* A few pixels more air down each side than the rows strictly need, so the
|
||||||
|
menu breathes rather than sitting flush against its own edges. */
|
||||||
|
padding: var(--s4) calc(var(--s3) + 3px);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||||
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(calc(var(--s3) + 3px), env(safe-area-inset-left));
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,6 +1150,32 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
|
/* The sidebar is padded, so the scrollbar sat a pad-width inside the edge and
|
||||||
|
read as floating in the middle. Pull the scroller out to the border and pad
|
||||||
|
the content back, so the bar sits against the edge where a scrollbar
|
||||||
|
belongs — the rows do not move. Thin and dim, so it is present without
|
||||||
|
drawing the eye. */
|
||||||
|
margin-right: calc(var(--s3) * -1);
|
||||||
|
padding-right: var(--s3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No transparent border and no padding-box clip: those centred a 4px bar in a
|
||||||
|
10px track, so the thumb floated three pixels shy of the divider. The bar is
|
||||||
|
the track now — a thin rounded thumb sitting flush against the line on the
|
||||||
|
right, which is where a scrollbar belongs. */
|
||||||
|
.scroller::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||||
@@ -1133,8 +1304,26 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.active .mark {
|
/* The fixed views carry a colour of their own, the way Things does — the star
|
||||||
color: var(--accent);
|
amber, the Inbox blue — and keep it whether or not they are the active row.
|
||||||
|
So no active-state override here; the colour is the icon's identity. */
|
||||||
|
[data-view='inbox'] .mark {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
[data-view='today'] .mark {
|
||||||
|
color: var(--color-amber);
|
||||||
|
}
|
||||||
|
[data-view='upcoming'] .mark {
|
||||||
|
color: var(--color-tomato);
|
||||||
|
}
|
||||||
|
[data-view='delegated'] .mark {
|
||||||
|
color: var(--color-violet);
|
||||||
|
}
|
||||||
|
[data-view='notes'] .mark {
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
[data-view='calendar'] .mark {
|
||||||
|
color: var(--color-indigo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-head {
|
.group-head {
|
||||||
@@ -1144,12 +1333,35 @@
|
|||||||
padding: 0 var(--s2) var(--s2);
|
padding: 0 var(--s2) var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Projekter heading holds the only way to add a project or a group, so it
|
||||||
|
must not scroll away with a long list. Pinned to the top of the scroller, on
|
||||||
|
the sidebar's own ground so the rows pass under it. */
|
||||||
|
.sticky-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
padding-top: var(--s2);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every heading — the system sections and the user's groups — reads the same:
|
||||||
|
a mark, then a bold plainly-cased name. What used to set them apart (one a
|
||||||
|
faint uppercase label, the other a bold name with a coloured dot) is gone. */
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--menu-size);
|
||||||
font-weight: 560;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
|
||||||
letter-spacing: 0.06em;
|
falls back to the reading ink and keeps them bold and bright. */
|
||||||
color: var(--ink-faint);
|
color: var(--heading-ink, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
||||||
|
nudged like a row's own mark, so the icons line up down the sidebar. */
|
||||||
|
.head-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only the square icon buttons — "+" and the folder — and asked for by class
|
/* Only the square icon buttons — "+" and the folder — and asked for by class
|
||||||
@@ -1235,57 +1447,17 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
/* The folder icon is the fold toggle now — no chevron beside it. A bare icon
|
||||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
button the size of the icon it holds: the name next to it opens the group's
|
||||||
you miss. */
|
page, this folds it, and neither wears an arrow to say so. */
|
||||||
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
.fold-icon {
|
||||||
it pushed the name 22px further in than a project's, so groups read as if
|
flex: none;
|
||||||
they were filed under the projects above them — the opposite of what they
|
|
||||||
are. Out of the flow, a group's name starts exactly where a project's does.
|
|
||||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
|
||||||
stays put while the group is folded, so a folded group still shows the way
|
|
||||||
back open. */
|
|
||||||
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
|
||||||
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
|
||||||
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
|
||||||
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
|
||||||
skifte, uden noget at male henover den med. */
|
|
||||||
.chevron-button {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(var(--s2) - 5px);
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0;
|
|
||||||
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
|
||||||
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
|
||||||
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
|
||||||
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
|
||||||
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
|
||||||
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-head:hover .chevron-button,
|
.fold-icon:hover {
|
||||||
.chevron-button:focus-visible,
|
|
||||||
.chevron-button[aria-expanded='false'] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
|
||||||
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
|
||||||
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
|
||||||
pilen kom frem. */
|
|
||||||
.folder-head:hover .group-dot,
|
|
||||||
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
|
||||||
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-button:hover {
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,22 +1486,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
flex: none;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2.4;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
transition: transform var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron.collapsed {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hidden until the heading is hovered, like the section actions on a project
|
/* Hidden until the heading is hovered, like the section actions on a project
|
||||||
page: two buttons permanently beside a label stop it reading as a label. */
|
page: two buttons permanently beside a label stop it reading as a label. */
|
||||||
.folder-head .count {
|
.folder-head .count {
|
||||||
@@ -1362,29 +1518,19 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The group's own mark, between the chevron and the name — inside the fold
|
/* Projects sit at the same left margin whether or not they are in an area —
|
||||||
button, so it cannot push the heading in. It is a mark now and not a
|
one straight edge down the whole sidebar reads calmer than a staircase, and
|
||||||
control: the colour is chosen while renaming, which is the other edit to
|
the bold area heading above already does the grouping the indent used to. */
|
||||||
the same thing. */
|
|
||||||
.group-dot {
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Projects inside a group are indented to the group's name, not to its dot:
|
|
||||||
the eye follows the text, and lining up under the dot leaves the names
|
|
||||||
looking a step short of where the heading starts. */
|
|
||||||
.folder > a {
|
.folder > a {
|
||||||
padding-left: calc(var(--s2) + var(--s4));
|
padding-left: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The line is indented with them, so a drop between two projects in a group
|
|
||||||
reads as landing in the group rather than across the whole sidebar. */
|
|
||||||
.folder > a.sortable::before {
|
.folder > a.sortable::before {
|
||||||
left: calc(var(--s2) + var(--s4));
|
left: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder > .empty {
|
.folder > .empty {
|
||||||
padding-left: calc(var(--s2) + var(--s4));
|
padding-left: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inside the rename form now, so it needs no padding of its own — the form
|
/* Inside the rename form now, so it needs no padding of its own — the form
|
||||||
@@ -1441,7 +1587,10 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: var(--text-sm);
|
/* Its own size (Settings → Appearance) and medium weight, so the menu items
|
||||||
|
carry more than the muted small text they used to and read at a glance. */
|
||||||
|
font-size: var(--menu-size);
|
||||||
|
font-weight: 500;
|
||||||
transition:
|
transition:
|
||||||
background var(--fast) var(--ease),
|
background var(--fast) var(--ease),
|
||||||
color var(--fast) var(--ease);
|
color var(--fast) var(--ease);
|
||||||
@@ -1452,10 +1601,17 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An accent-tinted selection, not a half-step of surface: on the sunken dark
|
||||||
|
sidebar --surface-raised was all but invisible, so the active row read as no
|
||||||
|
different from the rest. --accent-sunken carries the accent hue and stands out
|
||||||
|
in both themes. */
|
||||||
a.active {
|
a.active {
|
||||||
background: var(--surface-raised);
|
background: var(--accent-sunken);
|
||||||
color: var(--ink);
|
/* On a solid accent (Kul's amber menu item) the label goes dark via
|
||||||
font-weight: 500;
|
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
|
||||||
|
falls back to the reading ink. */
|
||||||
|
color: var(--nav-active-ink, var(--ink));
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||||
@@ -1577,6 +1733,16 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Asking to confirm: the row goes to the danger tint and the name is replaced by
|
||||||
|
"click again", so the second click is plainly the one that signs out. */
|
||||||
|
.user.confirming {
|
||||||
|
background: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user.confirming .user-name {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||