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 | ||
|
|
cdab817141 | ||
|
|
2e76ced5fa | ||
|
|
643b30555e | ||
|
|
91671a4f97 |
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
||||
integrations that a 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.
|
||||
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 that a
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| Mail-to-task | Done, tested |
|
||||
| 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 |
|
||||
| Calendar subscriptions: ICS and webcal, several per person | 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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 \
|
||||
-H "Authorization: Bearer vrd_..." \
|
||||
-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
|
||||
@@ -108,7 +108,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `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` `POST` | `/filters` | |
|
||||
| `GET` | `/filters/{id}/tasks` | Run one. |
|
||||
|
||||
@@ -74,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Mailboxes | Gmail API | `gmail.modify` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Saved filters
|
||||
|
||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
||||
Todoist's, so if you are coming from there you already know it.
|
||||
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||
feel familiar if you have used query-based filters in other task apps.
|
||||
|
||||
```
|
||||
today & p1
|
||||
|
||||
@@ -11,28 +11,46 @@ of. Which is what a to-do list is.
|
||||
|
||||
## 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
|
||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
|
||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
|
||||
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
|
||||
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
|
||||
line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
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
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move 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
|
||||
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
|
||||
joins through an invite link.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[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
|
||||
|
||||
@@ -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. |
|
||||
| `add_comment` | Comment on a task. |
|
||||
|
||||
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
|
||||
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
|
||||
labels and recurrence in either language.
|
||||
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||
projects, labels and recurrence in either language.
|
||||
|
||||
## What it can reach
|
||||
|
||||
|
||||
@@ -805,14 +805,36 @@ paths:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"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:
|
||||
post:
|
||||
tags: [tasks]
|
||||
summary: Create a task from one line of natural language.
|
||||
description: |
|
||||
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
||||
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
||||
vigtigt. An unknown `#project` is not an error — the task goes to the
|
||||
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
|
||||
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
|
||||
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.
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1228,7 +1250,7 @@ paths:
|
||||
|
||||
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
|
||||
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
|
||||
extractable from it and unusable without Google's security review of every
|
||||
deployment that used it.
|
||||
@@ -2343,7 +2365,12 @@ paths:
|
||||
tags: [views]
|
||||
summary: The next few days, empty ones included.
|
||||
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:
|
||||
"200":
|
||||
description: OK
|
||||
@@ -3072,6 +3099,88 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares:
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Who a note is shared with, and who it can be shared with.
|
||||
description: >
|
||||
Owner only. Returns the people the note is already shared with, each with
|
||||
their role, and the candidates the owner may add — everyone on the instance
|
||||
who is not already on the note.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shares:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteShare" }
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
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" }
|
||||
post:
|
||||
tags: [notes]
|
||||
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:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"200":
|
||||
description: Shared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
follows:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares/{userID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Take a person's access to a note away again.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: userID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Unshared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/linking/{kind}/{targetID}:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3907,6 +4016,14 @@ components:
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
shared_with_me:
|
||||
type: boolean
|
||||
description: >
|
||||
True when the reader reached this note through a direct share rather
|
||||
than by owning it or its project. What puts it in the Delt med mig group.
|
||||
owner:
|
||||
allOf: [{ $ref: "#/components/schemas/Person" }]
|
||||
description: Who the note belongs to, present on a note shared with the reader.
|
||||
links:
|
||||
type: array
|
||||
description: What the body points at, read out of the text on the way in.
|
||||
@@ -3923,6 +4040,38 @@ components:
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Person:
|
||||
type: object
|
||||
description: A user, as much of one as a name and a colour.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
avatar_color: { type: string }
|
||||
|
||||
NoteShare:
|
||||
type: object
|
||||
description: One person's direct grant on a note.
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
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:
|
||||
type: object
|
||||
description: One connected mailbox. The password and any token are never returned.
|
||||
@@ -4411,12 +4560,22 @@ components:
|
||||
|
||||
Notification:
|
||||
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:
|
||||
id: { type: string }
|
||||
actor_name: { type: string }
|
||||
project_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 }
|
||||
body: { type: string }
|
||||
read: { type: boolean }
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
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
|
||||
Firma project, labelled regnskab.
|
||||
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
|
||||
in the Accounts project, labelled tax.
|
||||
|
||||
The parts it understands are tinted underneath the text as you type, so you can see
|
||||
it being read before you commit to it.
|
||||
@@ -16,8 +16,8 @@ it being read before you commit to it.
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
|
||||
| `@regnskab` | Add that label. As many as you like. |
|
||||
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
|
||||
| `@tax` | Add that label. As many as you like. |
|
||||
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
||||
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
||||
| `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
|
||||
cache, no queue and no second process.
|
||||
Running it yourself outside a panel: one static binary and one directory. There is
|
||||
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
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
github.com/coder/websocket v1.8.15
|
||||
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-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -14,9 +16,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
// code again before enrolment is confirmed.
|
||||
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
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
// message does not delete the task, completing the task does not unstar the
|
||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
||||
// The one thing verdande writes back is the star. A starred message becomes a
|
||||
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||
// 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
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||
// 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 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"
|
||||
)
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||
// 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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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>",
|
||||
// falling back to the address when there is no display name.
|
||||
func SenderName(from string) string {
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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
|
||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
b := make([]byte, n)
|
||||
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.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||
// leaves the query on its own and this list is a safety net rather than the only
|
||||
// record. It still earns its place: it covers the window between making the task
|
||||
// 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",
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
// 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
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// Google 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
|
||||
// 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
|
||||
// recreate the container to paste the result was not.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
|
||||
@@ -19,21 +19,38 @@ import (
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
// Access has three ways in, and a note may have any of them at once. A note with
|
||||
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||
// note in a project is readable by everyone who can read the project and writable
|
||||
// by an editor. And a note can be shared with a person directly, which grants that
|
||||
// person a role of its own. The effective role is the most any of the three allow.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||
// on a shared project or a shared note, may do the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
// The owner and the project path, exactly as before.
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
if n.CreatedBy == user.ID {
|
||||
return true
|
||||
}
|
||||
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
// A direct share grants its role on top of whatever the above allow.
|
||||
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||
return shareRole.AtLeast(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||
// change its text; giving away access is the owner's alone, the same shape a
|
||||
// project takes where members edit but only the owner invites.
|
||||
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
@@ -187,9 +204,49 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "create note", err)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
@@ -206,9 +263,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||
// change the text; letting them also move the note would let them file
|
||||
// somebody else's note into a project the owner is not in — and the owner,
|
||||
// who is not a member there, would lose their own note. So the move is
|
||||
// owner-only, and then only into a project the owner may write to.
|
||||
if !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||
return
|
||||
}
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
@@ -218,6 +282,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
before := n.Body
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -225,20 +290,63 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
s.internal(w, r, "update note", err)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||
// — may change its text, but throwing away the owner's note is the owner's to
|
||||
// do. A project editor keeps the reach they have always had over a project's
|
||||
// notes; a direct share does not extend that far.
|
||||
mayDelete := s.ownsNote(r, n)
|
||||
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||
mayDelete = perr == nil
|
||||
}
|
||||
if n == nil || !mayDelete {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
@@ -246,6 +354,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -280,6 +391,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
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-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// markShared fills in the two fields the list needs to show a note somebody else
|
||||
// shared with the reader: that it is such a note, and whose it is.
|
||||
//
|
||||
// Done in one pass over the page rather than per note: one query asks which of
|
||||
// these ids are shared with the reader, and one asks for the owners' names and
|
||||
// colours. A note the reader owns is never "shared with me", however it was
|
||||
// reached — the group is other people's notes, not one's own.
|
||||
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
|
||||
me := userFrom(r.Context()).ID
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.CreatedBy != me {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
|
||||
if err != nil {
|
||||
// The list is worth more than the chip: a note without its "shared" mark
|
||||
// still reads and still opens. Log nothing here — the store already did.
|
||||
return notes
|
||||
}
|
||||
|
||||
owners := map[string]store.Person{}
|
||||
if len(shared) > 0 {
|
||||
ownerIDs := make([]string, 0, len(shared))
|
||||
for _, n := range notes {
|
||||
if shared[n.ID] && n.CreatedBy != "" {
|
||||
ownerIDs = append(ownerIDs, n.CreatedBy)
|
||||
}
|
||||
}
|
||||
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
|
||||
}
|
||||
|
||||
for i := range notes {
|
||||
if !shared[notes[i].ID] {
|
||||
continue
|
||||
}
|
||||
notes[i].SharedWithMe = true
|
||||
if p, ok := owners[notes[i].CreatedBy]; ok {
|
||||
owner := p
|
||||
notes[i].Owner = &owner
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// handleListNoteShares is who a note is shared with. Owner only: the list of people
|
||||
// who can see your note is itself something only you should see.
|
||||
func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note shares", err)
|
||||
return
|
||||
}
|
||||
// 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))
|
||||
already := map[string]bool{}
|
||||
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{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
Linked: via[sh.User.ID] != "",
|
||||
Via: via[sh.User.ID],
|
||||
})
|
||||
already[sh.User.ID] = true
|
||||
}
|
||||
|
||||
// The picker is filled from the same response, so the panel opens complete in
|
||||
// one round-trip. People already on the note are left out — they are in the list
|
||||
// above it, not the "add someone" menu.
|
||||
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
cand := make([]personJSON, 0, len(candidates))
|
||||
for _, p := range candidates {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"shares": out,
|
||||
"candidates": cand,
|
||||
"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 {
|
||||
User personJSON `json:"user"`
|
||||
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 {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareNoteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if role == "" {
|
||||
role = store.RoleViewer
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleEditor {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
// The recipient must be a real account and not the sharer themselves; ShareNote
|
||||
// refuses the owner, and a made-up id should not reach it.
|
||||
if req.UserID == userFrom(r.Context()).ID {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
|
||||
s.internal(w, r, "unshare note", err)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createNote makes a note as the current client and returns its id.
|
||||
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create note returned no id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func userID(t *testing.T, ts *testServer, email string) string {
|
||||
t.Helper()
|
||||
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("look up %s: %v", email, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||
// somebody, and a viewer can read it but not change it.
|
||||
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
// Before sharing, Sofie cannot see it at all.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||
notes, _ := list["notes"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range notes {
|
||||
n := raw.(map[string]any)
|
||||
if n["id"] == noteID {
|
||||
found = n
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the shared note is not in the recipient's list")
|
||||
}
|
||||
if found["shared_with_me"] != true {
|
||||
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||
}
|
||||
ownerObj, _ := found["owner"].(map[string]any)
|
||||
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||
}
|
||||
|
||||
// A viewer reads it.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||
}
|
||||
// But cannot change it.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An editor may change the text, but only the owner may delete the note or hand it
|
||||
// to somebody else.
|
||||
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
// The editor writes.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The editor cannot re-share it: giving access is the owner's alone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||
}
|
||||
// The editor cannot delete it.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("an editor deleted the owner's note")
|
||||
}
|
||||
// The note still exists for the owner.
|
||||
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharing takes access away again, and only the owner can do it.
|
||||
func TestUnshareRemovesAccess(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Hemmelig")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
// A non-owner cannot unshare.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The owner can.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||
}
|
||||
// And access is gone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The share list is owner-only and offers the instance's other people as
|
||||
// candidates, minus those already on the note.
|
||||
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
owner.newUser(t, "anders@example.dk", "Anders")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Plan")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
shares, _ := out["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("shares = %d, want 1", len(shares))
|
||||
}
|
||||
cands, _ := out["candidates"].([]any)
|
||||
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||
names := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
names[c.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||
}
|
||||
if names["Sofie"] {
|
||||
t.Error("somebody already on the note is offered as a candidate")
|
||||
}
|
||||
if names["Kristian"] {
|
||||
t.Error("the owner is offered as a candidate to share with themselves")
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing refuses a made-up person and the owner themselves.
|
||||
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
me := userID(t, owner, "kristian@example.dk")
|
||||
noteID := owner.createNote(t, "# Note")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
|
||||
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"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
NoteID string `json:"note_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, n := range list {
|
||||
out = append(out, notificationJSON{
|
||||
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,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
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{
|
||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
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
|
||||
@@ -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,
|
||||
// which has the schedule but not the keys.
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
Public: public, Private: private, Subject: subject,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||
s.log.Warn("mark push delivered", "err", delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Warn, not Debug.
|
||||
@@ -257,6 +336,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
// Sharing a note with a person directly — its own access list, beside
|
||||
// the project path. Owner only; see notes_share_handlers.go.
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
@@ -546,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("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,23 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type taskJSON struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
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
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
if t.CompletedAt != nil {
|
||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.SnoozedUntil != nil {
|
||||
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -241,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = req.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
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.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||
s.notifyAssigned(r, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -698,3 +720,52 @@ func parseLimit(raw string, def, max int) int {
|
||||
}
|
||||
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) {
|
||||
ts := newTestServer(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 {
|
||||
Secret string `json:"secret"`
|
||||
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) {
|
||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
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 {
|
||||
|
||||
@@ -51,7 +51,8 @@ type upcomingDay struct {
|
||||
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
|
||||
// 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")
|
||||
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{
|
||||
DueFrom: from,
|
||||
DueBefore: to,
|
||||
Limit: 1000,
|
||||
Limit: 200 * days,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "upcoming", err)
|
||||
|
||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
||||
|
||||
for _, t := range cal.Tasks {
|
||||
writeTodo(&b, t, cal.Domain)
|
||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
||||
if t.DueDatetime != nil && !t.Completed {
|
||||
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -128,22 +133,31 @@ func writeTodo(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")
|
||||
// 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.
|
||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||
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))
|
||||
if 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") }
|
||||
|
||||
// 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
|
||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||
// 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,
|
||||
// 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())
|
||||
|
||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
||||
strings.Count(out, "BEGIN:VEVENT"))
|
||||
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||
// 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.
|
||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the event's end does not reflect the task's duration")
|
||||
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||
// they sit on their own day rather than bleeding into the next.
|
||||
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.
|
||||
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
||||
|
||||
@@ -56,6 +56,13 @@ type Payload struct {
|
||||
Body string `json:"body,omitempty"`
|
||||
URL string `json:"url,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
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
|
||||
}
|
||||
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,30 @@
|
||||
-- Sharing a note with a person, rather than only by filing it in a shared project.
|
||||
--
|
||||
-- Until now a note was shared exactly one way: put it in a project, and everyone
|
||||
-- who could read the project could read the note. That is the right tool when the
|
||||
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
|
||||
-- see this" — it makes you stand up a project to hand over a single page.
|
||||
--
|
||||
-- A note_shares row is that direct grant: this note, this person, this role. It
|
||||
-- sits alongside the project path, not instead of it — a note can be loose, filed,
|
||||
-- shared with people, or any combination, and access is the most that any of those
|
||||
-- allow. Only the note's owner (its created_by) writes these rows.
|
||||
--
|
||||
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
|
||||
-- is one idea of what a role means in this program and not two.
|
||||
CREATE TABLE note_shares (
|
||||
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
||||
-- One share per person per note: sharing again changes the role, it does not
|
||||
-- add a second row that the first would then quietly disagree with.
|
||||
PRIMARY KEY (note_id, user_id)
|
||||
);
|
||||
|
||||
-- "Which notes are shared with me" is asked on every visit to the notes page, to
|
||||
-- build the Delt med mig group and to widen what the list may show. It is a lookup
|
||||
-- by user_id, so that is what carries the index.
|
||||
CREATE INDEX note_shares_by_user ON note_shares (user_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
|
||||
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
|
||||
-- then comes back on its own — no job wakes it, because "snoozed" is simply
|
||||
-- snoozed_until being still in the future, read against the clock each time.
|
||||
--
|
||||
-- Null means not snoozed, which is every task until somebody snoozes one.
|
||||
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||
// may do. The person is carried whole rather than by id, because every caller of
|
||||
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||
type NoteShare struct {
|
||||
User Person `json:"user"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||
// has already checked that they may.
|
||||
//
|
||||
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||
// confuse the access check.
|
||||
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||
if !role.Valid() || role == RoleOwner {
|
||||
return errors.New("store: a note can be shared as viewer or editor")
|
||||
}
|
||||
var createdBy sql.NullString
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if createdBy.String == userID {
|
||||
return errors.New("store: a note is already the owner's")
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
noteID, userID, role, byUserID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||
// error: the end state the caller asked for — this person cannot see this note —
|
||||
// is the same either way.
|
||||
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||
// name so the list under a note does not reshuffle every time it is opened.
|
||||
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.name, u.avatar_color, s.role
|
||||
FROM note_shares s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.note_id = ?
|
||||
ORDER BY lower(u.name)`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []NoteShare
|
||||
for rows.Next() {
|
||||
var s NoteShare
|
||||
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||
// role could not be read — so a caller does not have to treat "no share" as an
|
||||
// error.
|
||||
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||
var role Role
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !role.Valid() {
|
||||
// A role the database holds but this build does not understand is no access,
|
||||
// never a guess — the same rule project roles follow.
|
||||
return "", false, nil
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
|
||||
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||
// the user. It answers the Delt med mig question in one query rather than one per
|
||||
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||
// world to mark a single page.
|
||||
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
if len(noteIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
args := make([]any, 0, len(noteIDs)+1)
|
||||
args = append(args, userID)
|
||||
for _, id := range noteIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- 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()
|
||||
}
|
||||
@@ -37,6 +37,17 @@ type Note struct {
|
||||
// set by a caller: it is derived, and a caller that set it would be describing
|
||||
// a note whose text says something else.
|
||||
Links []NoteLink `json:"links,omitempty"`
|
||||
|
||||
// SharedWithMe is true when the person reading the list reached this note
|
||||
// through a direct share rather than by owning it or being in its project. It
|
||||
// is what puts the note in the Delt med mig group and shows whose note it is.
|
||||
// Derived per request, so it is never stored and never set by a caller.
|
||||
SharedWithMe bool `json:"shared_with_me,omitempty"`
|
||||
|
||||
// Owner is who the note belongs to — its created_by — as a name and a colour,
|
||||
// for the chip on a note shared with the reader. Set only when it helps the
|
||||
// list say something; nil the rest of the time.
|
||||
Owner *Person `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// NoteLink is one reference from a note to something else.
|
||||
@@ -263,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
|
||||
UNION
|
||||
SELECT project_id FROM project_members WHERE user_id = ?
|
||||
)
|
||||
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||
)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
|
||||
}
|
||||
|
||||
// NotesLinking answers the backwards question: what points at this thing.
|
||||
|
||||
@@ -18,6 +18,7 @@ type Notification struct {
|
||||
ActorName string
|
||||
ProjectID string
|
||||
TaskID string
|
||||
NoteID string
|
||||
Kind string
|
||||
Title string
|
||||
Body string
|
||||
@@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
||||
n.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
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
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
WHERE n.user_id = ?
|
||||
@@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
||||
out := []Notification{}
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var actorID, projectID, taskID sql.NullString
|
||||
var actorID, projectID, taskID, noteID sql.NullString
|
||||
var readAt sql.NullInt64
|
||||
var created int64
|
||||
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
|
||||
}
|
||||
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 {
|
||||
v := time.Unix(readAt.Int64, 0).UTC()
|
||||
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,
|
||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||
// 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 {
|
||||
if permanent {
|
||||
// Not scoped by user, and that is right here: this is the push service
|
||||
|
||||
@@ -29,6 +29,9 @@ type Task struct {
|
||||
AssigneeID string
|
||||
CompletedAt *time.Time
|
||||
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`
|
||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||
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,
|
||||
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.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
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
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,
|
||||
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.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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
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) {
|
||||
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
// stays; it loses its author.
|
||||
var createdBy sql.NullString
|
||||
var dueDatetime, completedAt sql.NullInt64
|
||||
var duration sql.NullInt64
|
||||
var duration, snoozedUntil sql.NullInt64
|
||||
var created, updated int64
|
||||
|
||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||
&recurrence, &assignee, &completedAt, &completedBy,
|
||||
&createdBy, &t.SortOrder, &created, &updated,
|
||||
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||
if err != nil {
|
||||
return t, err
|
||||
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
v := int(duration.Int64)
|
||||
t.DurationMin = &v
|
||||
}
|
||||
if snoozedUntil.Valid {
|
||||
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||
t.SnoozedUntil = &v
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return t, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
u.Name = NormalizeName(u.Name)
|
||||
if u.ID == "" {
|
||||
u.ID = NewID()
|
||||
}
|
||||
@@ -205,9 +227,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
|
||||
// Person is the public half of a user: what somebody else is allowed to see when
|
||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||
type Person struct {
|
||||
ID string
|
||||
Name string
|
||||
AvatarColor string
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
|
||||
// that somebody a note is about to be shared with actually exists, without loading
|
||||
// the whole account.
|
||||
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
|
||||
var p Person
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Person{}, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// UsersForSharing is everyone on the instance except the caller — the people a note
|
||||
// can be handed to.
|
||||
//
|
||||
// Deliberately wider than ListPeople, which is the project collaborators. The whole
|
||||
// point of sharing a note directly is to do it without first standing up a shared
|
||||
// project, so the picker cannot be limited to people you already share a project
|
||||
// with. An instance's accounts are a closed, invited set to begin with — there is
|
||||
// no open signup — so listing them to each other is not disclosure, it is the
|
||||
// address book the feature needs.
|
||||
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
people := []Person{}
|
||||
for rows.Next() {
|
||||
var p Person
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people = append(people, p)
|
||||
}
|
||||
return people, rows.Err()
|
||||
}
|
||||
|
||||
// PeopleByIDs looks up several users at once.
|
||||
@@ -432,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||
// field. Neither is is_admin — an account cannot promote itself.
|
||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||
name = NormalizeName(name)
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||
name, timezone, locale, time.Now().Unix(), userID)
|
||||
@@ -444,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
||||
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 {
|
||||
// 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
|
||||
|
||||
@@ -44,8 +44,8 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Self-hosting: self-hosting.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
- Using it:
|
||||
- Signing in: signing-in.md
|
||||
|
||||
@@ -856,8 +856,11 @@
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
Self-host it
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
||||
As a Rune
|
||||
</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
With Docker
|
||||
</a>
|
||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
@@ -928,13 +931,33 @@
|
||||
<div class="shots">
|
||||
<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 and repetition."
|
||||
src="screenshots/calendar-light.webp"
|
||||
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"
|
||||
height="900"
|
||||
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 class="shot">
|
||||
<img
|
||||
@@ -969,12 +992,22 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
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"
|
||||
height="900"
|
||||
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 class="shot">
|
||||
<img
|
||||
@@ -989,12 +1022,12 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
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"
|
||||
height="900"
|
||||
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 class="shot">
|
||||
<img
|
||||
@@ -1017,6 +1050,75 @@
|
||||
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||
</figure>
|
||||
</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 id="features">
|
||||
@@ -1050,8 +1152,8 @@
|
||||
<h3>Repeats, and says so in words</h3>
|
||||
<p>
|
||||
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||
screen. Reminders are absolute or relative, and go out to the devices
|
||||
you have allowed them on.
|
||||
screen. Reminders are absolute or relative, and go out as Web Push to
|
||||
an iPhone or a Mac you have allowed them on.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -1063,12 +1165,47 @@
|
||||
written about it. Archive what is done with it; the bin is for mistakes.
|
||||
</p>
|
||||
</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">
|
||||
<h3>Waiting on others</h3>
|
||||
<p>
|
||||
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
|
||||
you the moment it is shared.
|
||||
the person who has it — and marked where you meet it too, with an arrow
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1105,9 +1242,10 @@
|
||||
<h3>Your mail, as an inbox</h3>
|
||||
<p>
|
||||
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
|
||||
subject is parsed like anything else you type. One way: unstarring it
|
||||
later does nothing to the task you made.
|
||||
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.
|
||||
Forward one to your own address instead and the subject is parsed like
|
||||
anything else you type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -1188,6 +1326,42 @@
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<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>
|
||||
<span class="ver">0.34</span>
|
||||
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
|
||||
stopped answering by date and started answering by relevance.
|
||||
</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>
|
||||
</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 |
|
After Width: | Height: | Size: 58 KiB |
@@ -51,6 +51,12 @@ export default defineConfig({
|
||||
|
||||
use: {
|
||||
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',
|
||||
screenshot: 'only-on-failure',
|
||||
// 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: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ 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: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// 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`, {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -373,6 +415,8 @@ async function seed({ colleague, noteTitle }) {
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
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: '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
|
||||
// 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 },
|
||||
@@ -441,6 +496,17 @@ async function seed({ colleague, noteTitle }) {
|
||||
const made = {};
|
||||
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 feature that does not work.
|
||||
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' });
|
||||
|
||||
return {
|
||||
owner: me.id,
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
--text-xl: 1.375rem;
|
||||
--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. */
|
||||
--s1: 0.25rem;
|
||||
--s2: 0.5rem;
|
||||
@@ -204,6 +210,52 @@
|
||||
--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. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
@@ -369,9 +421,13 @@ button {
|
||||
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 {
|
||||
background: var(--accent-sunken);
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
||||
@@ -516,3 +572,41 @@ button {
|
||||
line-height: 1.5;
|
||||
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.
|
||||
const look = localStorage.getItem('verdande: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) {
|
||||
// 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 { 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.
|
||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, init);
|
||||
} catch (e) {
|
||||
// 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');
|
||||
}
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
||||
const form = new FormData();
|
||||
form.append(field, file);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
} catch {
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
@@ -310,6 +354,7 @@ export const api = {
|
||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
@@ -405,6 +450,12 @@ export const api = {
|
||||
deleteNote: (id) => del(`/notes/${id}`),
|
||||
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||
|
||||
// Sharing a note with people directly — its own access list, beside the project
|
||||
// path. Owner only; the list call also returns who it can still be shared with.
|
||||
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
||||
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
||||
|
||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||
// there is no instance-wide registration behind these the way Gmail has one.
|
||||
mailboxes: () => get('/mailboxes'),
|
||||
|
||||
@@ -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. */
|
||||
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) {
|
||||
if (!carries(event, TASK)) return;
|
||||
accept(event);
|
||||
@@ -394,7 +436,16 @@
|
||||
if (!task.due_datetime) continue;
|
||||
const at = new Date(task.due_datetime);
|
||||
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);
|
||||
@@ -547,12 +598,13 @@
|
||||
</svelte:element>
|
||||
{/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
|
||||
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. -->
|
||||
<button
|
||||
class={kind}
|
||||
class:resizing={resizing?.id === task.id}
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
@@ -562,6 +614,16 @@
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{/if}
|
||||
{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>
|
||||
{/snippet}
|
||||
|
||||
@@ -589,6 +651,7 @@
|
||||
<div
|
||||
class="allday"
|
||||
data-allday={iso(date)}
|
||||
class:today={iso(date) === todayISO}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
@@ -632,7 +695,7 @@
|
||||
{#if item.kind === 'event'}
|
||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||
{: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}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -814,12 +877,16 @@
|
||||
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,
|
||||
.head,
|
||||
.gutter,
|
||||
.allday,
|
||||
.daycol {
|
||||
background: var(--ground);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -827,6 +894,16 @@
|
||||
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 {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
@@ -839,9 +916,19 @@
|
||||
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 {
|
||||
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;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
@@ -915,6 +1002,10 @@
|
||||
/* 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å
|
||||
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 {
|
||||
position: absolute;
|
||||
margin-right: 2px;
|
||||
@@ -922,21 +1013,48 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour, var(--line-strong));
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
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 {
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
border-left-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
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'] {
|
||||
border-left-color: var(--p1);
|
||||
}
|
||||
@@ -1016,7 +1134,8 @@
|
||||
text-align: left;
|
||||
padding: 1px var(--s1);
|
||||
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);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
@@ -1035,7 +1154,7 @@
|
||||
}
|
||||
|
||||
.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
|
||||
@@ -1055,8 +1174,8 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1071,7 +1190,7 @@
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1080,7 +1199,7 @@
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1182,20 +1301,18 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
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'] {
|
||||
border-left-color: var(--p1);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='2'] {
|
||||
border-left-color: var(--p2);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='3'] {
|
||||
border-left-color: var(--p3);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
@@ -1207,12 +1324,12 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
@@ -120,11 +120,204 @@
|
||||
|
||||
function setStyle(style) {
|
||||
stylesOpen = false;
|
||||
editor?.focus();
|
||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
function readState() {
|
||||
if (!editor) return;
|
||||
@@ -135,7 +328,11 @@
|
||||
underline: document.queryCommandState('underline'),
|
||||
strike: document.queryCommandState('strikeThrough'),
|
||||
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 {
|
||||
// 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;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
|
||||
// A note link, [[, is the same idea as a project tag and gets the same
|
||||
// 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.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// 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;
|
||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: 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.
|
||||
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();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
if (item.kind === 'note') {
|
||||
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||
// 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 = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
@@ -470,6 +725,16 @@
|
||||
}
|
||||
|
||||
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.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
@@ -485,6 +750,9 @@
|
||||
if (key === 'u') {
|
||||
event.preventDefault();
|
||||
apply('underline');
|
||||
} else if (key === 'e') {
|
||||
event.preventDefault();
|
||||
toggleCode();
|
||||
} else if (key === 'x' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
apply('strikeThrough');
|
||||
@@ -632,19 +900,50 @@
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
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>
|
||||
|
||||
{#if asSource}
|
||||
@@ -678,7 +977,7 @@
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onclick={onEditorClick}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
@@ -698,7 +997,7 @@
|
||||
style:top={menuAt ? `${menuAt.top}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>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
@@ -706,12 +1005,17 @@
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
{#if item.kind === 'note'}
|
||||
<span class="dot note" aria-hidden="true">[[</span>
|
||||
{item.label}
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{item.label}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -864,6 +1168,18 @@
|
||||
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 {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -959,11 +1275,24 @@
|
||||
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-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":
|
||||
a title that is a title, and body text that is comfortable to read for a page
|
||||
at a time. */
|
||||
@@ -1023,6 +1352,18 @@
|
||||
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) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
|
||||