Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -103,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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -18,9 +18,10 @@ 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.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
@@ -39,8 +40,17 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||
of only filling up.
|
||||
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||
rather than private formats.
|
||||
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||
signal and goes through when it comes back; a reload opens from what it last saw
|
||||
rather than the browser's offline page. It installs as a PWA.
|
||||
|
||||
## What it looks like
|
||||
|
||||
@@ -60,6 +70,16 @@ columns.*
|
||||
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.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -832,9 +832,9 @@ paths:
|
||||
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
|
||||
@@ -1250,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.
|
||||
@@ -2365,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
|
||||
@@ -3121,10 +3126,20 @@ paths:
|
||||
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:
|
||||
@@ -3136,7 +3151,16 @@ paths:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"204": { description: Shared }
|
||||
"200":
|
||||
description: Shared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
follows:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
@@ -4030,6 +4054,23 @@ components:
|
||||
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
|
||||
|
||||
@@ -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: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 253 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: 238 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 220 KiB |
@@ -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)
|
||||
|
||||
@@ -204,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 {
|
||||
@@ -242,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
|
||||
}
|
||||
@@ -249,13 +290,41 @@ 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)
|
||||
}
|
||||
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 {
|
||||
@@ -279,6 +348,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,12 +77,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
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),
|
||||
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
|
||||
}
|
||||
@@ -103,12 +128,41 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"shares": out,
|
||||
"candidates": cand,
|
||||
"follows": followsOf(following, n.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
|
||||
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
|
||||
// følger med" er det samme, hvem det så end deles med.
|
||||
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
|
||||
seen := map[string]bool{}
|
||||
out := []followJSON{}
|
||||
for _, l := range all {
|
||||
if l.ViaID != noteID || seen[l.NoteID] {
|
||||
continue
|
||||
}
|
||||
seen[l.NoteID] = true
|
||||
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type followJSON struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
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 {
|
||||
@@ -159,7 +213,15 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
||||
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
||||
followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)})
|
||||
}
|
||||
|
||||
// handleUnshareNote takes a person's access away again.
|
||||
@@ -177,5 +239,12 @@ func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
@@ -193,7 +193,104 @@ func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -131,3 +133,242 @@ func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []stri
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -992,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
|
||||
@@ -1040,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">
|
||||
@@ -1120,6 +1199,15 @@
|
||||
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>
|
||||
|
||||
<p class="group-label">Connected to what you already use</p>
|
||||
@@ -1154,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">
|
||||
@@ -1237,6 +1326,25 @@
|
||||
<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
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 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: 44 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
@@ -64,6 +64,60 @@ function watchForTrouble(page) {
|
||||
return trouble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
* Syv dage er stadig det, den åbner på — det er den vandring, der er værd at gå om
|
||||
* morgenen. Men "hvornår bliver den her måned travl" er et andet spørgsmål, og
|
||||
* månedsgitteret svarer på det med form frem for med rækkefølge. Derfor kan
|
||||
* listen sættes til 14 og 30 dage, og valget bliver liggende: horisonten er et
|
||||
* spørgsmål om, hvor langt man planlægger, ikke om hvilken skærm man sidder ved —
|
||||
* men det er den samme slags valg som gitter eller liste, så det ligger samme sted.
|
||||
*/
|
||||
test('Kommende kan se en måned frem, og horisonten huskes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('skifte olie om 20 dage');
|
||||
await box.press('Enter');
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
|
||||
const days = page.locator('section:has(> h2)');
|
||||
const task = page.getByText('skifte olie', { exact: true });
|
||||
|
||||
// Syv dage er udgangspunktet, og opgaven ligger uden for dem. At den er væk her
|
||||
// er halvdelen af prøven: uden den kunne de tredive dage nedenfor være syv, der
|
||||
// tilfældigvis viste den.
|
||||
await expect(days).toHaveCount(7);
|
||||
await expect(task).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: '30 dage', exact: true }).click();
|
||||
await expect(days).toHaveCount(30);
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// Ugenummeret står kun, når strimlen er længere end en uge — ellers siger det
|
||||
// ingenting, man ikke kunne se i forvejen.
|
||||
await expect(days.first().getByText(/uge \d+/i)).toBeVisible();
|
||||
|
||||
// Og valget overlever en genindlæsning.
|
||||
await page.reload();
|
||||
await expect(days).toHaveCount(30);
|
||||
await expect(
|
||||
page.getByRole('button', { name: '30 dage', exact: true }),
|
||||
'horisonten blev ikke husket'
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await page.getByRole('button', { name: '7 dage', exact: true }).click();
|
||||
await expect(days).toHaveCount(7);
|
||||
await expect(days.first().getByText(/uge \d+/i)).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -298,11 +352,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||
|
||||
// The service worker is what Web Push needs; without it a subscription cannot
|
||||
// be created at all.
|
||||
const worker = await request.get('/sw.js');
|
||||
// The service worker is what Web Push needs — and, since it also caches, what
|
||||
// carries the app through a short network drop. SvelteKit serves it under this
|
||||
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||
const worker = await request.get('/service-worker.js');
|
||||
expect(worker.ok()).toBeTruthy();
|
||||
expect(await worker.text()).toContain("addEventListener('push'");
|
||||
// The source is minified, so the quotes around the event names are whatever the
|
||||
// minifier chose — match either kind rather than one it might change under us.
|
||||
const source = await worker.text();
|
||||
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||
* "you are offline" page.
|
||||
*
|
||||
* This is the whole point of the service worker's cache — not offline authoring,
|
||||
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||
* side of the same drop; this covers the read side.
|
||||
*/
|
||||
test.describe('med service worker', () => {
|
||||
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||
// test is about the worker, so it turns it back on for its own context.
|
||||
test.use({ serviceWorkers: 'allow' });
|
||||
|
||||
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||
// First load, online: the worker installs, and once it controls the page the
|
||||
// shell and the reads it serves are in the cache.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// Wait until the worker actually controls this page. Until it does, a reload
|
||||
// still goes to the network and the test proves nothing — the first load
|
||||
// registers the worker but the page that triggered the install is not itself
|
||||
// controlled until it activates and claims.
|
||||
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// A controlled reload, still online: now the navigation and its API reads pass
|
||||
// through the worker and land in the cache, ready for the drop.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// The network is gone.
|
||||
await context.setOffline(true);
|
||||
try {
|
||||
await page.reload();
|
||||
// The shell came from the cache — the sidebar is here, not a browser error
|
||||
// page — and so did the task list the app renders into it.
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||
} finally {
|
||||
// Never leave the shared context offline for whatever test runs next.
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2030,7 +2138,12 @@ test('en passkey kan registreres og logges ind med', async ({ browser, browserNa
|
||||
// Now sign out and back in with it. No email typed: the device knows which
|
||||
// account its key belongs to, which is also why this page cannot be used to
|
||||
// find out who has an account here.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
||||
// Signing out asks first now: one click arms it, a second within a few seconds
|
||||
// does it. The whole account row is the button; its label changes to the prompt.
|
||||
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
|
||||
await signOut.click();
|
||||
await expect(signOut).toContainText('Tryk igen for at logge ud');
|
||||
await signOut.click();
|
||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||
// Playwright matches an accessible name loosely unless told otherwise.
|
||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||
@@ -2725,6 +2838,238 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Note-links i en delt note.
|
||||
*
|
||||
* Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting.
|
||||
* Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen
|
||||
* ikke passede — og i en delt note er det første resultat næsten altid noten selv,
|
||||
* fordi den nævner den titel, den linker til. Så åbnede linket den note, man
|
||||
* allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt
|
||||
* noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten
|
||||
* findes, den er bare ikke delt med.
|
||||
*/
|
||||
test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill('link@example.dk');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
|
||||
const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const gæst = await gæstCtx.newPage();
|
||||
await gæst.goto(link);
|
||||
await gæst.getByLabel('Navn', { exact: true }).fill('Linnea');
|
||||
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await gæst.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
const noterURL = new URL('/noter', link).href;
|
||||
|
||||
// Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den
|
||||
// delte peger på den anden.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Kristians prisblad');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Faelles aftale om afhentning\n');
|
||||
await page.keyboard.type('[[Kristians prisblad]]');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Gæsten har en note af sin egen, som hun kan pege på.
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.getByRole('button', { name: 'Ny note' }).click();
|
||||
await gæst.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await gæst.keyboard.type('Linneas egne maal');
|
||||
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
await page.locator('.sharewrap > button').click();
|
||||
const share = page.locator('.sharepanel');
|
||||
await expect(share).toBeVisible();
|
||||
await share.locator('.addshare select').first().selectOption({ label: 'Linnea' });
|
||||
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||
const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' });
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('select').selectOption('editor');
|
||||
|
||||
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(body).toContainText('Kristians prisblad');
|
||||
await body.click();
|
||||
await gæst.keyboard.press('Control+End');
|
||||
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
||||
await body.blur();
|
||||
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
||||
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
|
||||
// Hendes eget link fører derhen, det siger.
|
||||
await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click();
|
||||
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Linneas egne maal'
|
||||
);
|
||||
|
||||
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
|
||||
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
|
||||
// allerede stod i — hvilket ser ud som ingenting.
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
|
||||
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
|
||||
// Stadig i den delte note: linket flyttede hende ingen steder hen.
|
||||
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Faelles aftale om afhentning'
|
||||
);
|
||||
|
||||
await gæstCtx.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Delingen tager de noter med, noten peger på.
|
||||
*
|
||||
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
|
||||
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
|
||||
* det, den *siger*, og et link er en del af det.
|
||||
*/
|
||||
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill('med@example.dk');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
|
||||
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const gæst = await ctx.newPage();
|
||||
await gæst.goto(link);
|
||||
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
|
||||
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await gæst.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
const noterURL = new URL('/noter', link).href;
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
|
||||
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
|
||||
for (const text of [
|
||||
'Leveringstider for pakker',
|
||||
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
|
||||
'Bankoplysninger som ingen skal se',
|
||||
'Aftale om levering\nse [[Prisliste i kroner]]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type(text);
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
}
|
||||
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||
await page.locator('.sharewrap > button').click();
|
||||
const share = page.locator('.sharepanel');
|
||||
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
|
||||
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||
|
||||
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
|
||||
await expect(page.locator('.toast')).toContainText('de 2 noter');
|
||||
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
|
||||
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
|
||||
|
||||
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||
await gæst
|
||||
.getByRole('textbox', { name: 'Notens tekst' })
|
||||
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
|
||||
.click();
|
||||
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(hos).toContainText('Prisliste i kroner');
|
||||
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
|
||||
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Leveringstider for pakker'
|
||||
);
|
||||
// Men kun det, noten peger på. Resten er stadig ejerens alene.
|
||||
await expect(
|
||||
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
|
||||
).toHaveCount(0);
|
||||
|
||||
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
|
||||
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
|
||||
// knap med den samme titel på.
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
|
||||
'følger med'
|
||||
);
|
||||
|
||||
// Taget tilbage følger den med tilbage.
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await page
|
||||
.locator('.sharepanel .sharelist li')
|
||||
.filter({ hasText: 'Mikkel' })
|
||||
.getByRole('button', { name: 'Fjern adgang' })
|
||||
.click();
|
||||
await gæst.goto(noterURL);
|
||||
await expect(
|
||||
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
|
||||
).toHaveCount(0);
|
||||
|
||||
await ctx.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `</>` er kode, ikke kilde.
|
||||
*
|
||||
* Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet,
|
||||
* og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet
|
||||
* program, så det er knappen, der skal give efter, ikke brugeren.
|
||||
*/
|
||||
test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await body.click();
|
||||
await page.keyboard.type('Kodenote\n');
|
||||
await page.keyboard.type('kør kommandoen npm først');
|
||||
|
||||
// Markér ét ord og gør det til kode.
|
||||
await page.keyboard.press('Shift+Home');
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.getByRole('button', { name: 'Kode', exact: true }).click();
|
||||
await expect(body.locator('code')).toHaveCount(1);
|
||||
|
||||
// Noten er stadig rig tekst — kildevisningen er ikke slået til.
|
||||
await expect(page.locator('textarea.source')).toHaveCount(0);
|
||||
|
||||
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||
await body.blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
|
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,19 @@ 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.
|
||||
|
||||
@@ -210,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;
|
||||
@@ -375,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
|
||||
|
||||
@@ -164,6 +164,111 @@
|
||||
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.
|
||||
*
|
||||
@@ -223,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
|
||||
@@ -641,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');
|
||||
@@ -788,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}
|
||||
@@ -1132,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. */
|
||||
|
||||
@@ -31,9 +31,36 @@
|
||||
|
||||
async function signOut() {
|
||||
await api.logout();
|
||||
// Empty the offline data cache before leaving: the service worker keeps the
|
||||
// last-seen task and note lists so a network drop does not blank the app, and
|
||||
// the next person to sign in on this browser must not find them.
|
||||
try {
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||
await caches?.delete('verdande-api');
|
||||
} catch {
|
||||
// No service worker / no Cache API (older browser, private mode): nothing
|
||||
// was cached, so nothing to clear.
|
||||
}
|
||||
location.href = '/';
|
||||
}
|
||||
|
||||
// Signing out is one click from a button the pointer passes over on its way to
|
||||
// everything below it, and it drops you at the login screen — so it asks first.
|
||||
// A second click within a few seconds confirms; anything else lets it lapse. Not
|
||||
// a dialog: a two-step button is lighter and stays where the eye already is.
|
||||
let confirmSignOut = $state(false);
|
||||
let signOutTimer;
|
||||
function onSignOutClick() {
|
||||
if (confirmSignOut) {
|
||||
clearTimeout(signOutTimer);
|
||||
signOut();
|
||||
return;
|
||||
}
|
||||
confirmSignOut = true;
|
||||
clearTimeout(signOutTimer);
|
||||
signOutTimer = setTimeout(() => (confirmSignOut = false), 3500);
|
||||
}
|
||||
|
||||
let filters = $state([]);
|
||||
let labels = $state([]);
|
||||
|
||||
@@ -1050,12 +1077,12 @@
|
||||
{#if !app.connected}
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
|
||||
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1091,14 +1118,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
padding: var(--s4) var(--s3);
|
||||
/* A few pixels more air down each side than the rows strictly need, so the
|
||||
menu breathes rather than sitting flush against its own edges. */
|
||||
padding: var(--s4) calc(var(--s3) + 3px);
|
||||
background: var(--surface-sunken);
|
||||
border-right: 1px solid var(--line);
|
||||
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||
overflow: hidden;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-left: max(calc(var(--s3) + 3px), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1317,7 +1346,9 @@
|
||||
h2 {
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
|
||||
falls back to the reading ink and keeps them bold and bright. */
|
||||
color: var(--heading-ink, var(--ink));
|
||||
}
|
||||
|
||||
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
||||
@@ -1572,7 +1603,10 @@
|
||||
in both themes. */
|
||||
a.active {
|
||||
background: var(--accent-sunken);
|
||||
color: var(--ink);
|
||||
/* On a solid accent (Kul's amber menu item) the label goes dark via
|
||||
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
|
||||
falls back to the reading ink. */
|
||||
color: var(--nav-active-ink, var(--ink));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1695,6 +1729,16 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Asking to confirm: the row goes to the danger tint and the name is replaced by
|
||||
"click again", so the second click is plainly the one that signs out. */
|
||||
.user.confirming {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.user.confirming .user-name {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -228,13 +228,21 @@
|
||||
align-items: flex-start;
|
||||
padding: var(--s3) var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
transition:
|
||||
opacity var(--medium) var(--ease),
|
||||
transform var(--medium) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
|
||||
target makes iOS spend the first tap applying the hover and only open on the
|
||||
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
|
||||
the task. The rounded corners are on the row itself, so the highlight is a
|
||||
rounded box like a note in the list. */
|
||||
@media (hover: hover) {
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
}
|
||||
|
||||
/* The exit: a short lift and fade, so completing something reads as the task
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||
*
|
||||
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
|
||||
* handing over. It is deliberately NOT offline support. The app is already loaded
|
||||
* and its data is already in memory, so there is nothing to cache and nothing to
|
||||
* open from cold; what a blip needs is for the save you just made to survive it,
|
||||
* not for the whole app to run without a server.
|
||||
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
|
||||
* tunnel, a base station handing over. The READ side lives in the service worker,
|
||||
* which keeps the last-seen task and note lists so a reload through a drop is not a
|
||||
* blank screen. This store is the other half: it is what keeps the save you just made
|
||||
* from being reverted under your hands while the network is briefly gone.
|
||||
*
|
||||
* The request layer drives this: it retries a failed request for a short window
|
||||
* (see api.js) and tells this store while it is trying, so the interface can say
|
||||
|
||||
@@ -23,7 +23,7 @@ export const da = {
|
||||
// Deling med enkeltpersoner, ved siden af projektet.
|
||||
'notes.groupSharedWithMe': 'Delt med mig',
|
||||
'notes.linkOpen': 'Åbn',
|
||||
'notes.linkMissing': 'Noten blev ikke fundet.',
|
||||
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
||||
'notes.sharedBy': 'Delt af {name}',
|
||||
'notes.sharePeople': 'Del med personer',
|
||||
'notes.sharePick': 'Vælg en person …',
|
||||
@@ -34,6 +34,9 @@ export const da = {
|
||||
'notes.shareRemove': 'Fjern adgang',
|
||||
'notes.shareNobody': 'Ikke delt med nogen endnu.',
|
||||
'notes.sharedToast': 'Delt med {name}.',
|
||||
'notes.sharedWithFollowers': 'Delt med {name} — og de {count} noter, den peger på.',
|
||||
'notes.shareFollows': 'Følger med:',
|
||||
'notes.shareVia': 'følger med “{note}”',
|
||||
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||
'notes.save': 'Gem',
|
||||
@@ -76,7 +79,8 @@ export const da = {
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
@@ -119,6 +123,7 @@ export const da = {
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
'nav.search': 'Søg',
|
||||
'nav.searchLong': 'Søg i opgaver og projekter',
|
||||
@@ -450,6 +455,8 @@ export const da = {
|
||||
'view.filterEmpty': 'Ingenting matcher lige nu.',
|
||||
'view.filterFailed': 'Filteret kunne ikke køres.',
|
||||
'view.upcoming': 'Kommende',
|
||||
'view.horizon': 'Periode',
|
||||
'view.days': '{n} dage',
|
||||
'view.mode': 'Visning',
|
||||
'view.list': 'Liste',
|
||||
'view.week': 'Uge',
|
||||
@@ -659,7 +666,7 @@ export const da = {
|
||||
// --- settings: integrations --------------------------------------------------------
|
||||
'int.mailboxes': 'Postkasser',
|
||||
'int.mailboxesHint':
|
||||
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
|
||||
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave — flaget fjernes, når opgaven er oprettet. Kun én vej: gør du selv noget ved mailen bagefter, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
|
||||
'int.addMailbox': 'Tilføj en postkasse',
|
||||
'int.host': 'IMAP-server',
|
||||
'int.username': 'Brugernavn',
|
||||
@@ -675,7 +682,7 @@ export const da = {
|
||||
'int.fetching': 'Henter …',
|
||||
'int.gmail': 'Gmail',
|
||||
'int.gmailHint':
|
||||
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
|
||||
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave — stjernen fjernes så (en etiket bliver, hvor den er). Det du selv gør ved mailen bagefter, ændrer ikke opgaven.',
|
||||
'int.connectGmail': 'Forbind Gmail',
|
||||
'int.fromEnv':
|
||||
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
|
||||
@@ -843,6 +850,8 @@ export const da = {
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Nordlys',
|
||||
'theme.darkNote': 'Mørk med grøn accent.',
|
||||
'theme.charcoal': 'Kul',
|
||||
'theme.charcoalNote': 'Neutral mørkegrå.',
|
||||
'theme.dusk': 'Skumring',
|
||||
'theme.duskNote': 'Mørk og varm, med rav.',
|
||||
'theme.light': 'Dagslys',
|
||||
|
||||
@@ -19,7 +19,7 @@ export const en = {
|
||||
// Sharing with individual people, alongside the project.
|
||||
'notes.groupSharedWithMe': 'Shared with me',
|
||||
'notes.linkOpen': 'Open',
|
||||
'notes.linkMissing': 'That note could not be found.',
|
||||
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
||||
'notes.sharedBy': 'Shared by {name}',
|
||||
'notes.sharePeople': 'Share with people',
|
||||
'notes.sharePick': 'Choose someone …',
|
||||
@@ -30,6 +30,9 @@ export const en = {
|
||||
'notes.shareRemove': 'Remove access',
|
||||
'notes.shareNobody': 'Not shared with anyone yet.',
|
||||
'notes.sharedToast': 'Shared with {name}.',
|
||||
'notes.sharedWithFollowers': 'Shared with {name} — along with the {count} notes it points at.',
|
||||
'notes.shareFollows': 'Comes along:',
|
||||
'notes.shareVia': 'came along with “{note}”',
|
||||
'notes.unsharedPerson': '{name} can no longer see the note.',
|
||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||
'notes.save': 'Save',
|
||||
@@ -72,7 +75,8 @@ export const en = {
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
'notes.source': 'Note source',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
@@ -115,6 +119,7 @@ export const en = {
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.signOutConfirm': 'Click again to sign out',
|
||||
'nav.main': 'Main menu',
|
||||
'nav.search': 'Search',
|
||||
'nav.searchLong': 'Search tasks and projects',
|
||||
@@ -445,6 +450,8 @@ export const en = {
|
||||
'view.filterEmpty': 'Nothing matches right now.',
|
||||
'view.filterFailed': 'The filter could not be run.',
|
||||
'view.upcoming': 'Upcoming',
|
||||
'view.horizon': 'Range',
|
||||
'view.days': '{n} days',
|
||||
'view.mode': 'View',
|
||||
'view.list': 'List',
|
||||
'view.week': 'Week',
|
||||
@@ -655,7 +662,7 @@ export const en = {
|
||||
// --- settings: integrations --------------------------------------------------------
|
||||
'int.mailboxes': 'Mailboxes',
|
||||
'int.mailboxesHint':
|
||||
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
|
||||
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task — the flag comes off once it has. One way only: what you do to the mail afterwards does not change the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
|
||||
'int.addMailbox': 'Add a mailbox',
|
||||
'int.host': 'IMAP server',
|
||||
'int.username': 'Username',
|
||||
@@ -671,7 +678,7 @@ export const en = {
|
||||
'int.fetching': 'Fetching …',
|
||||
'int.gmail': 'Gmail',
|
||||
'int.gmailHint':
|
||||
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
|
||||
'Star an email, or give it a label, and it becomes a task — the star then comes off (a label stays where it is). What you do to the mail afterwards does not change the task.',
|
||||
'int.connectGmail': 'Connect Gmail',
|
||||
'int.fromEnv':
|
||||
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
|
||||
@@ -836,6 +843,8 @@ export const en = {
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Northern lights',
|
||||
'theme.darkNote': 'Dark, with a green accent.',
|
||||
'theme.charcoal': 'Charcoal',
|
||||
'theme.charcoalNote': 'Neutral dark grey.',
|
||||
'theme.dusk': 'Dusk',
|
||||
'theme.duskNote': 'Dark and warm, with amber.',
|
||||
'theme.light': 'Daylight',
|
||||
|
||||
@@ -26,9 +26,13 @@ export function permission() {
|
||||
}
|
||||
|
||||
async function registration() {
|
||||
// `ready` waits for an active worker; register() alone resolves while the
|
||||
// worker is still installing, and subscribing against that fails.
|
||||
await navigator.serviceWorker.register('/sw.js');
|
||||
// SvelteKit registers src/service-worker.js itself on load, so normally there
|
||||
// is already a registration here. Register it by hand only if there is not yet
|
||||
// one — a push subscribe that races the app's own registration would otherwise
|
||||
// find nothing. `ready` waits for an active worker; register() alone resolves
|
||||
// while the worker is still installing, and subscribing against that fails.
|
||||
const existing = await navigator.serviceWorker.getRegistration();
|
||||
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
|
||||
@@ -865,8 +865,8 @@ class SidebarLayout {
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/**
|
||||
* Whether Kommende shows the next seven days as a list, one week as a grid, or the
|
||||
* whole month.
|
||||
* Whether Kommende shows a run of days as a list, one week as a grid, or the whole
|
||||
* month.
|
||||
*
|
||||
* In localStorage rather than on the account, and for the same reason as the
|
||||
* sidebar's width: a grid of seven columns needs room, and the answer on a phone is
|
||||
@@ -878,24 +878,48 @@ export const sidebar = new SidebarLayout();
|
||||
*/
|
||||
const UPCOMING_MODES = ['list', 'week', 'calendar'];
|
||||
|
||||
/**
|
||||
* How far the list looks ahead, in days.
|
||||
*
|
||||
* Seven days is a week's horizon and answers "what is coming"; thirty answers
|
||||
* "when does this month get busy", which is the question a month grid answers by
|
||||
* shape but not in order. The list is the only mode that takes a horizon — the
|
||||
* grids are anchored to a week and a month, and a fortnight is neither.
|
||||
*
|
||||
* The server caps the window at 31 days, so nothing here may exceed that.
|
||||
*/
|
||||
export const UPCOMING_DAYS = [7, 14, 30];
|
||||
|
||||
class UpcomingView {
|
||||
mode = $state(read());
|
||||
mode = $state(readChoice('verdande:upcoming', UPCOMING_MODES, 'list'));
|
||||
days = $state(readChoice('verdande:upcoming-days', UPCOMING_DAYS, 7, Number));
|
||||
|
||||
set(next) {
|
||||
if (!UPCOMING_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:upcoming', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
writeChoice('verdande:upcoming', next);
|
||||
}
|
||||
|
||||
setDays(next) {
|
||||
const n = Number(next);
|
||||
if (!UPCOMING_DAYS.includes(n)) return;
|
||||
this.days = n;
|
||||
writeChoice('verdande:upcoming-days', n);
|
||||
}
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (typeof localStorage === 'undefined') return 'list';
|
||||
const stored = localStorage.getItem('verdande:upcoming');
|
||||
return UPCOMING_MODES.includes(stored) ? stored : 'list';
|
||||
function readChoice(key, allowed, fallback, parse = String) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const stored = parse(localStorage.getItem(key));
|
||||
return allowed.includes(stored) ? stored : fallback;
|
||||
}
|
||||
|
||||
function writeChoice(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
@@ -1006,6 +1030,7 @@ export const completedView = new CompletedView();
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
|
||||
{ id: 'charcoal', name: 'theme.charcoal', note: 'theme.charcoalNote', dark: true },
|
||||
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
|
||||
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
||||
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
let totpSecret = $state('');
|
||||
let totpURI = $state('');
|
||||
let totpQR = $state('');
|
||||
let totpCode = $state('');
|
||||
let totpErrors = $state({});
|
||||
let recoveryCodes = $state([]);
|
||||
@@ -163,6 +164,7 @@
|
||||
enablePassword = '';
|
||||
totpSecret = r.secret;
|
||||
totpURI = r.uri;
|
||||
totpQR = r.qr;
|
||||
} catch (e) {
|
||||
totpErrors = e.fields ?? {};
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
@@ -177,6 +179,7 @@
|
||||
recoveryCodes = r.recovery_codes;
|
||||
totpSecret = '';
|
||||
totpURI = '';
|
||||
totpQR = '';
|
||||
totpCode = '';
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
@@ -492,8 +495,13 @@
|
||||
{:else if totpSecret}
|
||||
<div class="field">
|
||||
<p class="hint">{t('account.scanHint')}</p>
|
||||
{#if totpQR}
|
||||
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
|
||||
white square, so it scans in either theme. It is the URI it already
|
||||
sent, drawn; nothing here builds it. -->
|
||||
<div class="qr">{@html totpQR}</div>
|
||||
{/if}
|
||||
<p class="mono secret">{totpSecret}</p>
|
||||
<p class="hint mono uri">{totpURI}</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={confirmTOTP}>
|
||||
@@ -514,7 +522,14 @@
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit">{t('account.confirm')}</button>
|
||||
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
|
||||
<button
|
||||
class="secondary"
|
||||
onclick={() => {
|
||||
totpSecret = '';
|
||||
totpURI = '';
|
||||
totpQR = '';
|
||||
}}>{t('account.cancel')}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
@@ -859,6 +874,23 @@
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* On its own white card whatever the theme: a QR is dark-on-light or it does not
|
||||
scan, so the square keeps its own background rather than taking the page's. */
|
||||
.qr {
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
padding: var(--s2);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.qr :global(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
@@ -869,10 +901,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uri {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
||||
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||
.bmc {
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
|
||||
<!-- The registration behind the button, for whoever can do something about
|
||||
it. Google issues no Gmail access to an unregistered client, and
|
||||
gmail.readonly is a restricted scope — so this step cannot be skipped by
|
||||
gmail.modify is a restricted scope — so this step cannot be skipped by
|
||||
any app. It can stop requiring a redeploy, which is what this is. -->
|
||||
{#if app.user?.is_admin && client}
|
||||
{#if client.from_env}
|
||||
|
||||
@@ -192,6 +192,10 @@
|
||||
// hentes kun for en note, man selv har lavet.
|
||||
let shares = $state([]);
|
||||
let shareCandidates = $state([]);
|
||||
// De noter, denne note tager med sig, når den deles. Ejeren skal kunne se dem:
|
||||
// en deling, der stille tager tre noter mere med, er rigtig og alligevel ikke
|
||||
// til at overskue.
|
||||
let shareFollows = $state([]);
|
||||
let sharePick = $state('');
|
||||
let shareRole = $state('viewer');
|
||||
let shareBusy = $state(false);
|
||||
@@ -223,6 +227,7 @@
|
||||
// under den nye.
|
||||
shares = [];
|
||||
shareCandidates = [];
|
||||
shareFollows = [];
|
||||
sharePick = '';
|
||||
if (!id || selected.created_by !== app.user?.id) return;
|
||||
let alive = true;
|
||||
@@ -232,6 +237,7 @@
|
||||
if (!alive || selected?.id !== id) return;
|
||||
shares = r.shares ?? [];
|
||||
shareCandidates = r.candidates ?? [];
|
||||
shareFollows = r.follows ?? [];
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -246,10 +252,20 @@
|
||||
const person = shareCandidates.find((p) => p.id === sharePick);
|
||||
shareBusy = true;
|
||||
try {
|
||||
await api.shareNote(selected.id, sharePick, shareRole);
|
||||
const r = await api.shareNote(selected.id, sharePick, shareRole);
|
||||
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
|
||||
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
|
||||
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
|
||||
shareFollows = r?.follows ?? shareFollows;
|
||||
// Sagt frem for stiltiende. Delingen tog flere noter med, og den, der
|
||||
// delte, skal vide hvor mange, uden at skulle læse panelet efter.
|
||||
app.toast(
|
||||
shareFollows.length
|
||||
? t('notes.sharedWithFollowers', {
|
||||
name: person?.name ?? '',
|
||||
count: String(shareFollows.length)
|
||||
})
|
||||
: t('notes.sharedToast', { name: person?.name ?? '' })
|
||||
);
|
||||
sharePick = '';
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
@@ -665,17 +681,38 @@
|
||||
* to their own pages. Until now these were text and nothing else: the note said
|
||||
* it pointed somewhere and there was no way to go.
|
||||
*/
|
||||
/**
|
||||
* Følger et link — og siger det, når det ikke kan følges.
|
||||
*
|
||||
* Et `[[link]]` peger på en note ved dens *titel*, så det skal slås op. Først i
|
||||
* listen, der allerede er hentet, og ellers med en søgning, fordi listen kan
|
||||
* være snævret ind af et søgeord.
|
||||
*
|
||||
* Kun en titel, der passer helt. Der stod `?? found[0]` her, så et link, der
|
||||
* ikke kunne findes, åbnede den første note, søgningen tilfældigvis gav — og i
|
||||
* en *delt* note er det næsten altid den note, man allerede står i: den nævner
|
||||
* jo titlen, den linker til. Så linket så ud til ikke at gøre noget som helst,
|
||||
* hvilket er præcis sådan det blev meldt. Et link, der rammer forbi, skal sige
|
||||
* det, ikke ramme et andet sted.
|
||||
*
|
||||
* Beskeden nævner delingen, fordi det er den almindelige årsag: en note, der
|
||||
* deles, deler ikke de noter, den peger på, og modtageren kan derfor se linket
|
||||
* uden at kunne åbne det. Herfra kan de to tilfælde ikke skelnes — svaret er
|
||||
* det samme, hvad enten noten ikke findes eller bare ikke er ens egen — og
|
||||
* sætningen siger dem begge frem for at gætte på den ene.
|
||||
*/
|
||||
async function openLink(link) {
|
||||
if (link.kind === 'note') {
|
||||
const wanted = link.target_id.toLowerCase();
|
||||
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
||||
const wanted = link.target_id.trim().toLowerCase();
|
||||
const titled = (n) => (n.title ?? '').trim().toLowerCase();
|
||||
const here = notes.find((n) => titled(n) === wanted);
|
||||
if (here) {
|
||||
open(here);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
||||
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
|
||||
const hit = found.find((n) => titled(n) === wanted);
|
||||
if (hit) open(hit);
|
||||
else app.toast(t('notes.linkMissing'));
|
||||
} catch (e) {
|
||||
@@ -707,7 +744,10 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="notes">
|
||||
<!-- `has-note` drives the phone layout: one column, showing the list until a note
|
||||
is opened and the editor once one is. On a wide screen both are always up and
|
||||
the class does nothing. -->
|
||||
<div class="notes" class:has-note={selectedId}>
|
||||
<aside>
|
||||
<div class="head">
|
||||
<h1>{t('notes.title')}</h1>
|
||||
@@ -916,7 +956,15 @@
|
||||
</aside>
|
||||
|
||||
<section class="editor">
|
||||
{#if selected}
|
||||
{#if selectedId}
|
||||
<!-- Keyed on the id, which is set the instant a row is tapped — not on the
|
||||
loaded note, which arrives a moment later. On a phone that moment was the
|
||||
whole bug: the pane did not switch until the body had loaded, so the first
|
||||
tap looked like nothing and people tapped again. -->
|
||||
<!-- Phone only (hidden on a wide screen by CSS): the way back to the list,
|
||||
since there the editor fills the screen and the list is not beside it. -->
|
||||
<button class="back" onclick={() => open(null)}>‹ {t('notes.title')}</button>
|
||||
{#if selected}
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
notes={notes}
|
||||
@@ -992,25 +1040,49 @@
|
||||
></span>
|
||||
{sh.user.name}
|
||||
</span>
|
||||
<select
|
||||
value={sh.role}
|
||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
||||
>
|
||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||
</select>
|
||||
<button
|
||||
class="unshare"
|
||||
onclick={() => removeShare(sh.user.id)}
|
||||
title={t('notes.shareRemove')}
|
||||
aria-label={t('notes.shareRemove')}>×</button
|
||||
>
|
||||
<!-- En adgang, der er fulgt med et link, er ikke valgt her og
|
||||
kan ikke tages tilbage her: den følger den note, der peger
|
||||
på denne. Rollen og krydset ville begge love noget, det
|
||||
næste opslag rullede tilbage — så de står ikke. -->
|
||||
{#if sh.linked}
|
||||
<span class="viafollow"
|
||||
>{t('notes.shareVia', { note: sh.via ?? '' })}</span
|
||||
>
|
||||
{:else}
|
||||
<select
|
||||
value={sh.role}
|
||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
||||
>
|
||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||
</select>
|
||||
<button
|
||||
class="unshare"
|
||||
onclick={() => removeShare(sh.user.id)}
|
||||
title={t('notes.shareRemove')}
|
||||
aria-label={t('notes.shareRemove')}>×</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span class="none">{t('notes.shareNobody')}</span>
|
||||
{/if}
|
||||
{#if shareFollows.length}
|
||||
<!-- Hvad delingen tager med sig. Et link i en delt note skal føre
|
||||
samme sted hen for begge, og det kræver, at det, den peger på,
|
||||
også er delt — men så skal det stå her, hvor den, der deler,
|
||||
kan se det. -->
|
||||
<p class="follows">
|
||||
{t('notes.shareFollows')}
|
||||
{#each shareFollows as f, i (f.note_id)}<button
|
||||
class="followlink"
|
||||
onclick={() => openLink({ kind: 'note', target_id: f.title })}
|
||||
>{f.title}</button
|
||||
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{#if shareCandidates.length}
|
||||
<div class="addshare">
|
||||
<select bind:value={sharePick}>
|
||||
@@ -1050,6 +1122,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{:else}
|
||||
<!-- Chosen, but its full text is still loading. Keep the pane (so the tap
|
||||
felt like it landed) and stay quiet — the "pick a note" line is for
|
||||
when nothing is chosen at all, not for this half-second. -->
|
||||
<p class="empty" aria-busy="true"></p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">{t('notes.pickOne')}</p>
|
||||
{/if}
|
||||
@@ -1068,8 +1146,9 @@
|
||||
against the sidebar's rule, which reads as a rendering fault rather than
|
||||
as a layout. */
|
||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||
ingen kant havde og ingenting kunne støde imod. */
|
||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
||||
ingen kant havde og ingenting kunne støde imod. Et par pixels mere om
|
||||
notelisten, så kortet ikke ligger klods op ad menuen. */
|
||||
padding: var(--s3) var(--s4) var(--s3) calc(var(--s4) + 3px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -1083,13 +1162,15 @@
|
||||
gap: var(--s2);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: var(--s3);
|
||||
/* A rounded card, the same corner and border as the editor beside it — the
|
||||
list is its own sheet, not a column ruled off the page. */
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
/* White in light, a nuance lighter than the ground in dark — the note list
|
||||
is a surface you read down, so it lifts off the page like the editor does.
|
||||
--surface-raised is #fff in the light themes and one step up in the dark. */
|
||||
background: var(--surface-raised);
|
||||
padding-left: var(--s3);
|
||||
padding: var(--s3);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -1386,7 +1467,10 @@
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin: 0 0 var(--s1);
|
||||
background: var(--ground);
|
||||
/* --ground everywhere but Kul, where the ground is the darkest thing on screen
|
||||
and a day separator on it reads as a dark bar. There it takes the menu's own
|
||||
resting colour instead, via --note-group-bg. */
|
||||
background: var(--note-group-bg, var(--ground));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@@ -1472,13 +1556,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
|
||||
spends the first applying the hover and opens on the second. Gated, the first
|
||||
tap opens the note. The row's buttons are shown outright on touch below, since
|
||||
there is no hover to reveal them. */
|
||||
@media (hover: hover) {
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.star,
|
||||
.archive-one {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
background: var(--note-on-bg, var(--surface-raised));
|
||||
color: var(--note-on-ink, var(--ink));
|
||||
}
|
||||
|
||||
/* On a themed selection colour (Kul's gold), the row's own quieter tiers go dark
|
||||
too, or the date and the filed line vanish into it. Other themes have no
|
||||
--note-on-ink, so these fall back to their normal muted inks. */
|
||||
.row.on .when,
|
||||
.row.on .filed {
|
||||
color: var(--note-on-ink, var(--ink-muted));
|
||||
}
|
||||
|
||||
.row.on .filed-icon {
|
||||
color: var(--note-on-ink, var(--ink-faint));
|
||||
}
|
||||
|
||||
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
||||
@@ -1574,8 +1683,10 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
/* A nuance lighter than the ground in dark, still white in light — matches
|
||||
the note list beside it so the two read as one writing surface. */
|
||||
background: var(--surface-raised);
|
||||
the note list beside it so the two read as one writing surface. Kul gives
|
||||
the text field its own darker grey via --editor-bg; other themes fall back
|
||||
to the shared surface so the list and the editor stay the same colour. */
|
||||
background: var(--editor-bg, var(--surface-raised));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
/* Luft nok til at teksten ikke rører kanten.
|
||||
@@ -1839,6 +1950,38 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Adgang, der er fulgt med et link. Sat i stedet for rollen og krydset, altså
|
||||
yderst til højre, hvor øjet allerede leder efter "hvad kan denne række". */
|
||||
.sharelist .viafollow {
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Hvad delingen tager med sig. Under listen af folk, fordi det gælder dem alle
|
||||
— ikke én linje pr. person, som ville sige det samme tre gange. */
|
||||
.follows {
|
||||
margin: var(--s1) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.followlink {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.followlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.addshare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1894,14 +2037,48 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* The way back to the list on a phone — an iOS-style "‹ Noter" at the top of the
|
||||
editor. Hidden on a wide screen, where the list is always beside the editor. */
|
||||
.back {
|
||||
display: none;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
margin-bottom: var(--s2);
|
||||
padding: var(--s1) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.notes {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
aside {
|
||||
border-right: 0;
|
||||
padding-right: 0;
|
||||
/* One pane at a time. The two-column card layout has no room on a phone, so
|
||||
the list shows until a note is opened and the editor takes over once one is
|
||||
— the back button is the way between them. Without this the editor stacked
|
||||
below a full-height list, so a tapped note opened out of sight. */
|
||||
.notes.has-note aside {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notes:not(.has-note) .editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Full-bleed: the card border and rounded corners are for sitting beside
|
||||
another card, and on one column there is nothing beside it. */
|
||||
aside,
|
||||
.editor {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
/**
|
||||
* Kommende: the next seven days as a list, one week as a grid, or the month.
|
||||
* Kommende: a run of days as a list, one week as a grid, or the month.
|
||||
*
|
||||
* The views ask for different things and none is a subset of the others — seven
|
||||
* days from today, against a chosen week, against six whole weeks around a
|
||||
@@ -15,7 +15,7 @@
|
||||
* A week that straddles the 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app, upcomingView } from '$lib/stores.svelte.js';
|
||||
import { app, upcomingView, UPCOMING_DAYS } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
let days = $state([]);
|
||||
|
||||
// Reads `upcomingView.days` inside the effect on purpose: that is what makes
|
||||
// choosing a longer horizon reload, rather than extending the strip with days
|
||||
// nobody asked the server about.
|
||||
$effect(() => {
|
||||
if (upcomingView.mode !== 'list') return;
|
||||
api.upcoming().then((data) => {
|
||||
api.upcoming(upcomingView.days).then((data) => {
|
||||
days = data.days;
|
||||
app.tasks = data.days.flatMap((d) => d.tasks);
|
||||
});
|
||||
@@ -53,6 +56,21 @@
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// A month of headings repeats every weekday four times, and "mandag 6. okt"
|
||||
// alone does not say which mandag. The week number is the label that does, and
|
||||
// it only earns its place once the strip is longer than a week.
|
||||
function weekOf(date) {
|
||||
if (upcomingView.days <= 7) return null;
|
||||
const day = new Date(date + 'T00:00:00');
|
||||
// ISO weeks: Thursday decides which year and which week a row belongs to.
|
||||
const thursday = new Date(day);
|
||||
thursday.setDate(day.getDate() - ((day.getDay() + 6) % 7) + 3);
|
||||
const firstThursday = new Date(thursday.getFullYear(), 0, 4);
|
||||
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3);
|
||||
const n = 1 + Math.round((thursday - firstThursday) / (7 * 86400000));
|
||||
return t('view.weekNumber', { n });
|
||||
}
|
||||
|
||||
// Reading tasks back out of the store keeps a completed row disappearing
|
||||
// immediately rather than on the next load. It is also what makes a task
|
||||
// dragged from one day to another land in its new section straight away: the
|
||||
@@ -82,6 +100,19 @@
|
||||
<div class="view" class:wide={upcomingView.mode !== 'list'}>
|
||||
<header>
|
||||
<h1>{t('view.upcoming')}</h1>
|
||||
{#if upcomingView.mode === 'list'}
|
||||
<!-- Only the list takes a horizon. A week grid is a week and a month grid
|
||||
is a month; there is no fortnight for them to show. -->
|
||||
<div class="views" role="group" aria-label={t('view.horizon')}>
|
||||
{#each UPCOMING_DAYS as n}
|
||||
<button
|
||||
class:active={upcomingView.days === n}
|
||||
onclick={() => upcomingView.setDays(n)}
|
||||
aria-pressed={upcomingView.days === n}>{t('view.days', { n })}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['list', t('view.list')], ['week', t('view.week')], ['calendar', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
@@ -102,6 +133,7 @@
|
||||
{:else}
|
||||
{#each days as day (day.date)}
|
||||
{@const tasks = live(day.date)}
|
||||
{@const week = weekOf(day.date)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class:over={over === day.date}
|
||||
@@ -109,7 +141,10 @@
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, day.date)}
|
||||
>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
<h2>
|
||||
{heading(day.date)}
|
||||
{#if week}<span class="week">{week}</span>{/if}
|
||||
</h2>
|
||||
{#if tasks.length}
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -210,6 +245,10 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
@@ -220,6 +259,14 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
/* Lighter than the day it sits beside: it is there to be found when you are
|
||||
lost in the fourth week, not read on every row. */
|
||||
.week {
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* An em dash rather than "ingen opgaver": seven repetitions of a sentence is
|
||||
noise, and the reader only needs to know the day is clear. */
|
||||
.empty {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/* global self, caches, fetch, Response */
|
||||
|
||||
/**
|
||||
* verdande's service worker: Web Push, and a cache that holds through a short
|
||||
* network drop.
|
||||
*
|
||||
* It used to do Push and nothing else, with a note that a cache serving yesterday's
|
||||
* data was worse than an honest "cannot connect". The cache here does not do that.
|
||||
* It is network-FIRST for everything that can go stale — a reader online always gets
|
||||
* the live answer, and the cache is only reached for when the network is not — so it
|
||||
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
|
||||
* lift, a base-station handover. The five themes' worth of live task list stays live;
|
||||
* it just no longer goes blank the moment the signal does.
|
||||
*
|
||||
* Writes are never touched here — they go straight to the network, and api.js already
|
||||
* retries them across a short drop. Caching a write would mean a queue and conflicts,
|
||||
* which is a different and much larger feature.
|
||||
*/
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
|
||||
// old one is dropped on activate — the app can never be served half of two builds.
|
||||
const SHELL = `verdande-shell-${version}`;
|
||||
// Not versioned: the data does not belong to a build, and clearing it every deploy
|
||||
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
|
||||
const API = 'verdande-api';
|
||||
|
||||
// The shell: every hashed build asset, the static files, and the SPA fallback so a
|
||||
// navigation can be answered from the cache when the network cannot answer it.
|
||||
const SHELL_ASSETS = [...build, ...files, '/'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL);
|
||||
await cache.addAll(SHELL_ASSETS);
|
||||
// Take over at the next opportunity rather than waiting for every tab to
|
||||
// close — paired with clients.claim below and the versioned cache, a deploy
|
||||
// reaches the user on their next load.
|
||||
await self.skipWaiting();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
// Drop old shell versions; keep this shell and the data cache.
|
||||
if (key !== SHELL && key !== API) await caches.delete(key);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Writes go to the network, full stop — the app retries them itself, and a cached
|
||||
// write is a promise this cannot keep. Cross-origin is none of our business.
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Hashed build assets and static files never change for a given URL: cache-first,
|
||||
// so a repeat load and an offline load are both instant.
|
||||
if (isAsset(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request, SHELL));
|
||||
return;
|
||||
}
|
||||
|
||||
// The data: network-first so an online reader always sees the live list, with the
|
||||
// last good copy kept for when the network is gone.
|
||||
if (url.pathname.startsWith('/api/v1/')) {
|
||||
event.respondWith(networkFirst(request, API));
|
||||
return;
|
||||
}
|
||||
|
||||
// A navigation (reload, or opening the app): the live page when there is one, the
|
||||
// cached shell when there is not — so the app opens through a drop instead of
|
||||
// showing the browser's dinosaur.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(navigate(request));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(request);
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const res = await fetch(request);
|
||||
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
|
||||
// last good copy, or an expired session would be served back offline as fact.
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
} catch (err) {
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function navigate(request) {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch {
|
||||
const cache = await caches.open(SHELL);
|
||||
const shell = await cache.match('/');
|
||||
return shell ?? Response.error();
|
||||
}
|
||||
}
|
||||
|
||||
// Sign-out asks for the data cache to be emptied: the next person on this browser must
|
||||
// not find the last one's tasks sitting in it.
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'clear-api-cache') {
|
||||
event.waitUntil(caches.delete(API));
|
||||
}
|
||||
});
|
||||
|
||||
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
// A push whose body is not our JSON still deserves to be shown: browsers show
|
||||
// their own "site updated in the background" notice otherwise, which looks like
|
||||
// a bug in the app.
|
||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
|
||||
const title = payload.title || 'verdande';
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.body || '',
|
||||
icon: '/icon.svg',
|
||||
badge: '/icon.svg',
|
||||
// The tag collapses repeats: five comments on one task should replace each
|
||||
// other rather than stack into five separate notifications.
|
||||
tag: payload.tag || payload.url || 'verdande',
|
||||
data: { url: payload.url || '/' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const target = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientList = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
});
|
||||
|
||||
// Focus a tab that already has the app open rather than opening a second one.
|
||||
// Somebody with the app pinned does not want a new tab per reminder.
|
||||
for (const client of clientList) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
await client.focus();
|
||||
if ('navigate' in client) await client.navigate(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})()
|
||||
);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* verdande's service worker.
|
||||
*
|
||||
* It exists for Web Push and nothing else. There is deliberately no offline cache:
|
||||
* the app is a live view of a task list that other people are editing, and a cached
|
||||
* shell serving yesterday's data is worse than a browser saying it cannot connect.
|
||||
* Adding one later means versioning and invalidating it, which is a real feature
|
||||
* rather than a few lines here.
|
||||
*/
|
||||
|
||||
// Take over as soon as the browser is willing. Without these two, the first push
|
||||
// after granting permission arrives with no worker in control to show it.
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
// A push whose body is not our JSON still deserves to be shown: browsers
|
||||
// display their own "site updated in the background" notice otherwise, which
|
||||
// looks like a bug in the app.
|
||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
|
||||
const title = payload.title || 'verdande';
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.body || '',
|
||||
icon: '/icon.svg',
|
||||
badge: '/icon.svg',
|
||||
// The tag collapses repeats: five comments on one task should replace each
|
||||
// other rather than stack into five separate notifications.
|
||||
tag: payload.tag || payload.url || 'verdande',
|
||||
data: { url: payload.url || '/' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const target = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientList = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
});
|
||||
|
||||
// Focus a tab that already has the app open rather than opening a second
|
||||
// one. Somebody with the app pinned does not want a new tab per reminder.
|
||||
for (const client of clientList) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
await client.focus();
|
||||
if ('navigate' in client) await client.navigate(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})()
|
||||
);
|
||||
});
|
||||