Compare commits
@@ -66,3 +66,6 @@ node_modules.nosync/
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -90,21 +95,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Project templates | Done, tested |
|
||||
| Nightly backups with rotation | Done, tested |
|
||||
| Comments, attachments, notifications, Web Push | Built |
|
||||
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
|
||||
| Notes in and out: a zip of Markdown, both directions | Done, tested |
|
||||
| Todoist CSV import and export, account export | Done, tested |
|
||||
| MCP server for Claude | Done, tested |
|
||||
| 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 |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Everything else has an interface: opening a task shows
|
||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||
API calls are implemented against their specifications and have only ever been
|
||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
@@ -116,17 +124,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
||||
|  |  |
|
||||
| Five themes, all measured for contrast. | What has been done on the server. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
|
||||
|  |  |
|
||||
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
|
||||
|  |  |
|
||||
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
|
||||
|
||||
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
||||
the real binary, creates its own account, fills it with demo data and photographs
|
||||
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
|
||||
still works, and is the way to photograph an instance that is already running.)
|
||||
the real binary, creates its own account and a second one to share with, fills them
|
||||
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
|
||||
remember. (`go run ./tools/shots` still works, and is the way to photograph an
|
||||
instance that is already running.)
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ func run() error {
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, and they are not the same thing.
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|
||||
|---|---|---|
|
||||
| Direction | Read only | Two-way |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
|
||||
| You can tick things off | No | Yes |
|
||||
| Setup | Paste a URL | Username and token |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
| You can tick things off | No | Yes | No — an event is not a task |
|
||||
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
|
||||
|
||||
## Subscribing to a feed
|
||||
|
||||
@@ -39,6 +40,42 @@ most people actually keep.
|
||||
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
||||
immediately.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
This is the one where ticking something off in Apple Reminders ticks it off here.
|
||||
|
||||
@@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`.
|
||||
yourself, password resets are written to the log, and reminders arrive in the
|
||||
app and as Web Push rather than by email.
|
||||
|
||||
## Gmail
|
||||
## Google
|
||||
|
||||
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
|
||||
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
|
||||
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
|
||||
and add the `gmail.readonly` scope.
|
||||
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
|
||||
registration covers both: register an OAuth client in Google Cloud
|
||||
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
|
||||
**both** redirect URIs:
|
||||
|
||||
```
|
||||
https://your-address/oauth/gmail/callback
|
||||
https://your-address/oauth/calendar/callback
|
||||
```
|
||||
|
||||
Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
|---|---|
|
||||
@@ -52,8 +64,15 @@ and add the `gmail.readonly` scope.
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
||||
|
||||
One registration serves every user on the instance; each person authorises their
|
||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
||||
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
|
||||
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
|
||||
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
|
||||
|
||||
!!! note "Two connections, two grants"
|
||||
Connecting a calendar does not disturb a working Gmail connection, and
|
||||
disconnecting one does not take the other with it. Google issues a refresh
|
||||
token per authorisation and keeps up to a hundred of them live per account,
|
||||
each carrying only the scopes it was granted with.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
- **Drag where it means something** — reorder a list or a board, file a project
|
||||
under a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
|
||||
## What it looks like
|
||||
|
||||

|
||||
|
||||
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
|
||||
dragged from one day onto another.*
|
||||
|
||||

|
||||
|
||||
*The same project as a list, where sections read as bands. As a board they are
|
||||
columns.*
|
||||
|
||||

|
||||
|
||||
*What you have handed to somebody else, grouped by whoever has it.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
[mail-to-task address](mail.md) are connected.*
|
||||
|
||||

|
||||
|
||||
*Inviting somebody to the instance. There is no open registration — everybody
|
||||
arrives through a link, and picks their own password. See
|
||||
[signing in](signing-in.md).*
|
||||
|
||||
## Getting it running
|
||||
|
||||
verdande is built to be deployed as a **Rune** in
|
||||
|
||||
@@ -16,7 +16,7 @@ administrator who can read yours.
|
||||
**Settings → Integrations → Gmail → Connect.**
|
||||
|
||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
||||
rather than anybody's account — see [Configuration](configuration.md#gmail).
|
||||
rather than anybody's account — see [Configuration](configuration.md#google).
|
||||
Once it is set up, each person connects their own mailbox through it.
|
||||
|
||||
Choose what makes a task:
|
||||
|
||||
@@ -1396,6 +1396,153 @@ paths:
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/{noteID}/attachments:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Put a file into a note.
|
||||
description: |
|
||||
A picture pasted or dropped into a note. Until this existed the import
|
||||
could bring files in and nothing else could, so the most ordinary act in
|
||||
a notes app — paste a screenshot — did nothing at all.
|
||||
|
||||
Editor rights on the note. The same answer for "no such note" and "not
|
||||
yours", so an id cannot be probed.
|
||||
|
||||
The response carries the attachment's URL; the editor writes that into the
|
||||
note's text as a Markdown image. The file is never inlined as a data URL —
|
||||
a photograph as base64 would make the note itself megabytes of text.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
"201":
|
||||
description: Stored, and hung on the note.
|
||||
"404":
|
||||
description: No such note, or not yours to write in.
|
||||
"413":
|
||||
description: Larger than 25 MB.
|
||||
|
||||
/api/v1/beacon:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Report that an installation exists. Unauthenticated.
|
||||
description: |
|
||||
The receiving end of the beacon. Open on purpose: the caller is a
|
||||
stranger's installation, which is the whole feature.
|
||||
|
||||
Answers 404 unless this instance has been told it is the collector, so an
|
||||
ordinary installation is not a spam sink that answers 200 to anything.
|
||||
Rate limited like the login routes, and the table behind it has a ceiling:
|
||||
the endpoint inserts a row keyed on an id the caller invented, so without
|
||||
one anybody could grow it until the volume is full.
|
||||
|
||||
Nothing about the request is recorded but the two values in the body. No
|
||||
source address is read or written here.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [instance_id]
|
||||
properties:
|
||||
instance_id:
|
||||
type: string
|
||||
description: A random id the installation generated for itself.
|
||||
version:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Recorded.
|
||||
"400":
|
||||
description: The id or the version is not the expected shape.
|
||||
"404":
|
||||
description: This instance is not a collector.
|
||||
|
||||
/api/v1/beacon/count:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: How many installations reported in the last thirty days.
|
||||
description: |
|
||||
Unauthenticated and cached for a minute, and only answered when the
|
||||
collector has been told to publish it.
|
||||
|
||||
A window rather than a running total: a total only climbs, so it stops
|
||||
meaning "how many people use this" and starts meaning "how many ever
|
||||
tried it".
|
||||
responses:
|
||||
"200":
|
||||
description: The count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
|
||||
/api/v1/beacon/settings:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What this instance reports, and to whom. Administrators only.
|
||||
description: |
|
||||
Includes the exact payload that will be sent, so the settings page can
|
||||
print it in full rather than describe it.
|
||||
responses:
|
||||
"200":
|
||||
description: The current configuration, and the counts if this is the collector.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url: { type: string }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url:
|
||||
type: string
|
||||
description: |
|
||||
Where reports go. Fetched through a dialer that refuses
|
||||
loopback and private address space, so this cannot be used to
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
@@ -2507,6 +2654,142 @@ paths:
|
||||
created: { type: integer }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The Google Calendar connection and the calendars in it.
|
||||
description: |
|
||||
Read-only. verdande shows a Google calendar beside its own dated tasks and
|
||||
never writes one back — writing needs the `calendar.events` scope and an
|
||||
answer to what happens when both sides moved the same meeting, which is a
|
||||
synchronisation model rather than a scope.
|
||||
|
||||
The list of calendars is fetched from Google when it is empty, so an
|
||||
account that connected during a hiccup corrects itself on the next look
|
||||
rather than showing an empty panel for ever.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Calendar" }
|
||||
delete:
|
||||
tags: [integrations]
|
||||
summary: Disconnect the calendar and forget its tokens.
|
||||
description: >
|
||||
The cached events go too. Unlike the tasks a mailbox made, none of this is
|
||||
the person's own work — it is a copy of somebody else's calendar, and it
|
||||
means nothing without the account.
|
||||
responses:
|
||||
"204": { description: Disconnected. }
|
||||
|
||||
/api/v1/calendar/calendars:
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Choose which calendars are drawn.
|
||||
description: >
|
||||
The whole set in one write rather than one flip at a time, for the reason
|
||||
the projects' order is written the same way: the question is "these ones",
|
||||
and a list built out of several requests can land half applied. A calendar
|
||||
turned off has its cached events removed; they come back if it is turned
|
||||
on again.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shown:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/calendar/authorize:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Begin the OAuth flow for a calendar.
|
||||
description: |
|
||||
The same registration Gmail signs in through, asking for
|
||||
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
|
||||
issues a refresh token per authorisation, so connecting a calendar does not
|
||||
disturb a working Gmail connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
|
||||
/api/v1/calendar/sync:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Refresh the chosen calendars now.
|
||||
description: >
|
||||
Refreshes the list of calendars and replaces the cached window of events
|
||||
for the ones that are shown. `partial` means the budget ran out before the
|
||||
last calendar; what was read is kept, and the rest arrive on the next run.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events: { type: integer }
|
||||
partial: { type: boolean }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar/events:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The events of the chosen calendars, between two days.
|
||||
description: |
|
||||
Both ends inclusive, and an event that merely overlaps the range comes
|
||||
back — a grid asks which cells an event covers, not where it begins.
|
||||
|
||||
`from` and `to` in the answer are the window verdande holds a copy of,
|
||||
which is not the window that was asked for. A grid paged past it gets no
|
||||
events, and an empty answer with no explanation is a calendar quietly
|
||||
claiming a day is clear.
|
||||
parameters:
|
||||
- { name: from, in: query, required: true, schema: { type: string, format: date } }
|
||||
- { name: to, in: query, required: true, schema: { type: string, format: date } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CalendarEvent" }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Every note as a Markdown file, in a zip.
|
||||
description: >
|
||||
One file per note, named after its title, containing exactly what is
|
||||
stored. Nothing is converted on the way out — the note on disk is already
|
||||
the file you would export — so it opens in Obsidian, in a text editor, or
|
||||
in anything else that reads Markdown.
|
||||
responses:
|
||||
"200":
|
||||
description: A zip archive.
|
||||
content:
|
||||
application/zip:
|
||||
schema: { type: string, format: binary }
|
||||
|
||||
/api/v1/notes:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -2550,6 +2833,37 @@ paths:
|
||||
schema: { $ref: "#/components/schemas/Note" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/import:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: A zip of Markdown files becomes notes.
|
||||
description: >
|
||||
The mirror of the export, and the way in from anywhere else: a folder of
|
||||
Markdown is what Obsidian, Bear, iA Writer and a shell script all produce.
|
||||
Filenames are ignored — the title is the first line of the body, here as
|
||||
everywhere, and taking it from the filename would be a second rule for the
|
||||
same thing. Directories, macOS resource forks and anything that is not .md
|
||||
are passed over.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file: { type: string, format: binary }
|
||||
responses:
|
||||
"200":
|
||||
description: Read
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
created: { type: integer }
|
||||
skipped: { type: integer, description: Files that were not usable as a note. }
|
||||
"413": { description: The archive is too large. }
|
||||
|
||||
/api/v1/notes/{noteID}:
|
||||
parameters:
|
||||
- name: noteID
|
||||
@@ -3103,6 +3417,26 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/oauth/calendar/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Where Google sends the browser after consent to a calendar.
|
||||
description: |
|
||||
A URI of its own rather than a reuse of Gmail's. One callback deciding from
|
||||
stored state which of the two flows had come back would put the choice of
|
||||
which token store to write behind a lookup — and when that goes wrong it
|
||||
goes wrong by overwriting a working Gmail connection with calendar tokens.
|
||||
|
||||
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
|
||||
An `org_internal` error here means the OAuth app is registered as Internal
|
||||
and the account is outside the organisation.
|
||||
parameters:
|
||||
- { name: code, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { type: string } }
|
||||
- { name: error, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -3285,6 +3619,40 @@ components:
|
||||
email: { type: string }
|
||||
trigger: { type: string, enum: ["", starred, label, both] }
|
||||
label: { type: string }
|
||||
Calendar:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
connected: { type: boolean }
|
||||
account:
|
||||
type: string
|
||||
description: >
|
||||
The address of the connected account, taken from the id of its
|
||||
primary calendar — which is where Google puts it, so no profile
|
||||
scope has to be asked for to learn it.
|
||||
read_only:
|
||||
type: boolean
|
||||
description: >
|
||||
Always true today. Stated rather than assumed: an interface that
|
||||
quietly looks editable is one somebody will try to edit.
|
||||
has_client:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether an OAuth client is registered on this instance at all, so
|
||||
the page can explain rather than offer a button that answers 409.
|
||||
redirect_uri:
|
||||
type: string
|
||||
description: >
|
||||
What has to be registered with Google, spelled out. Derived from
|
||||
VERDANDE_BASE_URL and matched exactly — the single most likely
|
||||
thing to be wrong, and the error Google gives names neither value.
|
||||
last_sync_at: { type: string, format: date-time }
|
||||
calendars:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Calendar" }
|
||||
ImportResult:
|
||||
description: Created
|
||||
content:
|
||||
@@ -3302,13 +3670,69 @@ components:
|
||||
items: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
description: >
|
||||
One calendar inside a connected account. Kept whether or not it is shown,
|
||||
so turning one off and on again gives back the same row.
|
||||
properties:
|
||||
id: { type: string }
|
||||
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
|
||||
name: { type: string }
|
||||
colour:
|
||||
type: string
|
||||
description: >
|
||||
Google's own hex value, not one of verdande's ten named colours. Those
|
||||
are names so the interface can restyle them per theme and they are
|
||||
verdande's palette; a Google calendar is told apart by the colour the
|
||||
person already knows it by.
|
||||
time_zone: { type: string }
|
||||
primary: { type: boolean }
|
||||
writable:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the connected account could write to it. Nothing does — the
|
||||
scope is read-only — and it is stored so the day something can, the
|
||||
interface knows which calendars could even be offered.
|
||||
shown: { type: boolean }
|
||||
|
||||
CalendarEvent:
|
||||
type: object
|
||||
description: >
|
||||
One occurrence, in the shape a grid asks about. A recurrence is expanded by
|
||||
Google into its occurrences, so what arrives here is a day, not a rule.
|
||||
properties:
|
||||
id: { type: string }
|
||||
calendar_id: { type: string }
|
||||
summary: { type: string }
|
||||
starts_at:
|
||||
type: string
|
||||
description: >
|
||||
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
|
||||
event, which has a day rather than a moment — inventing midnight for it
|
||||
is how an event moves a day when a container moves time zone.
|
||||
ends_at: { type: string }
|
||||
start_day: { type: string, format: date }
|
||||
end_day:
|
||||
type: string
|
||||
format: date
|
||||
description: >
|
||||
The last day the event covers, inclusive. Google's all-day end date is
|
||||
exclusive and is corrected on the way in, once, rather than by every
|
||||
reader.
|
||||
all_day: { type: boolean }
|
||||
location: { type: string }
|
||||
url: { type: string, description: "Opens the event in Google's own interface." }
|
||||
calendar_name: { type: string }
|
||||
colour: { type: string }
|
||||
|
||||
Note:
|
||||
type: object
|
||||
description: A page of Markdown. The body is the stored form and the exported form.
|
||||
properties:
|
||||
id: { type: string }
|
||||
project_id: { type: string }
|
||||
title: { type: string, description: "Taken from the first line when not given." }
|
||||
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
|
||||
body: { type: string, description: Markdown }
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
@@ -3326,8 +3750,7 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Empty files it under no project." }
|
||||
title: { type: string }
|
||||
body: { type: string }
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Mailbox:
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,139 @@
|
||||
// Package beacon answers one question for the project, and refuses to answer any
|
||||
// other: how many installations are out there.
|
||||
//
|
||||
// It sends two values once a day — an anonymous id the installation made up for
|
||||
// itself, and the version it is running — and nothing else. No IP is stored by
|
||||
// the collector, no hostname, no domain, no account, no usage. The payload is
|
||||
// small enough to be printed in full on the settings page, and it is, because a
|
||||
// promise about telemetry is worth exactly as much as the reader's ability to
|
||||
// check it.
|
||||
//
|
||||
// # On by default, and off in one click
|
||||
//
|
||||
// The alternative that was asked for first was mandatory, and the argument
|
||||
// against it is practical rather than principled: it cannot be enforced. The
|
||||
// domain can be blocked, the binary can be patched, and a fork removes it in its
|
||||
// first commit — so mandatory buys nothing over on-by-default, while costing the
|
||||
// trust the rest of this program works for. Secrets are sealed here, the key is
|
||||
// kept out of the database, and the backup page warns you what a copy contains.
|
||||
// Telemetry you are not allowed to refuse is the one sentence in that story that
|
||||
// does not fit.
|
||||
//
|
||||
// So: on by default, said plainly, one switch to turn it off. That collects
|
||||
// essentially the same number and leaves nobody feeling tricked.
|
||||
//
|
||||
// # Where this disagrees with the rest of the program
|
||||
//
|
||||
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
|
||||
// "a self-hosted app that reaches out without being told to has broken the deal
|
||||
// its operator made by self-hosting." That is the same situation as this one, and
|
||||
// the two defaults do not agree.
|
||||
//
|
||||
// The disagreement is written down here rather than smoothed over, because it is
|
||||
// a real one and the reader deserves to see both halves. The case for treating
|
||||
// them differently is that the update check reveals an instance to a third party
|
||||
// (GitHub) and returns something the operator can act on, while this returns
|
||||
// nothing and goes to the project itself — and that a count nobody sends is a
|
||||
// count that does not exist, whereas an update nobody checks for is one you can
|
||||
// still go and look up. Whether that is a distinction or an excuse is a judgement
|
||||
// call, and it was made deliberately by the person whose instance this is.
|
||||
//
|
||||
// If it is ever decided the other way, the change is one word: the default in
|
||||
// `beaconSettings`. Nothing else in this package assumes which way it points.
|
||||
//
|
||||
// # What the collector can still see
|
||||
//
|
||||
// A request has a source address whether or not anybody writes it down. The
|
||||
// collector does not store or log it, and the code is arranged so that it never
|
||||
// reaches a function that could — but it passes through a reverse proxy and a
|
||||
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
|
||||
// be a half-truth; what is true is that nothing here records one.
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCollector is where a fresh installation reports, unless it is told
|
||||
// otherwise. Somebody running their own fleet points it at their own instance;
|
||||
// the field is on the settings page for exactly that.
|
||||
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
|
||||
|
||||
// Payload is the entire message. Two fields, and adding a third is a change to
|
||||
// what this program promises, not a change to a struct.
|
||||
type Payload struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InstanceID and Version are checked on the way in as well as written on the way
|
||||
// out, because the collector's endpoint is unauthenticated: anything that reaches
|
||||
// it is a stranger's bytes, and it should be able to store only the two shapes it
|
||||
// expects.
|
||||
var (
|
||||
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
|
||||
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
|
||||
)
|
||||
|
||||
// ValidID reports whether an id is the shape this program writes.
|
||||
func ValidID(s string) bool { return idShape.MatchString(s) }
|
||||
|
||||
// ValidVersion allows a version number and the word this binary uses when it was
|
||||
// built outside a release.
|
||||
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
|
||||
|
||||
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
|
||||
// queue, and a failure that is returned rather than made anybody's problem.
|
||||
//
|
||||
// No retry, because the next pass is tomorrow and a missed day changes a count of
|
||||
// installations by nothing. A telemetry ping that retries is a telemetry ping
|
||||
// that can hammer somebody's server on the day it is down.
|
||||
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
|
||||
if !ValidID(p.InstanceID) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed id")
|
||||
}
|
||||
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
|
||||
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
|
||||
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
|
||||
// en fremmed collector skal filtrere fra.
|
||||
if !ValidVersion(p.Version) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed version")
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// A plain user agent with the version already in the payload. Nothing is
|
||||
// learned from it that the body does not already say, and it makes the request
|
||||
// legible in a log rather than anonymous-looking.
|
||||
req.Header.Set("User-Agent", "verdande-beacon/1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Read and discard, so the connection can be reused rather than dropped.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hvad der bliver sendt, tegn for tegn.
|
||||
//
|
||||
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
|
||||
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
|
||||
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
|
||||
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
|
||||
func TestSendsExactlyTwoFields(t *testing.T) {
|
||||
var got map[string]any
|
||||
var contentType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("kroppen er ikke JSON: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
|
||||
Version: "v0.26.3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q", contentType)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
|
||||
}
|
||||
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
|
||||
t.Errorf("instance_id = %v", got["instance_id"])
|
||||
}
|
||||
if got["version"] != "v0.26.3" {
|
||||
t.Errorf("version = %v", got["version"])
|
||||
}
|
||||
}
|
||||
|
||||
// En collector, der er nede, er ikke denne installations problem.
|
||||
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("en 500 skal give en fejl")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
|
||||
// noget, en fremmed collector skal filtrere fra.
|
||||
func TestSendRefusesAMalformedID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("der blev sendt noget, som ikke skulle sendes")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
|
||||
}); err == nil {
|
||||
t.Fatal("et misformet id skal afvises før det forlader maskinen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapes(t *testing.T) {
|
||||
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
|
||||
for _, s := range ok {
|
||||
if !ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = false", s)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
|
||||
"<script>alert(1)</script>"}
|
||||
for _, s := range bad {
|
||||
if ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = true", s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
|
||||
if !ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
|
||||
if ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,19 @@ type Config struct {
|
||||
// without spending the real one on every run.
|
||||
GmailSyncBudget time.Duration
|
||||
|
||||
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||
// in production, where it means Google.
|
||||
GmailTokenURL string
|
||||
GmailAPIURL string
|
||||
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
||||
// the same reason: a slow Google held the request open past what Cloudflare is
|
||||
// willing to wait, and the browser got Cloudflare's HTML instead of an error
|
||||
// this server could explain.
|
||||
CalendarSyncBudget time.Duration
|
||||
|
||||
// Where Google is. Empty in production; a test points them at a server it
|
||||
// controls, which is the only way to exercise a Google that is slow or refuses.
|
||||
// The token endpoint is shared — Gmail and Calendar sign in through the same
|
||||
// registration — and the two APIs are not.
|
||||
GoogleTokenURL string
|
||||
GmailAPIURL string
|
||||
CalendarAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
@@ -98,14 +107,15 @@ func Load() (*Config, error) {
|
||||
TrashRetention: 30 * 24 * time.Hour,
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
CalendarSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -152,6 +162,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
|
||||
// configured separately and left to drift out of step with it.
|
||||
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
||||
|
||||
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
|
||||
//
|
||||
// One callback that decided from server-side state which of the two flows had come
|
||||
// back would save the operator one line in Google Cloud, and would put the choice
|
||||
// of *which token store to write* behind a lookup. When that goes wrong it goes
|
||||
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
|
||||
// nobody would think to look for. Two paths cannot do that.
|
||||
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package gcal reads a Google Calendar. It never writes one.
|
||||
//
|
||||
// The scope is calendar.readonly, and that is the whole of the decision: verdande
|
||||
// already is a CalDAV server, so a Google event shown here is a second calendar
|
||||
// laid over the first, not a copy Google and verdande now both have to agree
|
||||
// about. Writing needs `calendar.events`, and it needs an answer to what happens
|
||||
// when both sides moved the same meeting — which is a synchronisation model, not a
|
||||
// scope. Read first; write later and to one calendar at a time.
|
||||
//
|
||||
// What the API gives back and what this returns are deliberately different shapes.
|
||||
// Google answers with RFC 3339 instants for a timed event and bare dates for an
|
||||
// all-day one, and with an *exclusive* end date for the second. A calendar grid
|
||||
// asks one question — "which cells does this event cover" — so that is what an
|
||||
// Event answers, in days, worked out once here rather than in every caller.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Scope is read-only, and only the calendar.
|
||||
//
|
||||
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
|
||||
// which together are strictly less than this — they leave out access rules and
|
||||
// account settings, which verdande has no use for. They are the tighter pair and
|
||||
// worth moving to; they are also newer, and this one has been documented and
|
||||
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
|
||||
// is not a place to be adventurous on somebody else's Google account.
|
||||
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
|
||||
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
// The OAuth flow, which is Google's rather than the calendar's — the same one
|
||||
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
|
||||
// one package for one feature.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
var NewPKCE = google.NewPKCE
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
|
||||
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
|
||||
var ErrUnauthorized = google.ErrUnauthorized
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
// base is where the Calendar API is. A field rather than a constant so a test
|
||||
// can point it at a server it controls; empty is Google. There is no other way
|
||||
// to exercise a calendar that is slow, empty, or refuses.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Calendar is one entry in the person's calendar list.
|
||||
type Calendar struct {
|
||||
ID string
|
||||
// Name is the summary Google holds. A calendar somebody renamed for themselves
|
||||
// carries summaryOverride instead, and that is the name they will look for.
|
||||
Name string
|
||||
// Colour is Google's own hex value for the calendar, which is what makes two
|
||||
// calendars in one grid tellable apart. Their own colour rather than one picked
|
||||
// here: it is the colour they already know the calendar by.
|
||||
Colour string
|
||||
TimeZone string
|
||||
Primary bool
|
||||
// Owner means the connected account can write to it. Nothing writes today —
|
||||
// it is stored so the day something does, the interface knows which calendars
|
||||
// could even be offered.
|
||||
Owner bool
|
||||
}
|
||||
|
||||
// Calendars lists what the connected account can see.
|
||||
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
var out []Calendar
|
||||
page := ""
|
||||
// Bounded rather than "until Google stops": a page token that never empties is
|
||||
// a loop that never ends, and the difference between a bug at Google and a
|
||||
// hung sync should not be this server hanging.
|
||||
for range 10 {
|
||||
v := url.Values{}
|
||||
v.Set("minAccessRole", "reader")
|
||||
v.Set("maxResults", "250")
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
SummaryOverride string `json:"summaryOverride"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Primary bool `json:"primary"`
|
||||
AccessRole string `json:"accessRole"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
if item.Deleted {
|
||||
continue
|
||||
}
|
||||
name := item.SummaryOverride
|
||||
if name == "" {
|
||||
name = item.Summary
|
||||
}
|
||||
out = append(out, Calendar{
|
||||
ID: item.ID, Name: name, Colour: item.BackgroundColor,
|
||||
TimeZone: item.TimeZone, Primary: item.Primary,
|
||||
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
|
||||
})
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event is one occurrence, in the shape a calendar grid asks about.
|
||||
type Event struct {
|
||||
ID string
|
||||
Summary string
|
||||
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
|
||||
// one — an all-day event has no instant, only a day, and inventing midnight for
|
||||
// it is how an event moves a day every time a server changes time zone.
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
// StartDay and EndDay are the first and last day the event covers, inclusive at
|
||||
// both ends. Google's all-day end date is exclusive; that is corrected here,
|
||||
// once, rather than in every reader.
|
||||
StartDay string
|
||||
EndDay string
|
||||
AllDay bool
|
||||
Location string
|
||||
// URL opens the event in Google's own interface. Same reasoning as the Gmail
|
||||
// link on a task made from a mail: what verdande shows is a pointer to the
|
||||
// event, not a second copy of it that somebody could try to edit.
|
||||
URL string
|
||||
}
|
||||
|
||||
// Events lists what falls inside a window, one calendar at a time.
|
||||
//
|
||||
// `singleEvents` expands a recurrence into its occurrences, which is the only form
|
||||
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
|
||||
// it produced. It also means verdande does not have to grow a second recurrence
|
||||
// engine that agrees with Google's about Danish summer time.
|
||||
//
|
||||
// from and to are dates, inclusive. The window is the caller's business; this
|
||||
// turns it into the half-open interval Google wants.
|
||||
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
|
||||
start, err := time.Parse(dateLayout, from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: from: %w", err)
|
||||
}
|
||||
end, err := time.Parse(dateLayout, to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: to: %w", err)
|
||||
}
|
||||
|
||||
var out []Event
|
||||
page := ""
|
||||
for range 20 {
|
||||
v := url.Values{}
|
||||
v.Set("singleEvents", "true")
|
||||
v.Set("orderBy", "startTime")
|
||||
v.Set("maxResults", "2500")
|
||||
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
|
||||
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
|
||||
// 22:30 UTC the day *before* — so a window cut exactly at the requested
|
||||
// dates loses the events at both edges. The extra day is thrown away again
|
||||
// by the day fields below, which are computed from the event's own offset.
|
||||
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
|
||||
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []rawEvent `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
|
||||
if err := c.get(ctx, url, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
// A cancelled occurrence of a recurring event still comes back, so that
|
||||
// a client keeping its own copy knows to drop it. verdande replaces the
|
||||
// window wholesale, so it only has to not add it.
|
||||
if item.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
event, ok := item.event()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.EndDay < from || event.StartDay > to {
|
||||
continue // the slack above, thrown away
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type rawEvent struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
HTMLLink string `json:"htmlLink"`
|
||||
Start when `json:"start"`
|
||||
End when `json:"end"`
|
||||
}
|
||||
|
||||
type when struct {
|
||||
Date string `json:"date"`
|
||||
DateTime string `json:"dateTime"`
|
||||
}
|
||||
|
||||
// event turns Google's two shapes into the one a grid can read.
|
||||
//
|
||||
// The second return is false for an event with neither a date nor a dateTime —
|
||||
// which Google does return, for an event somebody is still being asked about. It
|
||||
// belongs on no day, so there is no cell to draw it in.
|
||||
func (r rawEvent) event() (Event, bool) {
|
||||
e := Event{
|
||||
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
|
||||
Location: r.Location, URL: r.HTMLLink,
|
||||
}
|
||||
if e.Summary == "" {
|
||||
// Google's own interface says this for an event with no title, and a blank
|
||||
// chip in a grid reads as a rendering fault rather than as an empty title.
|
||||
e.Summary = "(uden titel)"
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Start.Date != "":
|
||||
e.AllDay = true
|
||||
e.StartDay = r.Start.Date
|
||||
// Google's all-day end is the day *after* the last one, so a one-day event
|
||||
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
|
||||
// comparison rather than a special case at every call site.
|
||||
e.EndDay = r.Start.Date
|
||||
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
|
||||
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
|
||||
e.EndDay = last.Format(dateLayout)
|
||||
}
|
||||
}
|
||||
case r.Start.DateTime != "":
|
||||
e.StartsAt = r.Start.DateTime
|
||||
e.EndsAt = r.End.DateTime
|
||||
// The day is the date part of the timestamp Google returned, which carries
|
||||
// the event's own offset. Parsing it into a time.Time and formatting it
|
||||
// back would ask *this process* what day it is — and a container running in
|
||||
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
|
||||
e.StartDay = dayOf(r.Start.DateTime)
|
||||
e.EndDay = dayOf(r.End.DateTime)
|
||||
if e.EndDay == "" {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
// An event ending at midnight ends the evening before, as far as a grid is
|
||||
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
|
||||
// the following day as well.
|
||||
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
|
||||
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
|
||||
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return Event{}, false
|
||||
}
|
||||
if e.EndDay < e.StartDay {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
|
||||
func dayOf(stamp string) string {
|
||||
if len(stamp) < len(dateLayout) {
|
||||
return ""
|
||||
}
|
||||
day := stamp[:len(dateLayout)]
|
||||
if _, err := time.Parse(dateLayout, day); err != nil {
|
||||
return ""
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
func mustDay(s string) time.Time {
|
||||
day, _ := time.Parse(dateLayout, s)
|
||||
return day
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGoogle answers the two calls this package makes, out of whatever the test
|
||||
// hands it. There is no other way to exercise this: the real Calendar needs an
|
||||
// account, a registered client and somebody's consent, and none of the three can be
|
||||
// had inside a test — so the shapes Google returns are written down here and the
|
||||
// parsing is checked against them.
|
||||
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(calendarList)
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
body, ok := events[id]
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
default:
|
||||
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient("token").At(srv.URL)
|
||||
}
|
||||
|
||||
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
|
||||
client := fakeGoogle(t, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"id": "primary", "summary": "kw@nolimit.dk", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
|
||||
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
|
||||
"backgroundColor": "#0b8043", "accessRole": "reader"},
|
||||
// A calendar removed from the list still arrives, flagged.
|
||||
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
got, err := client.Calendars(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
|
||||
}
|
||||
if got[1].Name != "Familien" {
|
||||
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
|
||||
}
|
||||
if !got[0].Primary || !got[0].Owner {
|
||||
t.Errorf("the primary calendar came back as %+v", got[0])
|
||||
}
|
||||
if got[1].Owner {
|
||||
t.Error("a reader was reported as an owner; nothing may offer to write to it")
|
||||
}
|
||||
if got[0].Colour != "#3f51b5" {
|
||||
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
|
||||
}
|
||||
}
|
||||
|
||||
// The three shapes an event arrives in, and the one shape a grid can read.
|
||||
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"primary": map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
|
||||
},
|
||||
{
|
||||
// All-day, one day. Google's end is the day after.
|
||||
"id": "allday", "summary": "Fridag",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"},
|
||||
},
|
||||
{
|
||||
// All-day, three days.
|
||||
"id": "trip", "summary": "Aarhus",
|
||||
"start": map[string]string{"date": "2026-08-24"},
|
||||
"end": map[string]string{"date": "2026-08-27"},
|
||||
},
|
||||
{
|
||||
// Runs to midnight, which is the evening before as far as a
|
||||
// grid is concerned.
|
||||
"id": "late", "summary": "Nattevagt",
|
||||
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
|
||||
},
|
||||
{
|
||||
// A cancelled occurrence of a recurring event.
|
||||
"id": "off", "status": "cancelled", "summary": "Aflyst",
|
||||
"start": map[string]string{"date": "2026-08-25"},
|
||||
"end": map[string]string{"date": "2026-08-26"},
|
||||
},
|
||||
{
|
||||
// Neither a date nor a dateTime: belongs on no day at all.
|
||||
"id": "nowhere", "summary": "Uden tid",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byID := map[string]Event{}
|
||||
for _, e := range got {
|
||||
byID[e.ID] = e
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d events, want 4: %+v", len(got), got)
|
||||
}
|
||||
if _, ok := byID["off"]; ok {
|
||||
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
|
||||
}
|
||||
if _, ok := byID["nowhere"]; ok {
|
||||
t.Error("an event with no time was kept; there is no cell to draw it in")
|
||||
}
|
||||
|
||||
timed := byID["timed"]
|
||||
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
|
||||
t.Errorf("timed = %+v", timed)
|
||||
}
|
||||
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
|
||||
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
|
||||
}
|
||||
|
||||
allday := byID["allday"]
|
||||
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
|
||||
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
|
||||
allday)
|
||||
}
|
||||
if allday.StartsAt != "" {
|
||||
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
|
||||
allday.StartsAt)
|
||||
}
|
||||
|
||||
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
|
||||
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
|
||||
}
|
||||
if late := byID["late"]; late.EndDay != "2026-08-22" {
|
||||
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
|
||||
"or every one of them puts a chip in the following day", late.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no title has to say so. A blank chip in a grid reads as a
|
||||
// rendering fault rather than as an empty title.
|
||||
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{{
|
||||
"id": "x",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"},
|
||||
}}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary == "" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The window is asked for with a day of slack at each end, because an event late
|
||||
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
|
||||
// out: an event on the day before the window is not in the window.
|
||||
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{
|
||||
{"id": "before", "summary": "For tidligt",
|
||||
"start": map[string]string{"date": "2026-08-09"},
|
||||
"end": map[string]string{"date": "2026-08-10"}},
|
||||
{"id": "inside", "summary": "Med",
|
||||
"start": map[string]string{"date": "2026-08-10"},
|
||||
"end": map[string]string{"date": "2026-08-11"}},
|
||||
{"id": "after", "summary": "For sent",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "inside" {
|
||||
t.Fatalf("got %+v, want only the one inside the window", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A page token has to be followed, or a busy calendar silently shows its first
|
||||
// page and nothing else — which is a calendar that lies about a day being clear.
|
||||
func TestEveryPageIsRead(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body := map[string]any{
|
||||
"items": []map[string]any{{"id": "a", "summary": "Første",
|
||||
"start": map[string]string{"date": "2026-08-11"},
|
||||
"end": map[string]string{"date": "2026-08-12"}}},
|
||||
"nextPageToken": "more",
|
||||
}
|
||||
if r.URL.Query().Get("pageToken") == "more" {
|
||||
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
|
||||
"start": map[string]string{"date": "2026-08-12"},
|
||||
"end": map[string]string{"date": "2026-08-13"}}}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
|
||||
// two apart is what stops a revoked account being polled every ten minutes for ever.
|
||||
func TestARefusedTokenIsItsOwnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
|
||||
// and a scope that quietly grew is a promise quietly broken.
|
||||
func TestTheScopeIsReadOnly(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "calendar.readonly") {
|
||||
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google, and the small slice of the Gmail API verdande needs.
|
||||
// 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
|
||||
@@ -9,200 +9,51 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// tests — still read as Gmail's, which is what they are: one registration, one
|
||||
// flow, one scope each.
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// The OAuth flow, which is Google's rather than Gmail's.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
// Endpoints is where Google is.
|
||||
//
|
||||
// A field rather than a constant so a test can point it at a server it controls.
|
||||
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||
// and "the sync comes back before whatever sits in front of this server gives up
|
||||
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||
// against the real thing.
|
||||
//
|
||||
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||
type Endpoints struct {
|
||||
Token string
|
||||
API string
|
||||
}
|
||||
|
||||
func (e Endpoints) token() string {
|
||||
if e.Token != "" {
|
||||
return e.Token
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
func (e Endpoints) api() string {
|
||||
if e.API != "" {
|
||||
return e.API
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// Endpoints is Google unless a test says otherwise.
|
||||
Endpoints Endpoints
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
var (
|
||||
NewPKCE = google.NewPKCE
|
||||
ErrUnauthorized = google.ErrUnauthorized
|
||||
)
|
||||
|
||||
// --- the API ---------------------------------------------------------------------
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
endpoints Endpoints
|
||||
http *http.Client
|
||||
// base is where the Gmail API is. A field rather than a constant so a test can
|
||||
// point it at a server it controls — and "the sync comes back before whatever
|
||||
// sits in front of this server gives up on it" is exactly the kind of promise
|
||||
// worth a test and impossible to make against the real thing. Empty is Google.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
@@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client {
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(e Endpoints) *Client {
|
||||
c.endpoints = e
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -257,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
@@ -308,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gmail: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
|
||||
|
||||
// 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 {
|
||||
@@ -375,11 +211,3 @@ func Query(trigger, label string) string {
|
||||
terms = append(terms, "newer_than:30d")
|
||||
return strings.Join(terms, " ")
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
|
||||
@@ -1,113 +1,19 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
// Read-only, and only mail.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
trigger, label string
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package google implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google.
|
||||
//
|
||||
// It was written inside internal/gmail, because Gmail was the only thing that
|
||||
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
|
||||
// same refresh — and the only thing that differs between them is one string: the
|
||||
// scope. A second copy would be a second place for "access_type=offline" to be
|
||||
// forgotten, which is the mistake that makes a connection die silently an hour
|
||||
// after it was made.
|
||||
//
|
||||
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
|
||||
// keep their own clients and share only the way in.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud, plus the
|
||||
// scope one feature is asking for.
|
||||
//
|
||||
// The client belongs to the instance rather than to a person: one registration,
|
||||
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
|
||||
// in through the same registration and ask for different things, and Google issues
|
||||
// a refresh token per authorisation — so connecting one does not disturb the other.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scope string
|
||||
|
||||
// TokenURL is where Google is. A field rather than a constant so a test can
|
||||
// point it at a server it controls: there is no other way to exercise what
|
||||
// happens when the token endpoint is slow or refuses. Empty is Google, so
|
||||
// nothing outside a test has to know it exists.
|
||||
TokenURL string
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
func (c Config) tokenURL() string {
|
||||
if c.TokenURL != "" {
|
||||
return c.TokenURL
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", c.Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("google: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
|
||||
|
||||
// Get performs an authorised GET and decodes the JSON body into out.
|
||||
//
|
||||
// Shared by the two API clients because the interesting part is the same in both:
|
||||
// a 401 is not a failure to retry, it is a connection that has ended, and telling
|
||||
// those apart is what stops a revoked account being polled every ten minutes for
|
||||
// ever.
|
||||
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
Scope: "https://www.googleapis.com/auth/gmail.readonly",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": cfg.Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
}
|
||||
|
||||
// The scope is the one thing that differs between the two features signing in
|
||||
// through this, so it has to come out of the config rather than out of a constant
|
||||
// somebody could forget to change.
|
||||
func TestTheScopeComesFromTheConfig(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calendar := "https://www.googleapis.com/auth/calendar.readonly"
|
||||
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("scope"); got != calendar {
|
||||
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
|
||||
"that grants the wrong thing", got, calendar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/beacon"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The beacon, from the instance's side.
|
||||
//
|
||||
// Two halves that happen to live in the same binary: the part that reports in,
|
||||
// which every installation runs, and the part that receives, which only the one
|
||||
// acting as collector turns on. See internal/beacon for what is sent and why it
|
||||
// is on by default rather than compulsory.
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
type beaconConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
// anonymous id on first use.
|
||||
//
|
||||
// The id is generated here rather than derived from anything about the machine.
|
||||
// A hash of the hostname or the base URL would be stable and convenient and would
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
cfg.Collector = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
|
||||
cfg.InstanceID = v
|
||||
}
|
||||
if v, ok := stored["is_collector"].(bool); ok {
|
||||
cfg.IsCollector = v
|
||||
}
|
||||
if v, ok := stored["publish_count"].(bool); ok {
|
||||
cfg.PublishCount = v
|
||||
}
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
|
||||
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"collector_url": cfg.Collector,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon summary", err)
|
||||
return
|
||||
}
|
||||
out.Stats = &stats
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type beaconRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.IsCollector != nil {
|
||||
cfg.IsCollector = *req.IsCollector
|
||||
}
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
url = beacon.DefaultCollector
|
||||
}
|
||||
// http and https only, and said as a field error rather than a 500: an
|
||||
// operator pointing this at their own collector will mistype it, and the
|
||||
// page should say which field is wrong.
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
cfg.Collector = url
|
||||
}
|
||||
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.handleBeaconStatus(w, r)
|
||||
}
|
||||
|
||||
// --- the collector's side ---------------------------------------------------------
|
||||
|
||||
// handleBeaconPing receives one report. Unauthenticated, because the whole point
|
||||
// is that a stranger's installation can reach it.
|
||||
//
|
||||
// Nothing about the request is recorded except the two values in the body. The
|
||||
// source address is not read here, and there is no code path from this function
|
||||
// to anything that could write one down — which is the only version of that
|
||||
// promise worth making.
|
||||
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
// An instance that is not a collector does not quietly accept and drop pings —
|
||||
// it says there is nothing here. Otherwise every installation in the world is a
|
||||
// spam sink that answers 200 to anything.
|
||||
if !cfg.IsCollector {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
||||
var p beacon.Payload
|
||||
if err := decodeJSON(w, r, &p); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Checked, not trusted. This is the one endpoint anybody can post to, and the
|
||||
// two columns behind it should only ever hold the two shapes this program
|
||||
// writes — an id that is not id-shaped is somebody probing, not an install.
|
||||
if !beacon.ValidID(p.InstanceID) {
|
||||
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
|
||||
return
|
||||
}
|
||||
if p.Version != "" && !beacon.ValidVersion(p.Version) {
|
||||
writeFieldErrors(w, map[string]string{"version": "malformed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
|
||||
s.internal(w, r, "record beacon", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBeaconCount publishes the number, when the collector has been told to.
|
||||
//
|
||||
// Thirty days rather than a running total: a total only climbs, so it stops
|
||||
// meaning "how many people use this" and starts meaning "how many ever tried it".
|
||||
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.IsCollector || !cfg.PublishCount {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
|
||||
}
|
||||
|
||||
// SendBeacon reports this installation, at most once a day.
|
||||
//
|
||||
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
|
||||
// settings, the anonymous id and the version all live on this side, and the
|
||||
// runner should not have to know about any of them.
|
||||
//
|
||||
// The day is counted from the last successful send, not from a wall-clock hour.
|
||||
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
|
||||
// counted this way they spread themselves out by when each was started.
|
||||
func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
cfg, err := s.beaconSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
// The collector does not report to itself over the network. It would work, and
|
||||
// it would also mean the one instance whose count matters most depends on its
|
||||
// own tunnel being up to be counted.
|
||||
if cfg.IsCollector {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
|
||||
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
|
||||
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
|
||||
// svarede, er i sig selv det, sådan et forsøg leder efter.
|
||||
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
|
||||
InstanceID: cfg.InstanceID,
|
||||
Version: Version,
|
||||
}); err != nil {
|
||||
// Logged at info, not error. The collector being unreachable is not this
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
|
||||
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
|
||||
// server is the one that answers a slow Google rather than Cloudflare's own HTML
|
||||
// page — which is how a failure arrives with no code, no message and nothing in
|
||||
// the error log.
|
||||
const defaultCalendarSyncBudget = 25 * time.Second
|
||||
|
||||
// The window verdande keeps a copy of.
|
||||
//
|
||||
// A month grid pages, so asking Google on every arrow press would mean a round trip
|
||||
// for a question answered ninety seconds ago — a calendar slower than the calendar
|
||||
// it is showing. A cached window is the alternative, and a window has to end
|
||||
// somewhere. Ninety days back is roughly "when was that", a year forward is past
|
||||
// anything anybody has booked; between them they cover every screen the grid can
|
||||
// reach without a person noticing an edge.
|
||||
//
|
||||
// The edge is still real, and the events endpoint says where it is rather than
|
||||
// answering an empty list for a month outside it. A calendar that quietly shows no
|
||||
// events is a calendar that lies about a day being clear.
|
||||
const (
|
||||
calendarWindowBack = 90
|
||||
calendarWindowAhead = 365
|
||||
calendarDateLayout = "2006-01-02"
|
||||
calendarProviderName = "google"
|
||||
)
|
||||
|
||||
func (s *Server) calendarClient(accessToken string) *gcal.Client {
|
||||
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
|
||||
}
|
||||
|
||||
// calendarConfig is the same registration Gmail signs in through, asking for a
|
||||
// different scope and coming back to a different URI.
|
||||
//
|
||||
// One OAuth client, two features. Google issues a refresh token per authorisation
|
||||
// and keeps up to a hundred of them live per account, each carrying the scopes it
|
||||
// was granted with — so connecting the calendar does not disturb a working Gmail,
|
||||
// and disconnecting one does not take the other with it.
|
||||
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
|
||||
cfg := s.gmailConfig(ctx)
|
||||
cfg.Scope = gcal.Scope
|
||||
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- connecting ------------------------------------------------------------------
|
||||
|
||||
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
|
||||
// reason for keeping the verifier server-side: the callback arrives as a top-level
|
||||
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
|
||||
// until a browser tightens its defaults.
|
||||
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.calendarConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := gcal.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
|
||||
// person who started both flows had one verifier, and whichever came back
|
||||
// second would be refused for a reason nothing on the screen could explain.
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
settings["pkce_state"] = pkce.State
|
||||
// The attempt expires: a stored verifier that lives forever is a replay window
|
||||
// that lives forever.
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
}
|
||||
|
||||
// handleCalendarCallback is where Google sends the browser back.
|
||||
//
|
||||
// It answers with a redirect into the app rather than with JSON: the person is
|
||||
// looking at a browser tab, not at a fetch response.
|
||||
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
// Somebody clicked "cancel" on the consent screen — or, on an Internal
|
||||
// OAuth app, Google refused an account outside the organisation with
|
||||
// `org_internal`. Both arrive here, and the page names what it was given.
|
||||
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
expected, _ := settings["pkce_state"].(string)
|
||||
expires, _ := settings["pkce_expires"].(float64)
|
||||
|
||||
// The state has to match, or this endpoint accepts a code from anywhere.
|
||||
if verifier == "" || expected == "" || state != expected {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if int64(expires) < time.Now().Unix() {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
|
||||
gcal.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("calendar exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
// Without one the connection dies in an hour. Google only withholds it when
|
||||
// the consent prompt was skipped, which access_type and prompt are meant to
|
||||
// prevent — so this means something is wrong with the client registration
|
||||
// rather than with this request.
|
||||
s.log.Error("calendar returned no refresh token", "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The one-time values are cleared: keeping a spent verifier serves nothing and
|
||||
// is one more secret at rest.
|
||||
delete(settings, "pkce_verifier")
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "clear calendar handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: calendarProviderName,
|
||||
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}
|
||||
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
|
||||
s.internal(w, r, "save calendar tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The list of calendars, fetched now so the page that opens next has something
|
||||
// to tick rather than an empty panel and a button. Best effort: an account that
|
||||
// connects but cannot list itself is still connected, and the status endpoint
|
||||
// asks again when it finds nothing.
|
||||
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- what the settings page reads --------------------------------------------------
|
||||
|
||||
type calendarStatusJSON struct {
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Calendars []store.Calendar `json:"calendars"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. Derived
|
||||
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
|
||||
// be wrong, and the error Google gives for it names neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
// HasClient says whether there is an OAuth registration to connect through at
|
||||
// all, so the page can explain rather than offer a button that answers 409.
|
||||
HasClient bool `json:"has_client"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
Calendars: []store.Calendar{},
|
||||
RedirectURI: s.cfg.CalendarRedirectURL(),
|
||||
HasClient: s.calendarConfig(r.Context()).Configured(),
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
out.Connected = true
|
||||
out.Account = account.Account
|
||||
if !account.LastSyncAt.IsZero() {
|
||||
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
calendars, err := s.db.Calendars(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendars", err)
|
||||
return
|
||||
}
|
||||
// An account connected during a Google hiccup has no calendars and no way back:
|
||||
// the person sees an empty list and nothing to press. Asking again here costs
|
||||
// one request on a page nobody opens often, and means the wrong answer lasts
|
||||
// until the next look rather than for ever. Same reasoning as the Gmail address.
|
||||
if len(calendars) == 0 {
|
||||
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
|
||||
calendars = fetched
|
||||
out.Account = account.Account
|
||||
} else {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type showCalendarsRequest struct {
|
||||
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
|
||||
// a person has a handful of calendars, the question is "these ones", and a list
|
||||
// that can only be built by several requests can land half applied.
|
||||
Shown []string `json:"shown"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
var req showCalendarsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
// A 404 rather than a 409: everything the caller may not see answers 404,
|
||||
// and "there is no such connection" is the true answer either way.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
|
||||
return
|
||||
}
|
||||
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
|
||||
s.internal(w, r, "show calendars", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
|
||||
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
|
||||
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
|
||||
s.internal(w, r, "disconnect calendar", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- what the grid reads -----------------------------------------------------------
|
||||
|
||||
type calendarEventsJSON struct {
|
||||
Events []store.CalendarEvent `json:"events"`
|
||||
// From and To are the window verdande holds a copy of, not the window that was
|
||||
// asked for. A grid that has paged past it gets no events, and an empty answer
|
||||
// with no explanation is a calendar quietly claiming a day is clear.
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
|
||||
fields := map[string]string{}
|
||||
if !validDay(from) {
|
||||
fields["from"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if !validDay(to) {
|
||||
fields["to"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar events", err)
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []store.CalendarEvent{}
|
||||
}
|
||||
windowFrom, windowTo := calendarWindow(time.Now())
|
||||
writeJSON(w, http.StatusOK, calendarEventsJSON{
|
||||
Events: events, From: windowFrom, To: windowTo,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
|
||||
// can see it work rather than wondering whether it did.
|
||||
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// A budget of its own, well under what sits in front of this server. An unset
|
||||
// budget is not a budget of nothing: a zero here would expire the context
|
||||
// before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.CalendarSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultCalendarSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
count, err := s.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: Google is slow or busy, some calendars were read, and
|
||||
// the rest arrive on the next run. Every calendar is committed as it is
|
||||
// read, so a short budget is a shorter run rather than a lost one.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"events": count})
|
||||
}
|
||||
|
||||
// SyncCalendars refreshes every calendar this person has chosen to see.
|
||||
//
|
||||
// Returns how many events were written. Also used by the background job, which is
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
|
||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||
// now" during the sweep would otherwise have two runs replacing the same window
|
||||
// at once, and the loser's delete would land after the winner's insert.
|
||||
unlock := s.lockSync("calendar:" + account.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
|
||||
account = fresh
|
||||
}
|
||||
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
client := s.calendarClient(access)
|
||||
|
||||
// The list first, so a calendar added or renamed since the last run is drawn
|
||||
// under its new name and one that has gone stops being drawn at all.
|
||||
calendars, err := s.calendarList(ctx, client, account)
|
||||
if err != nil {
|
||||
return 0, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into a failed fetch per
|
||||
// calendar, logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
events, err := client.Events(ctx, calendar.RemoteID, from, to)
|
||||
if err != nil {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
return written, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
// One calendar somebody was unshared from must not stop the others.
|
||||
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.ID, Summary: e.Summary,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay,
|
||||
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(rows)
|
||||
}
|
||||
|
||||
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// calendarAccessToken returns a usable access token, refreshing only when the one
|
||||
// held has actually expired.
|
||||
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
|
||||
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account.AccessToken = token.AccessToken
|
||||
account.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
|
||||
// refreshCalendarList fetches the calendar list with a token it obtains itself.
|
||||
// Used by the paths that have an account but no client yet — connecting, and the
|
||||
// status endpoint correcting an empty list.
|
||||
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.calendarList(ctx, s.calendarClient(access), account)
|
||||
}
|
||||
|
||||
// calendarList writes what the provider says and returns what is stored, which is
|
||||
// not the same list: the provider says what exists, and only verdande knows which
|
||||
// of them somebody chose to look at.
|
||||
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
remote, err := client.Calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]store.Calendar, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
list = append(list, store.Calendar{
|
||||
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
|
||||
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
|
||||
})
|
||||
// The primary calendar is the account, and its id is the address. It is the
|
||||
// only place the connected account names itself without a second scope —
|
||||
// asking for a profile scope to learn an address already in the answer would
|
||||
// be one more thing on the consent screen for nothing.
|
||||
if c.Primary && account.Account == "" {
|
||||
account.Account = c.ID
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.db.Calendars(ctx, account.ID)
|
||||
}
|
||||
|
||||
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
|
||||
// other failure through untouched.
|
||||
//
|
||||
// The distinction is the point. A revoked account polled every ten minutes for ever
|
||||
// is a log line a week long and a connection the person believes they removed; a
|
||||
// Google that is merely down is one that will answer next time.
|
||||
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// calendarWindow is the span verdande keeps a copy of, measured from today.
|
||||
//
|
||||
// Built from the calendar date rather than by truncating the instant: Truncate
|
||||
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
|
||||
// two in the morning — so for two hours of every summer day the window would start
|
||||
// and end a day out.
|
||||
func calendarWindow(now time.Time) (string, string) {
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
|
||||
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
|
||||
}
|
||||
|
||||
func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
|
||||
// token endpoint, the calendar list and one calendar's events.
|
||||
//
|
||||
// There is no other way to exercise any of it. The real thing needs a registered
|
||||
// OAuth client, an account and somebody's consent, and none of the three can be had
|
||||
// inside a test — so the shapes Google returns are written down and the behaviour
|
||||
// is checked against them, the way internal/imap is checked against go-imap's own
|
||||
// in-process server.
|
||||
type fakeCalendarGoogle struct {
|
||||
*httptest.Server
|
||||
// refreshes counts token refreshes, so a test can prove one is not made for a
|
||||
// token that has not expired.
|
||||
refreshes int
|
||||
// refuse makes every API call answer 401, which is what a revoked grant does.
|
||||
refuse bool
|
||||
events map[string][]map[string]any
|
||||
}
|
||||
|
||||
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
|
||||
t.Helper()
|
||||
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
|
||||
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
g.refreshes++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
case g.refuse:
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
|
||||
{"id": "kw@nolimit.dk", "summary": "kw@nolimit.dk", "primary": true,
|
||||
"accessRole": "owner", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen"},
|
||||
{"id": "fam", "summary": "Familien", "accessRole": "reader",
|
||||
"backgroundColor": "#0b8043"},
|
||||
}})
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
items := g.events[id]
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(g.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// connectedCalendar returns a server with a calendar account already connected —
|
||||
// the state the OAuth callback leaves behind, without the consent screen nothing
|
||||
// can automate.
|
||||
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
|
||||
t.Helper()
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = g.URL + "/token"
|
||||
cfg.CalendarAPIURL = g.URL
|
||||
if tune != nil {
|
||||
tune(cfg)
|
||||
}
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts, user
|
||||
}
|
||||
|
||||
// The scope on the consent screen is what somebody agrees to. It has to be the
|
||||
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
|
||||
// would put "which token store do I write" behind a lookup.
|
||||
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
url, _ := body["url"].(string)
|
||||
for _, want := range []string{
|
||||
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
||||
"%2Foauth%2Fcalendar%2Fcallback",
|
||||
"access_type=offline",
|
||||
"prompt=consent",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
|
||||
t.Errorf("a writing scope was asked for:\n%s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a registered client there is nothing to connect through, and the answer
|
||||
// has to say that rather than "that already exists".
|
||||
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeGmailNotConfigured {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole path, minus the consent screen: sync fetches the list, refreshes the
|
||||
// events of what was chosen, and the grid endpoint answers with them.
|
||||
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
|
||||
}
|
||||
g.events["kw@nolimit.dk"] = []map[string]any{
|
||||
{"id": "e2", "summary": "Skal ikke med",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
// The list arrives on the first look at the settings page, which is what
|
||||
// somebody would be doing at this point.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["connected"] != true {
|
||||
t.Fatalf("not reported as connected: %v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Error("the connection did not say it is read-only; an interface that looks " +
|
||||
"editable is one somebody will try to edit")
|
||||
}
|
||||
// The primary calendar's id is the address, which is how the account names
|
||||
// itself without asking for a profile scope as well.
|
||||
if body["account"] != "kw@nolimit.dk" {
|
||||
t.Errorf("account = %v", body["account"])
|
||||
}
|
||||
|
||||
list, _ := body["calendars"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
|
||||
}
|
||||
var famID string
|
||||
for _, raw := range list {
|
||||
c := raw.(map[string]any)
|
||||
if c["shown"] == true {
|
||||
t.Errorf("%v was shown without anybody choosing it", c["name"])
|
||||
}
|
||||
if c["remote_id"] == "fam" {
|
||||
famID = c["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
|
||||
// calendars must not fill somebody's grid the moment it is connected.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
|
||||
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
|
||||
map[string]any{"shown": []string{famID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["events"] != float64(1) {
|
||||
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET",
|
||||
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
events, _ := body["events"].([]any)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
|
||||
}
|
||||
event := events[0].(map[string]any)
|
||||
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
|
||||
t.Errorf("the event came back as %v", event)
|
||||
}
|
||||
// The calendar's colour rides along, or the chip has to ask for it per row.
|
||||
if event["colour"] != "#0b8043" {
|
||||
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
|
||||
}
|
||||
// The window verdande holds is stated, so a grid paged past it can say so
|
||||
// rather than showing an empty month it cannot explain.
|
||||
if body["from"] == "" || body["to"] == "" {
|
||||
t.Error("the answer does not say which window it covers")
|
||||
}
|
||||
|
||||
// And it is one person's calendar. Somebody else on the same instance sees
|
||||
// nothing of it.
|
||||
other := ts.newUser(t, "andreas@example.dk", "Andreas")
|
||||
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("somebody else's calendar was visible: %v", body["events"])
|
||||
}
|
||||
_ = user
|
||||
}
|
||||
|
||||
// A token that has not expired must not be traded in for another. Refreshing on
|
||||
// every run burns the quota and makes the failure mode of a bad refresh token
|
||||
// arrive on every sweep rather than once an hour.
|
||||
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account.AccessToken = "ya29.still-good"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 0 {
|
||||
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
|
||||
}
|
||||
|
||||
// And an expired one is.
|
||||
account.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 1 {
|
||||
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
// Access revoked at Google's end is a connection that has ended, not a failure to
|
||||
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
|
||||
// connection the person believes they removed.
|
||||
func TestRevokedAccessDisconnects(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
account.AccessToken = "ya29.revoked"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.refuse = true
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
|
||||
resp.StatusCode, StatusUpstreamRefused)
|
||||
}
|
||||
if body["code"] != CodeCalendarFailed {
|
||||
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
|
||||
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
|
||||
}
|
||||
|
||||
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gone != nil {
|
||||
t.Error("the connection is still there after Google refused the token")
|
||||
}
|
||||
|
||||
// And the failure is in the error log, which is the point of having one: a
|
||||
// sync that has been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded")
|
||||
}
|
||||
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
|
||||
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the copy with it. None of it is the person's own work, and
|
||||
// a calendar left behind after the account is gone is a frozen lie about a day.
|
||||
func TestDisconnectingRemovesTheEvents(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Møde",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
|
||||
var famID string
|
||||
for _, c := range calendars {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
|
||||
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disconnect: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("events survived the disconnect: %v", body["events"])
|
||||
}
|
||||
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if body["connected"] != false {
|
||||
t.Error("still reported as connected")
|
||||
}
|
||||
}
|
||||
|
||||
// A range that is not a date has to be refused, not guessed at. An empty `from`
|
||||
// compared as a string matches everything before it, which is a grid quietly
|
||||
// answering a question nobody asked.
|
||||
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
for _, query := range []string{
|
||||
"", "?from=2026-08-01", "?from=&to=2026-08-31",
|
||||
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A person with no calendar connected still opens the view. It has to answer, and
|
||||
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
|
||||
// they must not disappear because Google is not set up.
|
||||
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
|
||||
t.Errorf("events = %v, want an empty list rather than null", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["connected"] != false {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["has_client"] != false {
|
||||
t.Error("an instance with no OAuth registration claimed to have one; the page " +
|
||||
"would offer a button that answers 409")
|
||||
}
|
||||
// Spelled out even when nothing is connected: it is what has to be registered
|
||||
// with Google, and it is the single most likely thing to be wrong.
|
||||
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
|
||||
t.Errorf("redirect_uri = %q", uri)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow Google must not hold the request open until something in front of this
|
||||
// server gives up on it and answers with its own HTML.
|
||||
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer slow.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = slow.URL + "/token"
|
||||
cfg.CalendarAPIURL = slow.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.CalendarSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "r",
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
|
||||
"own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if took := time.Since(started); took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A file inside a note follows the note, which follows its project — the same
|
||||
// chain everything else here follows. Without this a picture imported with a
|
||||
// note answered 404 while sitting in the middle of the text that named it.
|
||||
if a.NoteID != "" {
|
||||
n, err := s.db.Note(r.Context(), a.NoteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == nil {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
if n.ProjectID == "" {
|
||||
if n.CreatedBy != user.ID {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -370,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -384,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ const (
|
||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||
// is not there — and it says it in its own words.
|
||||
CodeMailboxFailed = "mailbox_failed"
|
||||
|
||||
// And for a calendar read from Google. Its own code rather than the Gmail one,
|
||||
// because the sentence the interface wraps it in names where it came from — and
|
||||
// "Gmail said no" about a calendar sends somebody to the wrong panel.
|
||||
CodeCalendarFailed = "calendar_failed"
|
||||
)
|
||||
|
||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||
|
||||
@@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second
|
||||
// to redeploy a container to paste a client id.
|
||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||
})
|
||||
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Endpoints: endpoints,
|
||||
Scope: gmail.Scope,
|
||||
TokenURL: s.cfg.GoogleTokenURL,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
@@ -251,6 +249,17 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// 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.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
const maxGmailSeen = 2000
|
||||
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
@@ -260,7 +269,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
unlock := s.lockSync(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
@@ -362,14 +371,35 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]string, 0, len(ids))
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
// It used to keep only the ids Gmail returned *this run*, to stop the list
|
||||
// growing without bound. That threw away every id that was not on the page
|
||||
// it happened to fetch — and the query is capped, so on an account with more
|
||||
// starred mail than one page, each run forgot the messages the last one had
|
||||
// remembered. They came back unseen, and became tasks again, and again. That
|
||||
// is the duplicate.
|
||||
//
|
||||
// Bounded by a count instead. Newest first, and a ceiling far above any
|
||||
// plausible page, so an id leaves the list because it is old rather than
|
||||
// because it was not in the last answer.
|
||||
keep := make([]string, 0, len(ids)+len(box.Seen))
|
||||
added := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
if seen[id] && !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
if !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
if len(keep) > maxGmailSeen {
|
||||
keep = keep[:maxGmailSeen]
|
||||
}
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
@@ -97,7 +97,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
unlock := s.lockSync(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
@@ -175,6 +175,10 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
@@ -204,12 +208,27 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
continue
|
||||
}
|
||||
created++
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The flag comes off what became a task. This is what actually stops a mail
|
||||
// arriving twice; the marker below is now a second line rather than the only
|
||||
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
|
||||
//
|
||||
// Outside the request's context, like the marker: a cancelled request that
|
||||
// already created tasks must still take the flags off them, or the next run
|
||||
// makes them all again.
|
||||
if err := client.Unflag(done); err != nil {
|
||||
// Not fatal, and not the caller's problem. A folder somebody opened
|
||||
// read-only, or a server that refuses the STORE, still gets the uid marker
|
||||
// underneath — worse, but not broken.
|
||||
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
@@ -231,8 +250,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
// lockSync serialises one thing that polls against itself and returns the release.
|
||||
//
|
||||
// A mailbox to begin with; a calendar connection now takes the same lock under a
|
||||
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
|
||||
// is in the middle of the same thing — both start from the state as it was, and
|
||||
// the loser writes last. For a mailbox that made forty copies of one mail in a
|
||||
// second; for a calendar it would be a window deleted after it was refilled.
|
||||
func (s *Server) lockSync(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
|
||||
@@ -530,8 +530,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
|
||||
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||
}),
|
||||
@@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
"removed.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"note_id": mcp.Str("Which note."),
|
||||
"body": mcp.Str("The whole new text, as Markdown."),
|
||||
"title": mcp.Str("A new title."),
|
||||
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||
}),
|
||||
}, s.mcpUpdateNote)
|
||||
@@ -604,7 +602,6 @@ func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) b
|
||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Body string `json:"body"`
|
||||
Title string `json:"title"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
@@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw
|
||||
|
||||
n := &store.Note{
|
||||
CreatedBy: userID,
|
||||
Title: params.Title,
|
||||
Body: params.Body,
|
||||
Pinned: params.Pinned,
|
||||
}
|
||||
@@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
||||
var params struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Body *string `json:"body"`
|
||||
Title *string `json:"title"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
@@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
||||
if params.Body != nil {
|
||||
n.Body = *params.Body
|
||||
}
|
||||
if params.Title != nil {
|
||||
n.Title = *params.Title
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
n.Pinned = *params.Pinned
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -97,9 +106,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
// No title. It is the note's first line and is derived on every save — a field
|
||||
// here would be a promise this server cannot keep.
|
||||
type noteBody struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
@@ -119,9 +129,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Title != nil {
|
||||
n.Title = *body.Title
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -164,9 +171,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Title != nil {
|
||||
n.Title = *body.Title
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -213,3 +217,432 @@ func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
}
|
||||
|
||||
// handleExportNotes writes every note this person can see as a zip of Markdown
|
||||
// files, one per note.
|
||||
//
|
||||
// A zip of plain files rather than one bundle in a format of our own: what comes
|
||||
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
|
||||
// That is the promise the whole design was arranged around — the note on disk is
|
||||
// already the file you would export — and this is where it is either kept or not.
|
||||
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
notes, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export notes", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
|
||||
// twice is a zip that loses one of them.
|
||||
used := map[string]int{}
|
||||
for _, n := range notes {
|
||||
name := noteFilename(n.Title)
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: name + ".md",
|
||||
Method: zip.Deflate,
|
||||
Modified: n.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool says is truncated —
|
||||
// better than a complete-looking file with a note missing from it.
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(f, n.Body); err != nil {
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noteFilename makes a title safe to be a filename on any of the three systems
|
||||
// people actually use, without turning it into something unrecognisable.
|
||||
func noteFilename(title string) string {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
name = "uden titel"
|
||||
}
|
||||
// Windows refuses these outright; a slash would make directories on the others.
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
// Long titles are the first line of a note, which can be a whole sentence.
|
||||
if len([]rune(name)) > 80 {
|
||||
name = string([]rune(name)[:80])
|
||||
}
|
||||
return strings.TrimRight(name, " .")
|
||||
}
|
||||
|
||||
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
// this program goes back into it. It is also the way in from anywhere else, since
|
||||
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
|
||||
// produce — and what the Apple Notes exporter in tools/ writes.
|
||||
//
|
||||
// Filenames are ignored. The title is the first line of the body, everywhere else
|
||||
// in this program, and making it the filename here would be a second rule for the
|
||||
// same thing that disagrees the moment somebody renames a file.
|
||||
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
created, skipped, files := 0, 0, 0
|
||||
|
||||
// Two passes. Everything that is not Markdown is stored first, so that when a
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
||||
// a note that did not arrive, and the caller is told the same number either
|
||||
// way unless it is said here — which is how one note went missing without
|
||||
// anything looking wrong.
|
||||
if skipFile(name) {
|
||||
s.log.Warn("import skipped a note file", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
|
||||
rc.Close()
|
||||
if err != nil || strings.TrimSpace(string(body)) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
text, wrote, changed := readFrontMatter(string(body))
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: text}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// After the save, not as part of it: SaveNote owns `updated_at` and is right
|
||||
// to, so the dates a note had before this program existed are written over
|
||||
// the top rather than passed through it.
|
||||
if !wrote.IsZero() || !changed.IsZero() {
|
||||
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
|
||||
s.log.Warn("import note dates", "err", err, "file", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
|
||||
n.Body = rewritten
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "skipped": skipped, "files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// skipFile is the things in an archive that are not content: directories' own
|
||||
// entries, macOS resource forks, and anything trying to climb out of the archive.
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
if strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Traversal, read as path segments rather than as a substring.
|
||||
//
|
||||
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
||||
// wrong thing: it does not describe a path that climbs out of the archive, it
|
||||
// describes any name with two dots next to each other anywhere in it. A note
|
||||
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
||||
// silently, and without even counting as skipped, so the number the import
|
||||
// reported still looked right. One note in twelve hundred, found only by
|
||||
// counting what came out.
|
||||
//
|
||||
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
||||
// starts at the root, is not — and those are what a zip uses to write outside
|
||||
// the folder it was unpacked into.
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
// where they now live. Returns the new body and how many were attached.
|
||||
//
|
||||
// Only the files this note refers to. An archive is one folder for many notes, and
|
||||
// hanging every picture on every note would turn a library of a hundred into ten
|
||||
// thousand attachments.
|
||||
func (s *Server) attachToNote(
|
||||
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
|
||||
) (string, int) {
|
||||
body := n.Body
|
||||
attached := 0
|
||||
|
||||
for _, f := range zr.File {
|
||||
hashed, ok := stored[f.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: base,
|
||||
MimeType: mimeOf(base),
|
||||
Size: int64(f.UncompressedSize64),
|
||||
Path: hashed,
|
||||
UploadedBy: n.CreatedBy,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
s.log.Warn("attach to note", "err", err, "file", base)
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
|
||||
// the two dates out of it, returning the rest of the text.
|
||||
//
|
||||
// Not a YAML parser, and deliberately not: the block this reads is the one this
|
||||
// program writes, plus whatever Obsidian and Bear write, and all of them use the
|
||||
// same handful of `key: value` lines. Pulling in a parser to read two dates would
|
||||
// be a dependency that can fail in ways nobody here would recognise.
|
||||
//
|
||||
// A file with no front matter comes back untouched — that is the normal case, and
|
||||
// it must never lose its first line to a rule about a block that is not there.
|
||||
func readFrontMatter(body string) (text string, created, modified time.Time) {
|
||||
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
rest := body[strings.Index(body, "\n")+1:]
|
||||
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
// An opening fence with no close is not front matter; it is a horizontal
|
||||
// rule somebody wrote at the top of their note.
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
block := rest[:end]
|
||||
after := rest[end+len("\n---"):]
|
||||
after = strings.TrimPrefix(after, "\r")
|
||||
after = strings.TrimPrefix(after, "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "created", "created_at", "date":
|
||||
created = parseNoteTime(value)
|
||||
case "modified", "updated", "updated_at":
|
||||
modified = parseNoteTime(value)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(after, "\n"), created, modified
|
||||
}
|
||||
|
||||
// parseNoteTime accepts the shapes these files actually carry. A date without a
|
||||
// time is that day at midnight, local — the note was written on that day, and
|
||||
// pretending to know the hour would be worse than not having one.
|
||||
func parseNoteTime(v string) time.Time {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// handleUploadNoteAttachment takes a file into a note.
|
||||
//
|
||||
// The import could already do this, and nothing else could: a note brought in
|
||||
// from Apple Notes arrived with its pictures, and a note written here could not
|
||||
// be given one. Pasting a screenshot into a note is the most ordinary thing there
|
||||
// is, and it did nothing at all.
|
||||
//
|
||||
// Editor rights on the note, checked the same way every other write to it is. The
|
||||
// answer for "no such note" and "not yours" is the same, so an id cannot be
|
||||
// probed by watching which one comes back.
|
||||
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
|
||||
//
|
||||
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
|
||||
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
|
||||
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
|
||||
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
|
||||
// tælle efter, hvor mange der kom ud.
|
||||
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
|
||||
keep := []string{
|
||||
"Så blev det endelig jul! Stay tuned... ☕️.md",
|
||||
"Møde 12.8..md",
|
||||
"vedhaeftninger/1-2-Pasted Graphic.png",
|
||||
"noter/Referat....md",
|
||||
}
|
||||
for _, name := range keep {
|
||||
if skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
|
||||
}
|
||||
}
|
||||
|
||||
drop := []string{
|
||||
"../uden for.md",
|
||||
"noter/../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"__MACOSX/._noget",
|
||||
".skjult.md",
|
||||
"noter/._AppleDouble.md",
|
||||
}
|
||||
for _, name := range drop {
|
||||
if !skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -113,3 +119,248 @@ func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
||||
t.Errorf("%d notes still point at Firma", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The export is the promise the whole design was arranged around: the note on
|
||||
// disk is already the file you would export, so nothing is converted on the way
|
||||
// out and nothing can be lost in the conversion.
|
||||
func TestNotesExportAsMarkdownFiles(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
bodies := []string{
|
||||
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
|
||||
"Slash/i/titlen\n\nog noget tekst",
|
||||
"# Møde med Anders\n\nen anden note med samme titel",
|
||||
}
|
||||
for _, body := range bodies {
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("export: status %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("content type is %q", ct)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("the archive does not open: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
byName[f.Name] = string(text)
|
||||
}
|
||||
|
||||
// Exactly what was stored, byte for byte. A converted export is an export that
|
||||
// can be wrong.
|
||||
if got := byName["Møde med Anders.md"]; got != bodies[0] {
|
||||
t.Errorf("the note came out as %q", got)
|
||||
}
|
||||
// A slash in a title would make a directory, and on Windows it is refused
|
||||
// outright.
|
||||
if _, ok := byName["Slash-i-titlen.md"]; !ok {
|
||||
t.Errorf("the awkward title became %v", keysOf(byName))
|
||||
}
|
||||
// Two notes with the same title are two notes; one entry twice is one lost.
|
||||
if _, ok := byName["Møde med Anders (2).md"]; !ok {
|
||||
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Out and back in again. If an export cannot be imported, it is not an export —
|
||||
// it is a file somebody hopes will be readable later.
|
||||
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
files := map[string]string{
|
||||
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
|
||||
"noget andet.md": "Løse tanker\n\nog en linje til",
|
||||
// A zip made on a Mac carries these beside every file. Importing them would
|
||||
// double the whole library, with each copy full of binary rubbish.
|
||||
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
|
||||
"billede.png": "ikke en note",
|
||||
"tom.md": " \n ",
|
||||
}
|
||||
for name, body := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("import: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct{ Created, Skipped int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 {
|
||||
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
|
||||
}
|
||||
|
||||
// The title came from the first line, not from the filename — which is the same
|
||||
// rule as everywhere else, and the reason renaming a file cannot rename a note.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
|
||||
titles := map[string]bool{}
|
||||
for _, raw := range listed["notes"].([]any) {
|
||||
titles[raw.(map[string]any)["title"].(string)] = true
|
||||
}
|
||||
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
|
||||
t.Errorf("the titles came out as %v", titles)
|
||||
}
|
||||
|
||||
// And the #tag was read on the way in, so an imported note lands on its project
|
||||
// exactly as a typed one does.
|
||||
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(linked["notes"].([]any)) != 1 {
|
||||
t.Errorf("the imported note did not link to its project")
|
||||
}
|
||||
}
|
||||
|
||||
// A note from Apple Notes is full of photographs. One that arrives as words alone
|
||||
// is not that note — it is a summary of it.
|
||||
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
write := func(name, body string) {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n\n\nog resten af teksten")
|
||||
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
|
||||
// Another note in the same archive, which must not inherit the first one's
|
||||
// picture: an archive is one folder for many notes.
|
||||
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct{ Created, Skipped, Files int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 || out.Files != 1 {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
if strings.Contains(body, "vedhaeftninger/bon.png") {
|
||||
t.Errorf("the link still points into the archive: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "/api/v1/attachments/") {
|
||||
t.Errorf("the picture was not attached: %q", body)
|
||||
}
|
||||
// And it is still where it was written, in the middle of the note.
|
||||
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
|
||||
t.Errorf("the note was rearranged: %q", body)
|
||||
}
|
||||
|
||||
// The picture is really there and really readable.
|
||||
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
|
||||
id = strings.SplitN(id, ")", 2)[0]
|
||||
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
|
||||
if got.StatusCode != http.StatusOK {
|
||||
t.Errorf("the attachment answers %d", got.StatusCode)
|
||||
}
|
||||
|
||||
// The other note did not inherit it.
|
||||
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
|
||||
for _, raw := range other["notes"].([]any) {
|
||||
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
|
||||
t.Error("a note that mentioned no picture was given one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockMailbox("box-1")
|
||||
unlock := srv.lockSync("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
@@ -136,10 +136,10 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockMailbox("box-a")
|
||||
a := srv.lockSync("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockMailbox("box-b")()
|
||||
srv.lockSync("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -133,6 +133,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
||||
// The calendar comes back on a URI of its own rather than sharing Gmail's.
|
||||
// One callback deciding from stored state which of the two flows had
|
||||
// returned would put the choice of *which token store to write* behind a
|
||||
// lookup — and when that goes wrong it goes wrong by overwriting a working
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
|
||||
@@ -143,6 +149,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
// whole feature. Rate limited like the login routes: it is the one endpoint
|
||||
// anybody on the internet can write to, and a row per request is a row per
|
||||
// request whether or not the request is honest.
|
||||
//
|
||||
// Refused with a 404 unless this instance has been told it is the collector,
|
||||
// so an ordinary installation is not a spam sink that answers 200 to
|
||||
// anything.
|
||||
r.Method(http.MethodPost, "/beacon",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
|
||||
r.Get("/beacon/count", s.handleBeaconCount)
|
||||
|
||||
// Reachable without being signed in — these are how you become signed in.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/setup", s.handleSetupState)
|
||||
@@ -283,10 +302,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Route("/notes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotes)
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
// A picture pasted or dropped into a note. The import could bring
|
||||
// files in and nothing else could, which made the most ordinary act
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -315,12 +339,26 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// A calendar read from somewhere else, laid under verdande's own dated
|
||||
// tasks. Read-only, so there is no write here to gate: the events are a
|
||||
// copy of somebody else's calendar, and the only thing a person decides
|
||||
// is which of them to look at.
|
||||
r.Route("/calendar", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetCalendar)
|
||||
r.Put("/calendars", s.handleSetCalendars)
|
||||
r.Delete("/", s.handleDisconnectCalendar)
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
|
||||
@@ -388,6 +426,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// Who this instance reports to, and whether it does. An
|
||||
// administrator's decision, not a person's: there is one
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -310,3 +310,61 @@ func cut(text string) string {
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Unflag takes the flag off the messages that have become tasks.
|
||||
//
|
||||
// This is what stops the same mail arriving twice, and it replaces a marker that
|
||||
// could not be made to hold. The marker was a uid: remember the highest one seen,
|
||||
// start above it next time. That is correct only if uids are what the server says
|
||||
// they are, and it broke in every way such a scheme can — a fetch reply without a
|
||||
// uid, a UIDVALIDITY that changed under a rebuilt mailbox, two mailbox rows
|
||||
// pointing at the same folder and each keeping its own marker. Every one of those
|
||||
// produced the same symptom: the same mail became a task again, and again.
|
||||
//
|
||||
// Unflagging moves the record out of our database and into the mailbox, where the
|
||||
// question is asked. "Flagged" means "turn this into a task"; once it is a task,
|
||||
// the flag has done its job and is not true any more. Nothing has to be
|
||||
// remembered, so there is nothing to get out of step — a second sync, a second
|
||||
// mailbox row, or a database restored from a backup all find the same answer,
|
||||
// because the answer is in the mailbox.
|
||||
//
|
||||
// It is destructive in the small way that matters: it changes something in
|
||||
// somebody's mail. That is the trade, and it is the right way round — a flag is a
|
||||
// note to self that this needs doing, and it has been done.
|
||||
//
|
||||
// The folder is re-selected writable. Since() opens it read-only on purpose, and
|
||||
// a search should not need write access just because a later step might.
|
||||
func (c *Client) Unflag(uids []uint32) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.c.Select(c.folder, nil).Wait(); err != nil {
|
||||
return fmt.Errorf("open %s for writing: %w", c.folder, err)
|
||||
}
|
||||
|
||||
set := imap.UIDSetNum()
|
||||
for _, u := range uids {
|
||||
if u == 0 {
|
||||
// No uid means nothing to address. The message keeps its flag and will
|
||||
// be seen again — which is the failure this whole function exists to
|
||||
// prevent, so it is worth saying rather than skipping quietly.
|
||||
continue
|
||||
}
|
||||
set.AddNum(imap.UID(u))
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Silent: the server has nothing to tell us that we would act on, and the
|
||||
// untagged replies would only have to be collected and thrown away.
|
||||
cmd := c.c.Store(set, &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsDel,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagFlagged},
|
||||
}, nil)
|
||||
if err := cmd.Close(); err != nil {
|
||||
return fmt.Errorf("unflag in %s: %w", c.folder, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,3 +316,81 @@ func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Den samme mail må ikke kunne blive en opgave to gange.
|
||||
//
|
||||
// Prøven kører hele vejen imod en rigtig server: den finder de flagede mails,
|
||||
// tager flaget af dem, og spørger så forfra som en ny synkronisering ville — med
|
||||
// markøren sat til nul, altså det værst tænkelige tilfælde, hvor alt, hvad vi
|
||||
// selv havde husket, er væk. Der skal ikke komme noget tilbage.
|
||||
//
|
||||
// Det er det, markøren ikke kunne love. Den holdt kun, hvis uids var, hvad
|
||||
// serveren sagde, og fejlede på et fetch-svar uden uid, på en UIDVALIDITY, der
|
||||
// skiftede, og på to postkasse-rækker, der pegede samme sted og hver især førte
|
||||
// deres eget regnskab. Alle tre gav det samme: mailen blev en opgave igen.
|
||||
func TestAnUnflaggedMailDoesNotComeBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "anders@example.dk", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "noreply@example.com", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "jura@example.dk", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("fandt %d flagede mails, ventede 2", len(msgs))
|
||||
}
|
||||
|
||||
uids := make([]uint32, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
uids = append(uids, m.UID)
|
||||
}
|
||||
if err := client.Unflag(uids); err != nil {
|
||||
t.Fatalf("Unflag: %v", err)
|
||||
}
|
||||
|
||||
// Forfra, og fra nul: selv en instans, der intet husker, må ikke finde dem igen.
|
||||
again, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("%d mails kom igen efter at flaget var taget af — den første af dem er %q",
|
||||
len(again), again[0].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tomt kald må ikke åbne mappen skrivbar for ingenting.
|
||||
func TestUnflagWithNothingToDoIsQuiet(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura", from: "a@example.dk", body: "x", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Unflag(nil); err != nil {
|
||||
t.Errorf("Unflag(nil) = %v", err)
|
||||
}
|
||||
// Kun nuller: der er intet at adressere, og mailen skal beholde sit flag.
|
||||
if err := client.Unflag([]uint32{0, 0}); err != nil {
|
||||
t.Errorf("Unflag(nuller) = %v", err)
|
||||
}
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Errorf("mailen mistede sit flag, uden at nogen havde adresseret den")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,19 @@ type Runner struct {
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
||||
// way and for the same reason: the HTTP layer owns the token refresh.
|
||||
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
SendBeacon func(ctx context.Context) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -69,6 +78,17 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
||||
// becomes a task, so being a quarter of an hour behind on a meeting next
|
||||
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
||||
// it matters, which is somebody who has just moved something and is looking at
|
||||
// the grid to check. A person who has just connected does not wait for this
|
||||
// either; connecting fetches the list, and the first sync is one click away.
|
||||
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
||||
// Checked hourly, sent at most daily — the same shape as the backup, and for
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -103,6 +123,17 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
// compulsory, lives in internal/beacon. This is only the clock.
|
||||
func (r *Runner) sendBeacon(ctx context.Context) error {
|
||||
if r.SendBeacon == nil {
|
||||
return nil
|
||||
}
|
||||
return r.SendBeacon(ctx)
|
||||
}
|
||||
|
||||
// --- reminders ------------------------------------------------------------------
|
||||
|
||||
// sendDueReminders delivers everything that has come due since the last pass.
|
||||
@@ -322,6 +353,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- calendars ------------------------------------------------------------------------
|
||||
|
||||
// syncCalendars refreshes everybody's connected calendars.
|
||||
//
|
||||
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
||||
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
||||
// tasks, this one replaces a window of somebody else's events. Folding them
|
||||
// together would mean one interval for two questions that do not want the same
|
||||
// answer, and a Google that is down would count against a mailbox that is not.
|
||||
//
|
||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
||||
// a person is watching a spinner. One account that has gone quiet costs this run
|
||||
// its own timeout and nothing else: the loop moves on per person.
|
||||
func (r *Runner) syncCalendars(ctx context.Context) error {
|
||||
if r.SyncCalendars == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs, err := r.db.UsersWithCalendars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
written, err := r.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
r.log.Warn("calendar sync", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
if written > 0 {
|
||||
r.log.Info("calendar refreshed", "user", id, "events", written)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- housekeeping -----------------------------------------------------------------
|
||||
|
||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package safedial refuses to open a connection to this machine or to the
|
||||
// network it sits on.
|
||||
//
|
||||
// The problem it solves is the same one three times: somewhere a user supplies a
|
||||
// URL or a host, and the server fetches it. An AI provider's base address, a push
|
||||
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
|
||||
// of them is also a way to ask the server to reach something the caller cannot —
|
||||
// the panel next door, a metadata endpoint, a database on the same bridge — and
|
||||
// to report back whether it answered. On a homelab that is the whole point of the
|
||||
// box being where it is.
|
||||
//
|
||||
// # Why the check is on the address and not on the URL
|
||||
//
|
||||
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
|
||||
// and it does not work: a name resolves to whatever its owner says, so
|
||||
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
|
||||
// time, after a parse-time check has passed. That is DNS rebinding, and the only
|
||||
// place it can be caught is after resolution and before the connection, which is
|
||||
// exactly where net.Dialer's Control hook runs.
|
||||
//
|
||||
// # What is refused
|
||||
//
|
||||
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
|
||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||
// wall around the inside.
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||
// deliberately does not say which address it resolved to: the answer is itself
|
||||
// the thing an attacker is fishing for.
|
||||
type ErrPrivate struct{ Host string }
|
||||
|
||||
func (e *ErrPrivate) Error() string {
|
||||
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
|
||||
}
|
||||
|
||||
// Blocked reports whether an address must not be dialled.
|
||||
func Blocked(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsPrivate()
|
||||
}
|
||||
|
||||
// Control is a net.Dialer Control function that refuses a private destination.
|
||||
//
|
||||
// It runs after the name has been resolved and before the socket is connected,
|
||||
// which is the only moment where the address that will actually be used is known.
|
||||
func Control(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if Blocked(ip) {
|
||||
return &ErrPrivate{Host: host}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialer is a net.Dialer that will not reach inside.
|
||||
func Dialer(timeout time.Duration) *net.Dialer {
|
||||
return &net.Dialer{Timeout: timeout, Control: Control}
|
||||
}
|
||||
|
||||
// Client is an http.Client for fetching something a user named.
|
||||
//
|
||||
// Redirects are followed but capped, and every hop goes through the same dialer —
|
||||
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
|
||||
// URL somebody typed.
|
||||
func Client(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: Dialer(timeout).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AllowPrivate is the same client without the wall, for a destination the
|
||||
// operator configured rather than a user — the panel, and nothing else. Named so
|
||||
// that using it is a decision somebody wrote down.
|
||||
func AllowPrivate(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBlockedCoversTheInside(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "::1", "0.0.0.0",
|
||||
// Skyens metadata-endepunkt. Den ene adresse, enhver SSRF sigter efter.
|
||||
"169.254.169.254",
|
||||
"10.0.0.5", "172.16.0.1", "192.168.1.10", "fd00::1", "fe80::1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946"} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en almindelig adresse", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En httptest-server lytter på loopback, hvilket gør den til den perfekte prøve:
|
||||
// den findes, den svarer, og den må ikke kunne nås.
|
||||
func TestClientRefusesLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("forbindelsen blev oprettet; den skulle være afvist")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Client(3 * time.Second).Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("en forespørgsel til loopback skal afvises")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateStillReaches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := AllowPrivate(3 * time.Second).Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("den skal kunne nå det, operatøren selv har peget på: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The collector's side of the beacon: what has reported in, and how many.
|
||||
//
|
||||
// Every method here is about counting installations, never about identifying one.
|
||||
// There is no lookup by anything but the id an installation made up for itself,
|
||||
// and there is nothing to join it against.
|
||||
|
||||
// BeaconStats is the whole of what the collector can say.
|
||||
type BeaconStats struct {
|
||||
Total int `json:"total"`
|
||||
Active7d int `json:"active_7d"`
|
||||
Active30d int `json:"active_30d"`
|
||||
ByVersion map[string]int `json:"by_version"`
|
||||
LastPingAt *time.Time `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// maxBeaconInstalls bounds the table.
|
||||
//
|
||||
// The receiving endpoint is unauthenticated — it has to be, since the caller is a
|
||||
// stranger's installation — and it inserts a row keyed on an id the caller made
|
||||
// up. Anybody can therefore mint ids and grow this table until the volume the
|
||||
// database lives on is full. A ceiling turns that from a disk-fill into a number
|
||||
// that is merely wrong, which is the difference between an outage and an
|
||||
// annoyance. A hundred thousand is far above any plausible real count and far
|
||||
// below anything that matters on disk.
|
||||
const maxBeaconInstalls = 100_000
|
||||
|
||||
// RecordBeacon writes down that an installation is alive, and on which version.
|
||||
//
|
||||
// `first_seen` is kept from the original row on purpose: it is the only thing
|
||||
// that distinguishes an installation that has been running for a year from one
|
||||
// that appeared this morning, and it costs nothing to keep.
|
||||
func (db *DB) RecordBeacon(ctx context.Context, instanceID, version string) error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// An installation already known keeps reporting whatever the ceiling says: the
|
||||
// cap must never stop counting the real ones, only stop new rows from being
|
||||
// invented without limit.
|
||||
var known int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE instance_id = ?`, instanceID).Scan(&known); err != nil {
|
||||
return err
|
||||
}
|
||||
if known == 0 {
|
||||
var total int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&total); err != nil {
|
||||
return err
|
||||
}
|
||||
if total >= maxBeaconInstalls {
|
||||
// Not an error to the caller. Their installation did nothing wrong, and
|
||||
// there is nothing for them to do about it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO beacon_installs (instance_id, version, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
last_seen = excluded.last_seen`,
|
||||
instanceID, version, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// BeaconCount is how many installations have reported within the window.
|
||||
//
|
||||
// A window rather than a running total, and that is not a detail. A total only
|
||||
// ever climbs: every instance anybody ever started for ten minutes stays in it
|
||||
// forever, so the number stops meaning "how many people use this" and starts
|
||||
// meaning "how many people have ever tried it". The question being asked is the
|
||||
// first one.
|
||||
func (db *DB) BeaconCount(ctx context.Context, within time.Duration) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE last_seen >= ?`,
|
||||
time.Now().Add(-within).Unix()).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BeaconSummary is everything the settings page shows.
|
||||
func (db *DB) BeaconSummary(ctx context.Context) (BeaconStats, error) {
|
||||
out := BeaconStats{ByVersion: map[string]int{}}
|
||||
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&out.Total); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var err error
|
||||
if out.Active7d, err = db.BeaconCount(ctx, 7*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Active30d, err = db.BeaconCount(ctx, 30*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Versions of the ones still alive, not of everything ever seen: an instance
|
||||
// that stopped reporting two years ago should not still be counted as running
|
||||
// the version it was on when it stopped.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT version, count(*) FROM beacon_installs
|
||||
WHERE last_seen >= ? AND version <> ''
|
||||
GROUP BY version`, time.Now().Add(-30*24*time.Hour).Unix())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v string
|
||||
var n int
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.ByVersion[v] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var last *int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT max(last_seen) FROM beacon_installs`).Scan(&last); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if last != nil {
|
||||
t := time.Unix(*last, 0)
|
||||
out.LastPingAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CalendarAccount is one connected account belonging to one person.
|
||||
type CalendarAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Provider string `json:"provider"`
|
||||
Account string `json:"account,omitempty"`
|
||||
|
||||
RefreshToken string `json:"-"` // never leaves the server
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
|
||||
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Calendar is one calendar inside an account, shown or not.
|
||||
type Calendar struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"-"`
|
||||
RemoteID string `json:"remote_id"`
|
||||
Name string `json:"name"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
TimeZone string `json:"time_zone,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
// Writable says the connected account could write to it. Nothing does — the
|
||||
// scope is read-only — and it is stored so the day something can, the interface
|
||||
// knows which calendars could even be offered.
|
||||
Writable bool `json:"writable"`
|
||||
Shown bool `json:"shown"`
|
||||
SortOrder int `json:"-"`
|
||||
}
|
||||
|
||||
// CalendarEvent is one occurrence, in the shape a grid asks about: days at both
|
||||
// ends, inclusive.
|
||||
type CalendarEvent struct {
|
||||
ID string `json:"id"`
|
||||
CalendarID string `json:"calendar_id"`
|
||||
RemoteID string `json:"-"`
|
||||
Summary string `json:"summary"`
|
||||
StartsAt string `json:"starts_at,omitempty"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
StartDay string `json:"start_day"`
|
||||
EndDay string `json:"end_day"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Location string `json:"location,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// CalendarName and Colour ride along with the event rather than being looked up
|
||||
// per row by the caller. Same reasoning as the assignee names on a task list:
|
||||
// the chip has to be drawn in the calendar's own colour, and a second request
|
||||
// per event to learn it is a request per event.
|
||||
CalendarName string `json:"calendar_name,omitempty"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
}
|
||||
|
||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||
access_token, expires_at, last_sync_at, created_at`
|
||||
|
||||
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
||||
a, err := db.scanCalendarAccount(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// SaveCalendarAccount writes one, sealing the two columns that must not travel
|
||||
// with a backup. Called by hand rather than by the settings tables' own seal,
|
||||
// which does not reach this table.
|
||||
func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error {
|
||||
if a.ID == "" {
|
||||
a.ID = NewID()
|
||||
}
|
||||
if a.Provider == "" {
|
||||
a.Provider = "google"
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
refresh, err := db.sealValue(a.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal refresh_token: %w", err)
|
||||
}
|
||||
access, err := db.sealValue(a.AccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal access_token: %w", err)
|
||||
}
|
||||
|
||||
// The conflict is on the pair, not on the id: a reconnect builds a fresh
|
||||
// struct with no id in it, and matching on the id would insert a second row
|
||||
// that the unique index then refuses — which reads to the person as "connecting
|
||||
// failed" when what happened is that it already had.
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
||||
account = excluded.account,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The row that is actually there wins. On a reconnect the insert above updated
|
||||
// an existing row, so the id in hand is one nothing points at — and the
|
||||
// calendars are hung off the id, so writing them against it would attach them
|
||||
// to nothing.
|
||||
return db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
||||
a.UserID, a.Provider).Scan(&a.ID)
|
||||
}
|
||||
|
||||
// MarkCalendarSynced records how far a run got. Written separately from the rest
|
||||
// so a sync never rewrites the credentials it is holding in memory.
|
||||
func (db *DB) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCalendarAccount disconnects one. The calendars and their events go with it
|
||||
// — unlike the tasks a mailbox made, none of this is the person's own work; it is
|
||||
// a copy of somebody else's calendar and it has no meaning without the account.
|
||||
func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsersWithCalendars returns everybody the sweep has to visit.
|
||||
func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// Calendars returns what one account holds, in the order the provider listed them.
|
||||
func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order
|
||||
FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour,
|
||||
&c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceCalendars writes the list the provider just gave, keeping what the person
|
||||
// chose.
|
||||
//
|
||||
// The choice survives because it is not in the list: Google says which calendars
|
||||
// exist and what they are called, and only verdande knows which of them somebody
|
||||
// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar
|
||||
// on does not come undone the next time the sweep runs.
|
||||
//
|
||||
// A calendar that has disappeared from the account is removed, and its events go
|
||||
// with it — a calendar somebody was unshared from should stop being drawn, not
|
||||
// stay behind as a frozen copy of what it held on the last good day.
|
||||
func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
keep := map[string]bool{}
|
||||
for i, c := range list {
|
||||
keep[c.RemoteID] = true
|
||||
// The insert cannot carry `shown`: on conflict it would overwrite the
|
||||
// choice with the default. A new calendar starts hidden — an account
|
||||
// with a dozen shared calendars in it should not fill somebody's grid
|
||||
// the moment they connect it.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendars
|
||||
(id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (account_id, remote_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
colour = excluded.colour,
|
||||
time_zone = excluded.time_zone,
|
||||
primary_one = excluded.primary_one,
|
||||
writable = excluded.writable,
|
||||
sort_order = excluded.sort_order`,
|
||||
NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone,
|
||||
c.Primary, c.Writable, c.Shown, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []string
|
||||
for rows.Next() {
|
||||
var id, remote string
|
||||
if err := rows.Scan(&id, &remote); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !keep[remote] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
// Closed before the next statement runs on this transaction. An open result
|
||||
// set with a query behind it is the deadlock the pool comment in store.go
|
||||
// exists to make slow rather than fatal.
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range gone {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ShowCalendars sets which of an account's calendars are drawn.
|
||||
//
|
||||
// The whole set in one write rather than one flip at a time, for the reason the
|
||||
// projects' order is written the same way: a person has a handful of calendars, the
|
||||
// question is "these ones", and a list that can only be built by several requests
|
||||
// can land half applied.
|
||||
func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range shown {
|
||||
// Scoped in the SQL rather than checked first: an id read in one step
|
||||
// and written in the next is a window, and the account is the whole of
|
||||
// the permission here.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`,
|
||||
id, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Events of a calendar nobody is looking at are dead weight in a table the
|
||||
// grid queries. They come back with the next sync if it is turned on again.
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events WHERE calendar_id IN
|
||||
(SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceCalendarEvents swaps one calendar's events inside a window.
|
||||
//
|
||||
// Replaced rather than merged, and no sync token. Google offers incremental sync,
|
||||
// and it would save bytes verdande is not short of while costing the one thing it
|
||||
// cannot afford: a token that has to be right, forever, for the grid not to be
|
||||
// quietly wrong. A window that is deleted and rewritten cannot drift — an event
|
||||
// somebody moved out of the window disappears because it is not in the answer, not
|
||||
// because verdande remembered to delete it.
|
||||
//
|
||||
// Bounded by the window at both ends, so a sync of September does not throw away
|
||||
// August.
|
||||
func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events
|
||||
WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`,
|
||||
calendarID, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range events {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendar_events
|
||||
(id, calendar_id, remote_id, summary, starts_at, ends_at,
|
||||
start_day, end_day, all_day, location, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt,
|
||||
e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CalendarEvents returns everything one person can see between two days,
|
||||
// inclusive at both ends.
|
||||
//
|
||||
// Scoped to the caller in the join rather than checked afterwards: the events of
|
||||
// somebody else's calendar are somebody else's, and a filter applied after the read
|
||||
// is a filter that can be forgotten.
|
||||
func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at,
|
||||
e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour
|
||||
FROM calendar_events e
|
||||
JOIN calendars c ON c.id = e.calendar_id
|
||||
JOIN calendar_accounts a ON a.id = c.account_id
|
||||
WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ?
|
||||
ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CalendarEvent
|
||||
for rows.Next() {
|
||||
var e CalendarEvent
|
||||
if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt,
|
||||
&e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL,
|
||||
&e.CalendarName, &e.Colour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
var a CalendarAccount
|
||||
var expires, lastSync, created int64
|
||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||
&a.AccessToken, &expires, &lastSync, &created)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
for _, field := range []*string{&a.RefreshToken, &a.AccessToken} {
|
||||
plain, err := db.unsealValue(*field)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
a.ExpiresAt = timeOrZero(expires)
|
||||
a.LastSyncAt = timeOrZero(lastSync)
|
||||
a.CreatedAt = time.Unix(created, 0)
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount {
|
||||
t.Helper()
|
||||
a := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "kw@nolimit.dk",
|
||||
RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
if err := db.SaveCalendarAccount(context.Background(), a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// The same claim the mailbox tokens have to meet: what is written must not be
|
||||
// readable in the file a backup copies.
|
||||
func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
var refresh, access string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`,
|
||||
a.ID).Scan(&refresh, &access); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") {
|
||||
t.Fatalf("a token is in the row as written: %q %q", refresh, access)
|
||||
}
|
||||
// The address stays readable, which is the reason for sealing named columns
|
||||
// rather than the whole row.
|
||||
var account string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account != "kw@nolimit.dk" {
|
||||
t.Errorf("the address was sealed too: %q", account)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken {
|
||||
t.Errorf("the tokens did not survive the round trip: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting again must replace what was there, not fail. It is the same OAuth
|
||||
// flow run a second time, and the person's word for it is "connect", not "add".
|
||||
func TestConnectingAgainReplacesTheAccount(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
first := connectedAccount(t, db, userID)
|
||||
|
||||
second := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "kw@nolimit.dk",
|
||||
RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh",
|
||||
}
|
||||
if err := db.SaveCalendarAccount(ctx, second); err != nil {
|
||||
t.Fatalf("connecting a second time failed: %v", err)
|
||||
}
|
||||
// And the id in hand is the row that is actually there. The calendars hang off
|
||||
// it, so an id nothing points at would attach them to nothing.
|
||||
if second.ID != first.ID {
|
||||
t.Errorf("the second connect came back with id %q, want the existing %q",
|
||||
second.ID, first.ID)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != "1//0-fresh" {
|
||||
t.Errorf("the token came back as %q", back.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Google says which calendars exist; only verdande knows which ones somebody wants
|
||||
// to look at. A refresh that came back with the same list must not undo the choice.
|
||||
func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
list := []Calendar{
|
||||
{RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien", Colour: "#0b8043"},
|
||||
{RemoteID: "old", Name: "Et delt"},
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nothing is shown to begin with: an account with a dozen shared calendars in
|
||||
// it should not fill somebody's grid the moment they connect it.
|
||||
stored, err := db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("got %d calendars, want 3", len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
if c.Shown {
|
||||
t.Errorf("%q was shown without anybody asking for it", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var famID string
|
||||
for _, c := range stored {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The sweep runs again, and one calendar has been renamed and one has gone.
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{
|
||||
{RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err = db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("got %d calendars; one that has left the account should stop being drawn",
|
||||
len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
switch c.RemoteID {
|
||||
case "fam":
|
||||
if !c.Shown {
|
||||
t.Error("the choice was undone by a refresh of the list")
|
||||
}
|
||||
if c.Name != "Familien og hunden" {
|
||||
t.Errorf("the new name did not arrive: %q", c.Name)
|
||||
}
|
||||
if c.ID != famID {
|
||||
t.Error("the row was rebuilt rather than updated; its id is what was chosen")
|
||||
}
|
||||
case "primary":
|
||||
if c.Shown {
|
||||
t.Error("a calendar nobody chose became shown")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A window is replaced, not merged. An event moved out of it has to disappear
|
||||
// because it is absent from the answer — and the months on either side must not go
|
||||
// with it.
|
||||
func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
cal := stored[0]
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"}
|
||||
august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31",
|
||||
[]CalendarEvent{july}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{august}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both months: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// August is synced again and the meeting has been cancelled. July stays.
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary != "Juli" {
|
||||
t.Fatalf("got %+v; syncing August threw away July", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The grid asks "which cells does this cover", so an event that straddles the edge
|
||||
// of the question has to come back.
|
||||
func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30",
|
||||
[]CalendarEvent{
|
||||
{RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A week that contains neither end of it.
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v; an event covering the whole week was not found", got)
|
||||
}
|
||||
// The calendar's own colour rides along, or the chip has to ask for it per row.
|
||||
if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" {
|
||||
t.Errorf("the calendar did not come with the event: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's calendar is somebody else's.
|
||||
func TestOnlyTheOwnersEventsComeBack(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
other := &User{Email: "andreas@example.dk", Name: "Andreas"}
|
||||
if err := db.CreateUser(ctx, other, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"}
|
||||
if err := db.SaveCalendarAccount(ctx, theirs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, theirs.ID)
|
||||
if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("somebody else's calendar came back: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A calendar turned off stops being drawn, and stops costing the grid anything.
|
||||
func TestTurningACalendarOffRemovesItsEvents(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.ShowCalendars(ctx, a.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%d events of a calendar nobody is looking at are still in the table", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the calendars and the events with it. Unlike the tasks a
|
||||
// mailbox made, none of this is the person's own work — it is a copy of somebody
|
||||
// else's calendar and it means nothing without the account.
|
||||
func TestDisconnectingTakesEverythingWithIt(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%s still holds %d rows after disconnecting", table, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep finds people by the presence of an account, so it has to keep working
|
||||
// now that the credential in it is ciphertext.
|
||||
func TestTheSweepFindsAConnectedCalendar(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
connectedAccount(t, db, userID)
|
||||
|
||||
users, err := db.UsersWithCalendars(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 || users[0] != userID {
|
||||
t.Fatalf("the sweep found %v", users)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Attachment struct {
|
||||
// GroupID hangs the file on a project group rather than on any one task in
|
||||
// it: the contract that governs all of "Arbejde", not a step in it.
|
||||
GroupID string
|
||||
NoteID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Size int64
|
||||
@@ -157,17 +158,18 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, group_id, note_id, filename, mime_type,
|
||||
size, path, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
|
||||
nullString(a.NoteID),
|
||||
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -176,11 +178,24 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
// ListNoteAttachments is the files inside one note — the pictures and scans that
|
||||
// came with it, and anything added since.
|
||||
func (db *DB) ListNoteAttachments(ctx context.Context, noteID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE note_id = ? ORDER BY created_at`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
// ListGroupAttachments is the documents that belong to a whole group rather than
|
||||
// to anything inside it.
|
||||
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -195,7 +210,7 @@ func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (ma
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||
ORDER BY created_at`, args...)
|
||||
if err != nil {
|
||||
@@ -218,15 +233,16 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
out := []Attachment{}
|
||||
for rows.Next() {
|
||||
var a Attachment
|
||||
var taskID, commentID, groupID sql.NullString
|
||||
var taskID, commentID, groupID, noteID sql.NullString
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, ¬eID, &a.Filename, &a.MimeType,
|
||||
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CommentID = commentID.String
|
||||
a.GroupID = groupID.String
|
||||
a.NoteID = noteID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, a)
|
||||
}
|
||||
@@ -235,7 +251,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
|
||||
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE id = ?`, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- #garageristeriet and #GarageRisteriet are the same project.
|
||||
--
|
||||
-- They were not, because the key was stored exactly as typed and looked up the
|
||||
-- same way — so a note that named a project in lower case simply did not appear
|
||||
-- on it, silently and with nothing to suggest why.
|
||||
--
|
||||
-- Folded on the way in from now on; this brings the rows already written into
|
||||
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
|
||||
-- and "møde" are two notes.
|
||||
--
|
||||
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
|
||||
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
|
||||
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
|
||||
CREATE TABLE note_links_folded (
|
||||
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
|
||||
target_id TEXT NOT NULL,
|
||||
PRIMARY KEY (note_id, kind, target_id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
|
||||
SELECT note_id, kind,
|
||||
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
|
||||
FROM note_links;
|
||||
|
||||
DROP TABLE note_links;
|
||||
ALTER TABLE note_links_folded RENAME TO note_links;
|
||||
|
||||
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- A note can carry files, the way a task and a group already can.
|
||||
--
|
||||
-- Apple Notes is full of photographs, scans and pasted screenshots, and a note
|
||||
-- that arrives here as words alone is not that note — it is a summary of it. The
|
||||
-- import writes the files as attachments and rewrites the links in the text, so a
|
||||
-- picture that was in the middle of a paragraph is still in the middle of it.
|
||||
--
|
||||
-- Same table as everything else attaches to, rather than one of its own. The files
|
||||
-- are content-addressed and deduplicated on disk, the upload path is written and
|
||||
-- tested, and the cleanup that removes an orphan already walks this table. A
|
||||
-- second table would be a second everything.
|
||||
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
CREATE TABLE attachments_with_notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
|
||||
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
|
||||
note_id TEXT REFERENCES notes (id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
-- Counted, not spelled out as pairs: with four columns the pairwise version is
|
||||
-- six clauses, and the one that is wrong looks exactly like the one that is right.
|
||||
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL)
|
||||
+ (group_id IS NOT NULL) + (note_id IS NOT NULL) = 1)
|
||||
);
|
||||
|
||||
INSERT INTO attachments_with_notes
|
||||
(id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||
SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments;
|
||||
|
||||
DROP TABLE attachments;
|
||||
|
||||
ALTER TABLE attachments_with_notes RENAME TO attachments;
|
||||
|
||||
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_note ON attachments (note_id) WHERE note_id IS NOT NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- One row per installation that has ever reported in.
|
||||
--
|
||||
-- This table only exists on the instance that is acting as the collector. Every
|
||||
-- other instance has it too, empty, because the schema is the same everywhere —
|
||||
-- and because "am I the collector" is a setting, not a build.
|
||||
--
|
||||
-- What is deliberately NOT here is the point of the whole feature: there is no
|
||||
-- column for an IP address, a hostname, a domain, an account, a count of tasks,
|
||||
-- or anything else. The two things stored are the two things sent, and the table
|
||||
-- is the promise written down where it cannot drift from the prose — a column
|
||||
-- added here later is a promise broken, and it should be as awkward to add as
|
||||
-- possible.
|
||||
--
|
||||
-- `instance_id` is the primary key rather than a rowid alias, because the same
|
||||
-- installation reporting daily must update its row rather than grow a new one.
|
||||
-- What is wanted is "how many installations", not "how many pings".
|
||||
CREATE TABLE beacon_installs (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The count is always "seen in the last N days", so last_seen is what every query
|
||||
-- sorts and filters on.
|
||||
CREATE INDEX beacon_by_last_seen ON beacon_installs (last_seen DESC);
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Calendars read from somewhere else: the account, the calendars in it, and the
|
||||
-- events they hold.
|
||||
--
|
||||
-- Not in `mailboxes`, and it was the first thing considered. The two look alike
|
||||
-- from a distance — a person connects a Google account over OAuth, tokens are
|
||||
-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`,
|
||||
-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a
|
||||
-- task", and nothing here turns into a task. What is here instead is a *set*:
|
||||
-- one account holds many calendars, and a person picks which of them to look at.
|
||||
-- `mailboxes` has no room for a one-to-many, and giving it one would push the
|
||||
-- other kind's columns onto rows that can never use them.
|
||||
--
|
||||
-- The decisive part is smaller and more practical. `mailboxes.kind` carries
|
||||
-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a
|
||||
-- third value means rebuilding the table with foreign keys off, which is the
|
||||
-- expensive, careful thing migration 0021 had to do for a reason. Paying that to
|
||||
-- store something that shares no column with the table is the wrong trade.
|
||||
--
|
||||
-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it
|
||||
-- should cost a row, not a rebuild.
|
||||
|
||||
-- One connected account per person per provider. The tokens live here rather than
|
||||
-- on each calendar: a refresh token copied onto ten rows is ten copies of one
|
||||
-- secret, ten refreshes racing each other, and ten places to miss when access is
|
||||
-- revoked.
|
||||
--
|
||||
-- `refresh_token` and `access_token` are written sealed; see internal/secret. The
|
||||
-- store's own seal/unseal only covers the settings tables, so this table's methods
|
||||
-- call them by hand — the same fact worth knowing that migration 0014 wrote down.
|
||||
CREATE TABLE calendar_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'google',
|
||||
-- The address the account reports, so the settings page can say which one is
|
||||
-- connected rather than only that one is.
|
||||
account TEXT NOT NULL DEFAULT '',
|
||||
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow
|
||||
-- is one account at a time, so a second row for the same provider would be a
|
||||
-- second set of tokens for the same grant and no way to say which is live.
|
||||
CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider);
|
||||
|
||||
-- One row per calendar the account can see, kept whether or not it is shown.
|
||||
--
|
||||
-- `shown` rather than deleting the ones that are not: a person who turns a calendar
|
||||
-- off and on again should get the same row back, and the list is refreshed from
|
||||
-- Google on every sync — so a calendar that only existed while it was ticked would
|
||||
-- lose its choice every time somebody added a new one.
|
||||
--
|
||||
-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named
|
||||
-- colours, which is the rule everywhere else in this database: those are names so
|
||||
-- the interface can restyle them per theme, and they are *verdande's* palette. A
|
||||
-- Google calendar is told apart by the colour the person already knows it by, and
|
||||
-- that colour belongs to Google.
|
||||
CREATE TABLE calendars (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE,
|
||||
-- The id Google knows it by. Long, and an address for the primary calendar.
|
||||
remote_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
colour TEXT NOT NULL DEFAULT '',
|
||||
time_zone TEXT NOT NULL DEFAULT '',
|
||||
primary_one INTEGER NOT NULL DEFAULT 0,
|
||||
writable INTEGER NOT NULL DEFAULT 0,
|
||||
shown INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id);
|
||||
|
||||
-- The events themselves, cached.
|
||||
--
|
||||
-- Cached rather than fetched per view, and the reason is what a month grid does:
|
||||
-- it pages. Asking Google on every arrow press means a token refresh, a round trip
|
||||
-- and a spinner for a question that was answered ninety seconds ago — and a
|
||||
-- calendar that is slower than the calendar it is showing is not worth having.
|
||||
-- The window is refreshed by the sweep and by the "fetch now" button.
|
||||
--
|
||||
-- Days rather than instants, and both ends inclusive. A grid asks one question —
|
||||
-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that
|
||||
-- question. `starts_at` is kept as Google wrote it, offset and all, because it is
|
||||
-- what the chip shows and reparsing it here would ask *this process* what time
|
||||
-- zone it is in. An all-day event has neither: it has a day, not a moment, and
|
||||
-- inventing midnight for it is how an event moves a day when a container moves
|
||||
-- time zone.
|
||||
CREATE TABLE calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE,
|
||||
remote_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
starts_at TEXT NOT NULL DEFAULT '',
|
||||
ends_at TEXT NOT NULL DEFAULT '',
|
||||
start_day TEXT NOT NULL,
|
||||
end_day TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- The one query there is: everything a person can see, between two days. Ordered
|
||||
-- by the account so the join has somewhere to start, then by the days the grid
|
||||
-- compares against.
|
||||
CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day);
|
||||
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
||||
}
|
||||
n.UpdatedAt = now
|
||||
|
||||
// A note with no title takes its first line, the way Apple Notes does. A list
|
||||
// of untitled notes is a list nobody can read.
|
||||
if strings.TrimSpace(n.Title) == "" {
|
||||
n.Title = firstLine(n.Body)
|
||||
}
|
||||
// The title is always the first line, the way Apple Notes does it. Not "when it
|
||||
// is empty": derived once and then left alone, it goes stale the moment somebody
|
||||
// rewrites the opening of a note, and the list ends up calling a note by a name
|
||||
// that is nowhere in it. The column is a cache of the body, not a field beside
|
||||
// it, and every caller gets the same answer because none of them can set it.
|
||||
n.Title = firstLine(n.Body)
|
||||
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var projectID any
|
||||
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
|
||||
add("task", strings.ToLower(m[1]))
|
||||
}
|
||||
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
||||
add("project", m[1])
|
||||
// Folded, because #garageristeriet and #GarageRisteriet are the same project
|
||||
// to everybody except a database. The key is stored folded and looked up
|
||||
// folded, so the comparison stays an exact match and keeps using its index —
|
||||
// a comparison that lowered the column instead would read the whole table.
|
||||
//
|
||||
// The project's real spelling is not lost: it is on the project, which is
|
||||
// what the interface shows. This column is a key, not a label.
|
||||
add("project", strings.ToLower(m[1]))
|
||||
}
|
||||
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
||||
add("note", strings.TrimSpace(m[1]))
|
||||
@@ -209,6 +217,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
// The reason note_links exists. Without it this would mean reading every note's
|
||||
// prose, and a panel that slow is one nobody leaves open.
|
||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||
if kind == "project" {
|
||||
targetID = strings.ToLower(targetID)
|
||||
}
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
||||
@@ -236,6 +247,37 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
||||
LIMIT ?`, expr, limit)
|
||||
}
|
||||
|
||||
// SetNoteTimes writes the dates a note had somewhere else.
|
||||
//
|
||||
// Only the import calls this, and it exists because SaveNote is right to refuse:
|
||||
// `updated_at` is when this program last wrote the note, and letting any caller
|
||||
// set it would make the column mean whatever the last writer felt like. A note
|
||||
// brought in from Apple Notes is the one case where the dates are facts from
|
||||
// before this database existed — and without them a move of twelve hundred notes
|
||||
// is a pile that was all written the same evening, which throws away the order
|
||||
// they were written in. That order is half of what an archive is.
|
||||
func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error {
|
||||
if created.IsZero() && updated.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// COALESCE, so a note carrying only one of the two keeps what it already had
|
||||
// for the other rather than being stamped with the zero time — which would sort
|
||||
// it to the year one.
|
||||
var c, u any
|
||||
if !created.IsZero() {
|
||||
c = created.Unix()
|
||||
}
|
||||
if !updated.IsZero() {
|
||||
u = updated.Unix()
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE notes
|
||||
SET created_at = COALESCE(?, created_at),
|
||||
updated_at = COALESCE(?, updated_at)
|
||||
WHERE id = ?`, c, u, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
|
||||
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
|
||||
if len(projects) != 2 {
|
||||
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
||||
}
|
||||
if projects[0] != "Firma" || projects[1] != "Regnskab" {
|
||||
// Folded: the key is a key, not a label. #firma and #Firma are the same project
|
||||
// to everybody except a database, and the project's own spelling is on the
|
||||
// project, which is what the interface shows.
|
||||
if projects[0] != "firma" || projects[1] != "regnskab" {
|
||||
t.Errorf("got %v", projects)
|
||||
}
|
||||
}
|
||||
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The title is the first line now, so that is where it goes.
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Title: "Grønt regnskab",
|
||||
Body: "Målt på årsbasis for Århus.",
|
||||
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
|
||||
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
||||
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
||||
{"the same tag twice is one link", "#Firma og #Firma igen",
|
||||
[]NoteLink{{"project", "Firma"}}},
|
||||
[]NoteLink{{"project", "firma"}}},
|
||||
{"two spellings are one tag", "#Firma og #firma",
|
||||
[]NoteLink{{"project", "firma"}}},
|
||||
{"a heading is not a tag", "# Overskrift", nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
|
||||
t.Errorf("after deleting one, the count is %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The title follows the first line, always — not only the first time.
|
||||
//
|
||||
// Derived once and then left alone, it goes stale the moment somebody rewrites
|
||||
// the opening, and the list ends up calling a note by a name that is nowhere in
|
||||
// it. That is exactly what happened: a note headed "Ny note" was listed as
|
||||
// "fdgfgfgh", which was what its first line had been some edits ago.
|
||||
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Første udgave" {
|
||||
t.Fatalf("title started as %q", n.Title)
|
||||
}
|
||||
|
||||
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Ny note" {
|
||||
t.Errorf("after rewriting the first line the title is %q", n.Title)
|
||||
}
|
||||
|
||||
back, err := db.Note(ctx, n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Title != "Ny note" {
|
||||
t.Errorf("the stored title is %q", back.Title)
|
||||
}
|
||||
|
||||
// And a title cannot be set beside the text, because then the two can disagree.
|
||||
n.Title = "noget andet"
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Ny note" {
|
||||
t.Errorf("a title set by hand survived as %q", n.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// A note that names a project in the other case must still turn up on it.
|
||||
//
|
||||
// It did not, and nothing said why: the key was stored exactly as typed and
|
||||
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
|
||||
// different things that looked identical to a person.
|
||||
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Body: "Bønnerne kommer fredag #garageristeriet",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
|
||||
found, err := db.NotesLinking(ctx, "project", spelling)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
|
||||
}
|
||||
}
|
||||
|
||||
// A note title is a title, not a key: two notes called "Møde" and "møde" are
|
||||
// two notes, and folding them together would merge things somebody kept apart.
|
||||
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
|
||||
t.Error("a note title was folded")
|
||||
}
|
||||
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
|
||||
t.Error("a note title did not match itself")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,13 +621,13 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/today-light.webp"
|
||||
alt="The same Today view in the light theme."
|
||||
src="screenshots/notes.webp"
|
||||
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>The light theme.</figcaption>
|
||||
<figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -642,7 +642,7 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/detail.webp"
|
||||
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
||||
alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
@@ -652,12 +652,12 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/themes.webp"
|
||||
alt="The settings page showing the five themes as swatches."
|
||||
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Five themes, all of them measured for contrast.</figcaption>
|
||||
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -689,6 +689,16 @@
|
||||
invite link. Changes appear live.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Notes, beside the work</h3>
|
||||
<p>
|
||||
Rich text to write in, Markdown on disk. File a note in a project and
|
||||
everybody in it can read it; <code>#project</code> and
|
||||
<code>[[another note]]</code> are links, and a task shows what has been
|
||||
written about it. A zip of Markdown goes in and comes back out the same
|
||||
shape, so nothing is trapped here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>A real CalDAV server</h3>
|
||||
<p>
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,491 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apple Noter → Markdown, med formateringen i behold.
|
||||
|
||||
Den første udgave af det her brugte `textutil -convert txt`, som er hurtigt at
|
||||
skrive og smider alt væk: overskrifter, fed, monospace, lister. Ti prøvenoter
|
||||
viste hvad det koster — en note er ikke kun sine ord, den er også hvordan de er
|
||||
sat op, og et referat med overskrifter er ikke det samme referat som en klump.
|
||||
|
||||
Apple Noter er HTML indeni, og HTML kan læses. Kortet er lille, fordi Noter selv
|
||||
bruger et lille sæt:
|
||||
|
||||
<h1> <h2> <h3> overskrifter → # ## ###
|
||||
<b> <i> <u> fremhævning → ** * <u>
|
||||
<tt> monospace → ```-blok
|
||||
<ul><li> punktliste → -
|
||||
<ol><li> nummereret → 1.
|
||||
<div> en linje → linjeskift
|
||||
<br> en tom linje → tom linje
|
||||
|
||||
Kører på din egen maskine og sender ingenting nogen steder.
|
||||
|
||||
./apple-notes-til-markdown.py ~/Desktop/noter # alle
|
||||
./apple-notes-til-markdown.py ~/Desktop/noter 10 # de første ti
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
|
||||
# Fra lukkemærke tilbage til åbnemærke, når et mærke skal deles over to linjer.
|
||||
OPENER = {"**": "**", "*": "*", "</u>": "<u>"}
|
||||
MARKS_ONLY = re.compile(r"(?:\*\*|\*|~~|<u>|</u>|\s)*")
|
||||
FORBIDDEN = set('/\\:*?"<>|')
|
||||
|
||||
|
||||
def osascript(script: str) -> str:
|
||||
"""Ét kald til Noter. Fejl er tomme frem for fatale: en enkelt note, der ikke
|
||||
kan læses, skal ikke stoppe en kørsel på tolv hundrede."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
return out.stdout.rstrip("\n")
|
||||
except subprocess.SubprocessError:
|
||||
return ""
|
||||
|
||||
|
||||
class NoteParser(HTMLParser):
|
||||
"""Apple Noters HTML til Markdown.
|
||||
|
||||
Linjebaseret, fordi Noter er linjebaseret: hver <div> er en linje, og det er
|
||||
dét, personen skrev. En rigtig Markdown-gengiver ville slå linjer sammen til
|
||||
afsnit, hvilket er korrekt Markdown og forkert i forhold til noten.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.lines: list[str] = []
|
||||
self.line: list[str] = []
|
||||
self.block: str | None = None # h1, h2 …
|
||||
self.mono = False # den aktuelle linje er monospace
|
||||
self.mono_lines: list[str] = [] # samlet op til én kodeblok
|
||||
self.list_kind: list[str] = [] # ul/ol i lag
|
||||
self.list_no: list[int] = []
|
||||
self.marks: list[str] = [] # b, i, u i lag
|
||||
|
||||
# --- linjer ---------------------------------------------------------------
|
||||
|
||||
def _flush_line(self):
|
||||
# Et mærke, der står åbent, når linjen slutter, lukkes her og åbnes igen på
|
||||
# den næste.
|
||||
#
|
||||
# Noter skriver <b>Møde forberedelse<br></b>: linjeskiftet ligger *inde i*
|
||||
# det fede. Skrev man linjen ud uden videre, blev de to stjerner, der lukker,
|
||||
# til den næste linjes første tegn — "**Møde forberedelse" og så en linje med
|
||||
# "**" på. Markdown er linjebaseret; et mærke, der skal over et linjeskift,
|
||||
# findes ikke, og skal derfor deles i to.
|
||||
reopen = list(self.marks)
|
||||
for closer in reversed(self.marks):
|
||||
self.line.append(closer)
|
||||
|
||||
text = "".join(self.line).rstrip()
|
||||
|
||||
# En linje, der kun er mærker, er en tom linje. Det sker for den sidste
|
||||
# stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet.
|
||||
if MARKS_ONLY.fullmatch(text):
|
||||
text = ""
|
||||
|
||||
self.line = [OPENER[c] for c in reopen]
|
||||
mono, self.mono = self.mono, False
|
||||
|
||||
if mono:
|
||||
# Monospace samles op og skrives som én blok, ikke som en række
|
||||
# enkeltlinjer: en terminaludskrift er én ting.
|
||||
self.mono_lines.append(text)
|
||||
return
|
||||
|
||||
# En tom linje lukker ikke en kodeblok. Hver linje i Noter er pakket ind i
|
||||
# sin egen <div>, og dens starttag tømmer den forrige linje — så mellem to
|
||||
# <tt>-linjer kommer der altid en tom flush forbi. Lod man den lukke
|
||||
# blokken, blev hver eneste linje i en terminaludskrift sin egen blok.
|
||||
if not text and self.mono_lines:
|
||||
return
|
||||
|
||||
self._close_mono()
|
||||
|
||||
if self.block:
|
||||
self.lines.append(BLOCK_PREFIX[self.block] + text if text else "")
|
||||
return
|
||||
if self.list_kind:
|
||||
if not text:
|
||||
return
|
||||
if self.list_kind[-1] == "ol":
|
||||
self.list_no[-1] += 1
|
||||
self.lines.append(f"{self.list_no[-1]}. {text}")
|
||||
else:
|
||||
self.lines.append(f"- {text}")
|
||||
return
|
||||
self.lines.append(text)
|
||||
|
||||
def _close_mono(self):
|
||||
if not self.mono_lines:
|
||||
return
|
||||
# Tomme linjer i enderne er afstand omkring blokken, ikke en del af koden.
|
||||
block = self.mono_lines
|
||||
while block and not block[0].strip():
|
||||
block.pop(0)
|
||||
while block and not block[-1].strip():
|
||||
block.pop()
|
||||
if block:
|
||||
self.lines.append("```")
|
||||
self.lines.extend(block)
|
||||
self.lines.append("```")
|
||||
# Luft efter blokken. Uden den klæber den næste sætning til den
|
||||
# afsluttende hegnslinje, og en Markdown-læser er i sin gode ret til
|
||||
# at læse dem som ét.
|
||||
self.lines.append("")
|
||||
self.mono_lines = []
|
||||
|
||||
def _open(self, opener: str, closer: str):
|
||||
"""Åbner et mærke — eller fortryder det, der lige lukkede.
|
||||
|
||||
Noter deler også fremhævet tekst op i kørsler: "Møde" står som
|
||||
<b>M</b><b>øde</b>, fordi der er en attributkørsel bag hver stump. Skrevet
|
||||
ligeud bliver det **M****øde**, som er ét ord i fed sagt på den grimmest
|
||||
mulige måde — og for kursiv bliver det *M**øde*, hvor de to stjerner i
|
||||
midten læses som fed af enhver Markdown-læser. Ordet kom ud forkert.
|
||||
|
||||
Lukkes et mærke og åbnes det samme igen med det samme, er der ikke sket
|
||||
noget imellem. Så tages lukningen tilbage, og de to kørsler er ét mærke.
|
||||
"""
|
||||
if self.line and self.line[-1] == closer:
|
||||
self.line.pop()
|
||||
else:
|
||||
self.line.append(opener)
|
||||
self.marks.append(closer)
|
||||
|
||||
# --- tags -----------------------------------------------------------------
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in BLOCK_PREFIX:
|
||||
# En overskrift, der fortsætter den forrige, er den samme linje.
|
||||
#
|
||||
# Noter deler en linje op i så mange stumper, den har brug for — en
|
||||
# overskrift skrevet i ét hug står som
|
||||
# <h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>, fordi der er en
|
||||
# attributkørsel bag hver stump. Læste man et linjeskift ved hver <h1>,
|
||||
# blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og
|
||||
# noten så ødelagt ud på den mest synlige måde, der findes: i sin egen
|
||||
# titel.
|
||||
#
|
||||
# Der brydes derfor kun, når blokken skifter til en anden slags. Selve
|
||||
# linjeskiftene kommer fra <div> og <br>, som er dem, der betyder det.
|
||||
if self.block != tag:
|
||||
self._flush_line()
|
||||
self.block = tag
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
self.list_kind.append(tag)
|
||||
self.list_no.append(0)
|
||||
elif tag == "li":
|
||||
self._flush_line()
|
||||
elif tag in ("tt", "code", "pre"):
|
||||
# Ikke et linjeskift: <tt> står inde i den <div>, der ER linjen.
|
||||
# Mærket sættes på linjen og aflæses, når den skrives ud.
|
||||
self.mono = True
|
||||
elif tag in ("b", "strong"):
|
||||
self._open("**", "**")
|
||||
elif tag in ("i", "em"):
|
||||
self._open("*", "*")
|
||||
elif tag == "u":
|
||||
self._open("<u>", "</u>")
|
||||
elif tag == "br":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in BLOCK_PREFIX:
|
||||
# Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste
|
||||
# stump af samme linje ikke se, at den hører til — det er <div> og <br>,
|
||||
# der afgør, hvor linjen holder op.
|
||||
pass
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
if self.list_kind:
|
||||
self.list_kind.pop()
|
||||
self.list_no.pop()
|
||||
elif tag == "li":
|
||||
self._flush_line()
|
||||
elif tag in ("tt", "code", "pre"):
|
||||
# Blokken lukkes IKKE her. To <tt>-linjer efter hinanden er én
|
||||
# terminaludskrift, ikke to; den lukkes, når en linje uden monospace
|
||||
# skrives ud, hvilket _flush_line sørger for.
|
||||
pass
|
||||
elif tag in ("b", "strong", "i", "em", "u"):
|
||||
if self.marks:
|
||||
self.line.append(self.marks.pop())
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
|
||||
def handle_data(self, data):
|
||||
self.line.append(data)
|
||||
|
||||
def result(self) -> str:
|
||||
self._flush_line()
|
||||
self._close_mono()
|
||||
# Tomme linjer i stimer bliver til én: Noter er fuld af <div><br></div>,
|
||||
# og fem tomme linjer i træk er ikke afstand, det er rod.
|
||||
out, blanks = [], 0
|
||||
for line in self.lines:
|
||||
if line.strip():
|
||||
blanks = 0
|
||||
out.append(line)
|
||||
else:
|
||||
blanks += 1
|
||||
if blanks == 1:
|
||||
out.append("")
|
||||
while out and not out[-1].strip():
|
||||
out.pop()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def to_markdown(body: str) -> str:
|
||||
p = NoteParser()
|
||||
p.feed(body)
|
||||
p.close()
|
||||
return p.result()
|
||||
|
||||
|
||||
def safe_name(title: str, fallback: str) -> str:
|
||||
name = "".join("-" if c in FORBIDDEN or ord(c) < 32 else c for c in title).strip()
|
||||
name = name.rstrip(" .")
|
||||
return name[:80] or fallback
|
||||
|
||||
|
||||
# Skilletegn, der ikke står i en note: enhedsadskillere fra ASCII, som ingen
|
||||
# skriver med et tastatur.
|
||||
FIELD, RECORD = "\u241f", "\u241e"
|
||||
|
||||
|
||||
def osascript_file(script: str) -> str:
|
||||
"""Som osascript(), men til et manuskript, der er for stort til -e."""
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".applescript", delete=False,
|
||||
encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
path = f.name
|
||||
try:
|
||||
out = subprocess.run(["osascript", path], capture_output=True, text=True,
|
||||
timeout=600, check=False)
|
||||
return out.stdout
|
||||
except subprocess.SubprocessError:
|
||||
return ""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def fetch(ids: list[str]) -> list[dict]:
|
||||
"""Navn, krop og datoer for en håndfuld noter i ét kald.
|
||||
|
||||
Ét kald pr. note var det oplagte, og det tog halvanden time for tolv hundrede
|
||||
— ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal
|
||||
startes. Det gjorde det umuligt at rette en fejl i oversættelsen og se
|
||||
resultatet: hver runde var en frokostpause. Nu hentes de i klumper, og hele
|
||||
eksporten tager minutter.
|
||||
|
||||
Klumper frem for alle på én gang, fordi en enkelt uleselig note ellers ville
|
||||
tage hele kørslen med sig — og fordi svaret skal kunne være i hukommelsen.
|
||||
|
||||
Datoerne læses som tal frem for som tekst. `creation date` giver en dato
|
||||
formateret efter maskinens sprog, og den ville skulle gættes tilbage; year,
|
||||
month og day er de samme tal på enhver maskine.
|
||||
"""
|
||||
quoted = ", ".join('"' + i.replace('"', '\\"') + '"' for i in ids)
|
||||
script = f'''
|
||||
tell application "Notes"
|
||||
set out to ""
|
||||
repeat with theId in {{{quoted}}}
|
||||
try
|
||||
set n to note id theId
|
||||
set c to creation date of n
|
||||
set m to modification date of n
|
||||
set out to out & (name of n) & "{FIELD}" & ¬
|
||||
((year of c) & "-" & (month of c as integer) & "-" & (day of c) & "T" & ¬
|
||||
(hours of c) & ":" & (minutes of c) & ":" & (seconds of c)) & "{FIELD}" & ¬
|
||||
((year of m) & "-" & (month of m as integer) & "-" & (day of m) & "T" & ¬
|
||||
(hours of m) & ":" & (minutes of m) & ":" & (seconds of m)) & "{FIELD}" & ¬
|
||||
(count of attachments of n) & "{FIELD}" & ¬
|
||||
(body of n) & "{RECORD}"
|
||||
on error
|
||||
set out to out & "{FIELD}{FIELD}{FIELD}{FIELD}" & "{RECORD}"
|
||||
end try
|
||||
end repeat
|
||||
return out
|
||||
end tell
|
||||
'''
|
||||
raw = osascript_file(script)
|
||||
rows = []
|
||||
for chunk in raw.split(RECORD):
|
||||
if not chunk.strip("\n "):
|
||||
continue
|
||||
parts = chunk.split(FIELD)
|
||||
if len(parts) < 5:
|
||||
rows.append({"name": "", "created": "", "modified": "", "files": 0, "body": ""})
|
||||
continue
|
||||
rows.append({
|
||||
"name": parts[0].lstrip("\n"), "created": parts[1], "modified": parts[2],
|
||||
"files": int(parts[3]) if parts[3].strip().isdigit() else 0,
|
||||
"body": parts[4],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def iso(stamp: str) -> str:
|
||||
""""2026-8-17T12:55:14" → "2026-08-17T12:55:14". Nul foran, så det sorterer."""
|
||||
try:
|
||||
date, _, time = stamp.partition("T")
|
||||
y, mo, d = (int(x) for x in date.split("-"))
|
||||
h, mi, se = (int(x) for x in time.split(":"))
|
||||
return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{se:02d}"
|
||||
except (ValueError, AttributeError):
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Id'erne først, i ét kald.
|
||||
#
|
||||
# Ikke "note 1", "note 2" …: Noter sorterer listen efter ændringsdato, så et
|
||||
# indeks peger på noget andet, hvis noget flytter sig imellem to kald — og
|
||||
# navn og krop blev hentet hver for sig. Resultatet var en note med én titel
|
||||
# og en andens indhold, hvilket først ses, når man læser dem igennem. Et id
|
||||
# peger på den samme note hver gang.
|
||||
ids = osascript(
|
||||
'tell application "Notes" to return id of every note'
|
||||
)
|
||||
if not ids:
|
||||
print("Noter svarer ikke. Første gang skal macOS have lov — se efter dialogen.")
|
||||
return 1
|
||||
ids = [x.strip() for x in ids.split(",") if x.strip()]
|
||||
|
||||
total = len(ids)
|
||||
print(f"Fandt {total} noter.")
|
||||
if limit and limit < total:
|
||||
ids = ids[:limit]
|
||||
total = limit
|
||||
print(f"Tager de første {total}.")
|
||||
|
||||
used: dict[str, int] = {}
|
||||
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
|
||||
|
||||
CHUNK = 40
|
||||
fetched: list[dict] = []
|
||||
for start in range(0, len(ids), CHUNK):
|
||||
fetched.extend(fetch(ids[start:start + CHUNK]))
|
||||
print(f" hentet {min(start + CHUNK, len(ids))} / {total}")
|
||||
|
||||
for i, (note_id, row) in enumerate(zip(ids, fetched), start=1):
|
||||
title = row["name"].strip() or f"uden titel {i}"
|
||||
text = to_markdown(row["body"])
|
||||
|
||||
# Kroppen begynder med notens egen titel. Skrives der en overskrift
|
||||
# ovenover, står titlen to gange i hver eneste note.
|
||||
#
|
||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
||||
# starttag tømmer linjen før den — så første linje er tom, og
|
||||
# sammenligningen så på ingenting.
|
||||
# Titlen kan være delt over flere overskrifter.
|
||||
#
|
||||
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||
#
|
||||
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||
lines = text.split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
|
||||
taken, joined = 0, ""
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
taken += 1
|
||||
continue
|
||||
if not line.lstrip().startswith("#"):
|
||||
break
|
||||
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||
if not title.strip().startswith(candidate):
|
||||
break
|
||||
joined, taken = candidate, taken + 1
|
||||
if joined == title.strip():
|
||||
break
|
||||
|
||||
if joined == title.strip():
|
||||
lines = lines[taken:]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
text = "\n".join(lines)
|
||||
|
||||
name = safe_name(title, f"note-{i}")
|
||||
used[name] = used.get(name, 0) + 1
|
||||
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
|
||||
|
||||
# Datoerne i et hoved, som enhver anden Markdown-læser kender.
|
||||
#
|
||||
# Uden dem er en flytning af tolv hundrede noter en bunke, der alle er
|
||||
# skrevet samme aften: rækkefølgen, som er halvdelen af hvad et arkiv er,
|
||||
# findes ikke længere. Obsidian, Bear og alt andet læser den her blok, og
|
||||
# verdandes egen import gør nu også.
|
||||
created, modified = iso(row["created"]), iso(row["modified"])
|
||||
head = ""
|
||||
if created or modified:
|
||||
head = "---\n"
|
||||
if created:
|
||||
head += f"created: {created}\n"
|
||||
if modified:
|
||||
head += f"modified: {modified}\n"
|
||||
head += "---\n\n"
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{head}# {title}\n\n{text}\n")
|
||||
|
||||
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
|
||||
# note, der kommer over som ord alene, er ikke den note.
|
||||
# Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver
|
||||
# tiende note har et bilag, var de ni af dem en proces startet for at få
|
||||
# svaret nul.
|
||||
if row["files"] > 0:
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
for j in range(1, row["files"] + 1):
|
||||
a_name = osascript(
|
||||
f'tell application "Notes" to return name of attachment {j} '
|
||||
f'of note id "{note_id}"'
|
||||
) or f"bilag-{i}-{j}"
|
||||
a_file = f"{i}-{j}-" + safe_name(a_name, f"bilag-{i}-{j}")
|
||||
target = os.path.join(attachments_dir, a_file)
|
||||
# Nogle vedhæftninger — links til websider, kort — har ingen
|
||||
# fil bag sig og fejler her. De springes over.
|
||||
osascript(
|
||||
f'tell application "Notes" to save attachment {j} '
|
||||
f'of note id "{note_id}" in POSIX file "{target}"'
|
||||
)
|
||||
if os.path.exists(target):
|
||||
f.write(f"\n})\n")
|
||||
|
||||
if i % 25 == 0:
|
||||
print(f" {i} / {total}")
|
||||
|
||||
print(f"\nFærdig. {total} noter ligger i:\n {out_dir}\n")
|
||||
print("Pak dem og importér zip'en under Indstillinger → Data:")
|
||||
print(f' cd "{os.path.dirname(out_dir)}" && zip -r "{os.path.basename(out_dir)}.zip" "{os.path.basename(out_dir)}"')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||
|
||||
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||
|
||||
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||
portion, ville pege på ingenting.
|
||||
|
||||
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||
# portion mere end en, der ryger på målstregen.
|
||||
DEFAULT_LIMIT_MB = 40
|
||||
|
||||
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||
out_dir = src.rstrip("/") + "-portioner"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||
if not notes:
|
||||
print(f"Ingen .md-filer i {src}")
|
||||
return 1
|
||||
|
||||
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||
weighed = []
|
||||
for name in notes:
|
||||
path = os.path.join(src, name)
|
||||
text = open(path, encoding="utf-8", errors="replace").read()
|
||||
files = []
|
||||
size = os.path.getsize(path)
|
||||
for rel in LINK.findall(text):
|
||||
f = os.path.join(src, rel)
|
||||
if os.path.exists(f):
|
||||
files.append(rel)
|
||||
size += os.path.getsize(f)
|
||||
weighed.append((name, files, size))
|
||||
|
||||
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||
# kan sendes.
|
||||
batches, current, current_size = [], [], 0
|
||||
for item in weighed:
|
||||
if current and current_size + item[2] > limit:
|
||||
batches.append(current)
|
||||
current, current_size = [], 0
|
||||
current.append(item)
|
||||
current_size += item[2]
|
||||
if current:
|
||||
batches.append(current)
|
||||
|
||||
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||
for i, batch in enumerate(batches, start=1):
|
||||
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||
seen = set()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, files, _ in batch:
|
||||
z.write(os.path.join(src, name), name)
|
||||
for rel in files:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
z.write(os.path.join(src, rel), rel)
|
||||
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||
|
||||
print(f"\nLigger i:\n {out_dir}")
|
||||
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,7 +5,7 @@
|
||||
// cookie. Kept in the repository so the images can be regenerated when the
|
||||
// interface changes, rather than being a thing somebody did once by hand.
|
||||
//
|
||||
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots
|
||||
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
func main() {
|
||||
base := flag.String("url", "http://localhost:8096", "the running instance")
|
||||
cookie := flag.String("cookie", "", "session cookie value")
|
||||
out := flag.String("out", "site/screenshots", "where to write the PNGs")
|
||||
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
|
||||
// PNGs dropped in beside them are files nothing points at.
|
||||
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
|
||||
width := flag.Int("width", 1400, "viewport width")
|
||||
height := flag.Int("height", 900, "viewport height")
|
||||
flag.Parse()
|
||||
|
||||
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('males inden fredag', { exact: true }).click();
|
||||
// Titlen alene, ikke rækken med datomærket i.
|
||||
//
|
||||
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
|
||||
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
|
||||
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
|
||||
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
|
||||
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
|
||||
// datoer; den skal ikke vide hvilken dag det er.
|
||||
await page.getByText('males inden', { exact: true }).click();
|
||||
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
||||
@@ -971,6 +979,211 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
||||
* asserting against a project the admin can already see would pass with the old
|
||||
* per-project endpoint behind it.
|
||||
*/
|
||||
/**
|
||||
* Kalender: Google's events and verdande's own tasks in one grid, and the rule
|
||||
* that tells them apart.
|
||||
*
|
||||
* The Google half is stubbed at the network layer rather than connected. There are
|
||||
* no credentials in a test run and there never will be — an OAuth consent screen is
|
||||
* a human pressing a button on somebody else's website — so what can be proved here
|
||||
* is what the grid does with events once it has them, which is the part that has a
|
||||
* rule in it: a task is draggable and reschedules, an event is not and cannot.
|
||||
*
|
||||
* The date is read out of the *page*, not out of this process. Playwright pins the
|
||||
* browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of
|
||||
* every day the two disagree about what today is — which is how a green suite
|
||||
* starts failing at half past midnight. Today is also the one day guaranteed to be
|
||||
* in the month grid whatever the weekday, so nothing here has to page.
|
||||
*/
|
||||
test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// A day at least a week out, whose next day is in the same Monday-to-Sunday
|
||||
// week — so the drag below crosses one cell and not one grid.
|
||||
//
|
||||
// Worked out in the browser, not here. Playwright pins the page to
|
||||
// Europe/Copenhagen and this process is pinned to nothing, so for two hours of
|
||||
// every day the two disagree about what today is. A week out also keeps this
|
||||
// test's day clear of the ones the rest of the suite dates things on.
|
||||
const { day, next } = await page.evaluate(() => {
|
||||
const iso = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
const monday = (d) => {
|
||||
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
||||
return iso(m);
|
||||
};
|
||||
const cursor = new Date();
|
||||
cursor.setDate(cursor.getDate() + 7);
|
||||
for (let i = 0; i < 14; i++) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
|
||||
if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) };
|
||||
}
|
||||
throw new Error('no two consecutive days inside one week in three weeks');
|
||||
});
|
||||
|
||||
// A calendar that is connected and holds two events that day: one with a clock
|
||||
// and one that lasts the whole day. The window is deliberately wide, so the
|
||||
// notice about having paged past it stays out of the way.
|
||||
await page.route('**/api/v1/calendar/events*', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
from: '2000-01-01',
|
||||
to: '2099-12-31',
|
||||
events: [
|
||||
{
|
||||
id: 'ev-1',
|
||||
calendar_id: 'c1',
|
||||
summary: 'Bestyrelsesmøde',
|
||||
starts_at: `${day}T14:00:00+02:00`,
|
||||
ends_at: `${day}T15:30:00+02:00`,
|
||||
start_day: day,
|
||||
end_day: day,
|
||||
all_day: false,
|
||||
url: 'https://calendar.google.com/event?eid=abc',
|
||||
calendar_name: 'Arbejde',
|
||||
colour: '#0b8043'
|
||||
},
|
||||
{
|
||||
id: 'ev-2',
|
||||
calendar_id: 'c1',
|
||||
summary: 'Feriedag',
|
||||
start_day: day,
|
||||
end_day: day,
|
||||
all_day: true,
|
||||
calendar_name: 'Familien',
|
||||
colour: '#3f51b5'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/calendar', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
connected: true,
|
||||
account: 'kw@nolimit.dk',
|
||||
read_only: true,
|
||||
has_client: true,
|
||||
redirect_uri: 'http://localhost/oauth/calendar/callback',
|
||||
calendars: [
|
||||
{ id: 'c1', remote_id: 'fam', name: 'Arbejde', colour: '#0b8043', shown: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// verdande's own half of the grid: a task on that day, made the way anybody
|
||||
// would make it. The date is written out rather than typed as "i morgen", so
|
||||
// the assertion does not depend on the parser as well.
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(`vande blomster ${day}`);
|
||||
await box.press('Enter');
|
||||
// Not asserted here: this is the Today view and the task is dated a week out,
|
||||
// so it is correctly absent. The grid below is where it has to turn up.
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
// The menu item, which is the thing the sidebar gained.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.locator('a[href="/kalender"]').click();
|
||||
await page.waitForURL('**/kalender');
|
||||
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
|
||||
|
||||
// The week, not the month, and for a reason worth writing down: a month cell
|
||||
// truncates at three chips and this suite has other tests putting tasks on
|
||||
// other days. A week cell holds ten, so what is asserted below is what the view
|
||||
// does with events rather than how full somebody else left the month.
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Forward a week at a time until the row is the one holding that day. Bounded,
|
||||
// because a loop that pages for ever on a broken button is a timeout with no
|
||||
// explanation in it.
|
||||
const cell = page.locator(`[data-date="${day}"]`);
|
||||
const forward = page.getByRole('button', { name: 'Næste uge' });
|
||||
for (let i = 0; i < 6 && (await cell.count()) === 0; i++) {
|
||||
await forward.click();
|
||||
}
|
||||
await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible();
|
||||
|
||||
// Connected, with a calendar ticked — so neither of the two "there is nothing
|
||||
// here" notices belongs on the screen. They exist so an empty grid is never
|
||||
// unexplained, and an explanation for a state you are not in is noise.
|
||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
||||
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
||||
|
||||
// Both kinds are in the same cell, which is the whole point of the view.
|
||||
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
|
||||
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
|
||||
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
|
||||
await expect(event).toBeVisible();
|
||||
await expect(allDay).toBeVisible();
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// A timed event carries its clock; an all-day one has none to carry.
|
||||
await expect(event).toContainText('14:00');
|
||||
await expect(allDay).not.toContainText(':');
|
||||
|
||||
// The rule. An event is read-only, so it must not offer to be moved — the drop
|
||||
// targets would refuse it anyway, but a chip that lifts off the page and then
|
||||
// will not land reads as a bug rather than as a rule.
|
||||
await expect(event).toHaveAttribute('draggable', 'false');
|
||||
await expect(allDay).toHaveAttribute('draggable', 'false');
|
||||
await expect(task).toHaveAttribute('draggable', 'true');
|
||||
|
||||
// And it cannot be ticked off either: there is nothing to complete about
|
||||
// somebody else's meeting.
|
||||
expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON');
|
||||
|
||||
// It does link back to Google, which is what makes it a pointer to the event
|
||||
// rather than a second copy of it.
|
||||
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
||||
|
||||
// The task still reschedules by drag, in a grid that now has events in it.
|
||||
await task.dragTo(page.locator(`[data-date="${next}"]`));
|
||||
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(cell.getByText('vande blomster')).toBeHidden();
|
||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||
await expect(cell.locator('.event')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The view without a calendar connected, which is what every fresh instance is.
|
||||
*
|
||||
* It has to draw anyway. The grid is verdande's own tasks with due dates — the
|
||||
* thing this view was before anything was laid over it — and a Google account that
|
||||
* is not there must not take it down with it.
|
||||
*/
|
||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/kalender');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
|
||||
// Seven weekday columns and a grid under them, whatever day of the week it is.
|
||||
await expect(page.locator('.calendar .grid .day').first()).toBeVisible();
|
||||
|
||||
// It says so rather than looking like a calendar with nothing in it, and it
|
||||
// says where to go about it.
|
||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/indstillinger/integrationer"]').first()
|
||||
).toBeVisible();
|
||||
|
||||
// The week is the same grid with a different span, and switching to it must not
|
||||
// lose the events prop on the way.
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await expect(page.locator('.calendar.week')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
||||
browser,
|
||||
page
|
||||
@@ -2089,3 +2302,405 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
// Straight in, without choosing a style. The first line is the title because
|
||||
// that is what the list calls the note by.
|
||||
await page.keyboard.type('Titelprøven');
|
||||
await expect(ed.locator('h1')).toHaveText('Titelprøven');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
|
||||
|
||||
// And the line after is body, not a second title. A note where every line is a
|
||||
// heading is a note with no heading at all.
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
|
||||
|
||||
// A third line stays body too.
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Og han ringer selv.');
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
|
||||
// It survives the trip through Markdown: the title is a heading again on the
|
||||
// way back, and the body is not.
|
||||
await ed.blur();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Titelprøven/ }).click();
|
||||
|
||||
const back = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(back.locator('h1')).toHaveText('Titelprøven');
|
||||
await expect(back.locator('h1')).toHaveCount(1);
|
||||
await expect(back).toContainText('ringer selv');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
await page.keyboard.type('Første udkast');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Half a tag is enough. The name is long and nobody should have to spell it.
|
||||
await page.keyboard.type('Handler om #Forslags');
|
||||
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
|
||||
await expect(option).toBeVisible();
|
||||
|
||||
// Return takes the highlighted one — the list owns the key while it is open.
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(ed).toContainText('#Forslagsprojektet');
|
||||
await expect(option).toBeHidden();
|
||||
|
||||
// And the syntax is spelled out under the field, where it is being typed.
|
||||
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// The title is the first line. It used to be derived once and then left alone,
|
||||
// so rewriting the opening left the list calling the note by an old name.
|
||||
await page.keyboard.press('Enter');
|
||||
await ed.blur();
|
||||
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
|
||||
|
||||
// Rewrite the first line; the list has to follow.
|
||||
await ed.click();
|
||||
await page.keyboard.press('ControlOrMeta+Home');
|
||||
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.keyboard.type('Andet udkast');
|
||||
await ed.blur();
|
||||
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
// Written in lower case, the way somebody types when they are not thinking
|
||||
// about it. It used to mean the note simply never appeared on the project, with
|
||||
// nothing anywhere to say why.
|
||||
await page.keyboard.type('Aftale om levering');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Handler om #storebogstaver');
|
||||
await page.keyboard.press('Escape');
|
||||
await ed.blur();
|
||||
|
||||
// The panel shows the project's own spelling, not the folded key: a label
|
||||
// nobody wrote and that is nowhere in the note would be worse than none.
|
||||
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
|
||||
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave viser de noter, der nævner den, og linket åbner noten', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('ring til leverandøren i dag');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('ring til leverandøren', { exact: true })).toBeVisible();
|
||||
|
||||
// Asked for rather than read off the address bar: opening a task shows a drawer
|
||||
// without navigating, so the URL is still the list's.
|
||||
const taskId = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/tasks?limit=100', {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
const j = await r.json();
|
||||
return (j.tasks ?? []).find((t) => t.content === 'ring til leverandøren')?.id;
|
||||
});
|
||||
expect(taskId, 'opgaven blev ikke oprettet').toBeTruthy();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Referat fra tirsdag');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Aftalt at han ringer, se /opgave/' + taskId);
|
||||
await ed.blur();
|
||||
|
||||
// Back on the task: the other direction of the same link. Without it the
|
||||
// connection only works if you happen to be reading the note, which is the half
|
||||
// nobody needs help with.
|
||||
await page.goto('/opgave/' + taskId);
|
||||
const linked = page.locator('.linked-notes').getByRole('link', { name: /Referat fra tirsdag/ });
|
||||
await expect(linked).toBeVisible();
|
||||
|
||||
// And it opens the note rather than dropping you on a list to find it again.
|
||||
await linked.click();
|
||||
await expect(page.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Aftalt at han ringer'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const pre = ed.locator('pre');
|
||||
await expect(pre).toBeVisible();
|
||||
|
||||
// Mørk flade uanset tema: en terminal er sort, også midt på dagen.
|
||||
const look = await pre.evaluate((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return { bg: s.backgroundColor, font: s.fontFamily };
|
||||
});
|
||||
expect(look.bg).toBe('rgb(20, 24, 27)');
|
||||
expect(look.font.toLowerCase()).toMatch(/mono/);
|
||||
|
||||
// Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
|
||||
await expect(pre.locator('.tok-prompt')).toBeVisible();
|
||||
await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
|
||||
await expect(pre.locator('.tok-string')).toContainText('hej');
|
||||
|
||||
// Teksten omkring er ikke blevet til kode.
|
||||
await expect(ed.locator('h1')).toHaveText('Terminalprøven');
|
||||
await expect(ed).toContainText('og resten.');
|
||||
|
||||
// Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
|
||||
// hegnet blok med sit sprog, ikke som en klump linjer.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
expect(body).toContain('```bash');
|
||||
expect(body).toContain('kw@shell:~$ ls -la');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Formerne, der hver især gik tabt: en punktliste, en nummereret, en der
|
||||
// begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et
|
||||
// citat. Den sidste er den, en note fra Apple Noter er fuld af.
|
||||
const markdown = [
|
||||
'# Former',
|
||||
'',
|
||||
'- Punkt',
|
||||
'- Punkt 2',
|
||||
'',
|
||||
'1. Nummer',
|
||||
'2. Nummer 2',
|
||||
'',
|
||||
'10. Ti',
|
||||
'11. Elleve',
|
||||
'',
|
||||
'- Ydre',
|
||||
' - Indre',
|
||||
'',
|
||||
'> Et citat',
|
||||
'> - med en liste i'
|
||||
].join('\n');
|
||||
|
||||
const id = await page.evaluate(async (body) => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}, markdown);
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('ul > li').first()).toBeVisible();
|
||||
|
||||
// Vist som lister, ikke som linjer med bindestreger i.
|
||||
await expect(ed.locator('ol')).toHaveCount(2);
|
||||
await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i');
|
||||
// Den indlejrede ligger inde i sit punkt, ikke ved siden af.
|
||||
await expect(ed.locator('ul li ul li')).toHaveText('Indre');
|
||||
// Og den, der begyndte ved ti, gør det stadig.
|
||||
await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti');
|
||||
|
||||
// Rør noten, sådan som en tastning gør, og lad den gemme.
|
||||
await ed.click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' ');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Det, der står i filen bagefter, er det, der stod i den før.
|
||||
//
|
||||
// Det var her, det gik galt: alt fra det første punkt blev til én linje —
|
||||
// "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som
|
||||
// tekst. Ingen havde rørt de linjer.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
|
||||
expect(body).toContain('- Punkt\n- Punkt 2');
|
||||
expect(body).toContain('1. Nummer\n2. Nummer 2');
|
||||
expect(body).toContain('10. Ti\n11. Elleve');
|
||||
expect(body).toContain('- Ydre\n - Indre');
|
||||
expect(body).toContain('> Et citat\n> - med en liste i');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
|
||||
// og resten stod som markup: et img med en onerror på. En note deles gennem et
|
||||
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
body: '# Ondsindet\n\n\n'
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('img')).toHaveCount(1);
|
||||
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
|
||||
// den vej, en onerror ville blive kaldt ad.
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
|
||||
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
|
||||
const alt = await ed.locator('img').getAttribute('alt');
|
||||
expect(alt).toContain('onerror');
|
||||
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
|
||||
|
||||
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
|
||||
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
|
||||
// andet skal stadig være stille.
|
||||
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed).toBeVisible();
|
||||
|
||||
// Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil
|
||||
// på disken — og lagt i både en paste og et drop, som er de to veje ind.
|
||||
const png =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
for (const kind of ['paste', 'drop']) {
|
||||
await ed.click();
|
||||
await page.evaluate(
|
||||
async ({ kind, png }) => {
|
||||
const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0));
|
||||
const file = new File([bytes], `${kind}.png`, { type: 'image/png' });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
const ed = document.querySelector('.page[contenteditable]');
|
||||
ed.focus();
|
||||
ed.dispatchEvent(
|
||||
kind === 'paste'
|
||||
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
||||
: new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })
|
||||
);
|
||||
},
|
||||
{ kind, png }
|
||||
);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
// To billeder, begge hentet og tegnet — ikke to stykker tekst med ).toHaveCount(2);
|
||||
const loaded = await ed.locator('img').evaluateAll((els) =>
|
||||
els.filter((e) => e.complete && e.naturalWidth > 0).length
|
||||
);
|
||||
expect(loaded).toBe(2);
|
||||
|
||||
// Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en
|
||||
// data-URL, som ville lægge fotografiet i noteteksten.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2);
|
||||
expect(body).not.toContain('data:image');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -9,17 +9,22 @@
|
||||
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
|
||||
* running instance and a session cookie pasted in by hand — and which therefore
|
||||
* ran rarely enough that the images fell many rounds behind the app they show.
|
||||
* This one starts the real binary, creates its own account, fills it with the
|
||||
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
||||
* This one starts the real binary, creates its own two accounts, fills them with
|
||||
* the demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
||||
*
|
||||
* The binary, not the dev server: the screenshots should show what ships. That
|
||||
* means building the frontend and copying it into cmd/verdande/webbuild first,
|
||||
* exactly as the smoke tests do — a capture of a stale embedded build would be a
|
||||
* picture of an app nobody is running.
|
||||
*
|
||||
* English, because the people who read the landing page and the docs read English.
|
||||
* Not by translating anything here: the account is created with an English locale
|
||||
* and the app is then in English by itself, which also means a string nobody
|
||||
* translated shows up in a picture instead of hiding until a user finds it.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
|
||||
const PORT = 8098;
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
|
||||
// Somebody to share with. Half of what the app is for only exists once there are
|
||||
// two people in it: an assignee on a task, a name in the history, a face in the
|
||||
// members list, and "Waiting on others" as something other than an empty page.
|
||||
const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' };
|
||||
|
||||
// 1400×900 is what the landing page's <img> declares. A capture at another size
|
||||
// would be resampled by the browser and look soft next to the text beside it.
|
||||
const WIDTH = 1400;
|
||||
const HEIGHT = 900;
|
||||
|
||||
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
|
||||
// The docs and the README read the PNGs; the landing page reads the WebP, which is
|
||||
// a fifth of the weight over the wire. Both are written from the same capture, so
|
||||
// the two sets can never drift apart.
|
||||
const PNG_DIR = join(repo, 'docs', 'screenshots');
|
||||
const WEBP_DIR = join(repo, 'site', 'screenshots');
|
||||
|
||||
/**
|
||||
* The pictures, and why each one is here.
|
||||
@@ -54,31 +67,64 @@ const SHOTS = [
|
||||
// The same project as a list, which is where sections read as bands rather than
|
||||
// as columns — and the shape most people actually work in.
|
||||
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||
// By address rather than by clicking the first row: the drawer has to be over
|
||||
// the one task that was given a description, sub-tasks, a file, a reminder and a
|
||||
// conversation, or the picture shows an empty form and proves nothing.
|
||||
{ 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 },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||
// Scrolled, because the themes are the second panel on the page and a picture
|
||||
// captioned "five themes" that shows the profile form is a caption about
|
||||
// nothing.
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes },
|
||||
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
|
||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }
|
||||
];
|
||||
|
||||
const NOTE_TITLE = 'Launch meeting — agenda';
|
||||
|
||||
async function showList(page) {
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showMonth(page) {
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function showWeek(page) {
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Week', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function openFirstTask(page) {
|
||||
await page.locator('.row').first().click();
|
||||
async function openMeetingNote(page) {
|
||||
// By text inside the row rather than by its accessible name: a row's name is its
|
||||
// title, its date, the first line of its body and the project it is filed in,
|
||||
// all run together.
|
||||
await page.locator('button.row', { hasText: NOTE_TITLE }).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function showNoteSource(page) {
|
||||
await openMeetingNote(page);
|
||||
await page.getByRole('button', { name: 'Show as code' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showThemes(page) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||
// decides nothing is needed and the picture is of the profile form above it.
|
||||
//
|
||||
// `.first()`, because the looks below the themes carry the same heading — one
|
||||
// panel, two questions, and both of them called Appearance.
|
||||
await page
|
||||
.getByRole('heading', { name: 'Appearance' })
|
||||
.first()
|
||||
.evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' }));
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
@@ -114,29 +160,42 @@ let browser;
|
||||
try {
|
||||
await waitForHealth();
|
||||
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
// The same pinning the smoke tests use, and for the same reason: the sign-up
|
||||
// form sends navigator.language as the account's locale, and every due date
|
||||
// is resolved in the account's zone.
|
||||
locale: 'da-DK',
|
||||
timezoneId: 'Europe/Copenhagen',
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
// The browser's own language, which is not the same setting as the context's.
|
||||
// `<input type="date">` is drawn by Chromium, not by the app, and it takes its
|
||||
// order from this one — so without it the drawer shows an American date inside
|
||||
// a day-month app, which is the exact thing i18n.svelte.js warns about.
|
||||
browser = await chromium.launch({ args: ['--lang=en-GB'] });
|
||||
const context = await browser.newContext(viewing());
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(baseURL);
|
||||
// The sign-up form is the one screen that is always Danish: it is drawn before
|
||||
// there is an account to read a language off, and Danish is the fallback. So
|
||||
// these labels are Danish on purpose, and the app is English from the moment
|
||||
// the account exists — which is every screen this script photographs.
|
||||
await page.getByLabel(/Navn/).fill(USER.name);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
|
||||
console.log('seeding…');
|
||||
const ids = await page.evaluate(seed);
|
||||
const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
|
||||
|
||||
mkdirSync(OUT[0], { recursive: true });
|
||||
mkdirSync(OUT[1], { recursive: true });
|
||||
console.log('adding the second person…');
|
||||
const ids2 = await addColleague(browser, ids);
|
||||
Object.assign(ids, ids2);
|
||||
await page.reload();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
|
||||
mkdirSync(PNG_DIR, { recursive: true });
|
||||
mkdirSync(WEBP_DIR, { recursive: true });
|
||||
|
||||
// A page of its own for the WebP encoding, on about:blank: the app's pages send
|
||||
// a content security policy, and an encoder that borrows whichever page was last
|
||||
// photographed would be one policy change away from silently producing nothing.
|
||||
const encoder = await context.newPage();
|
||||
await encoder.goto('about:blank');
|
||||
|
||||
for (const shot of SHOTS) {
|
||||
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
|
||||
@@ -151,9 +210,9 @@ try {
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
const png = await page.screenshot();
|
||||
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
|
||||
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
|
||||
console.log(` ${shot.name}.png`);
|
||||
writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
|
||||
writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
|
||||
console.log(` ${shot.name}`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
@@ -166,6 +225,25 @@ try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The browser settings every context here shares. */
|
||||
function viewing() {
|
||||
return {
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
// The locale is not a formatting preference: the sign-up form sends
|
||||
// navigator.language as the account's language, so this is what makes the
|
||||
// interface, the Inbox's name and every date English. The zone is pinned for
|
||||
// the same reason the smoke tests pin it — every due date is resolved in it.
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'Europe/Copenhagen',
|
||||
// Dark, because that is what the shots are forced to below. app.html reads
|
||||
// prefers-color-scheme before first paint, and Playwright's default is light
|
||||
// — which left the settings page rendering dark with the *light* theme
|
||||
// ringed as the chosen one.
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 2
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth() {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
try {
|
||||
@@ -179,6 +257,92 @@ async function waitForHealth() {
|
||||
throw new Error('the server never came up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a PNG as WebP, in the browser that is already open.
|
||||
*
|
||||
* Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` —
|
||||
* would be something to install first, in a script whose whole point is that there
|
||||
* is nothing to install. Resized on the way through: the landing page shows these
|
||||
* 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail
|
||||
* no screen renders.
|
||||
*/
|
||||
async function toWebP(encoder, png) {
|
||||
const base64 = await encoder.evaluate(
|
||||
async ({ data, width, quality }) => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${data}`;
|
||||
await image.decode();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width);
|
||||
const ctx = canvas.getContext('2d');
|
||||
// The default resampler leaves the interface's hairlines ragged, which on a
|
||||
// screenshot of a screenshot is the first thing the eye finds.
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/webp', quality).split(',')[1];
|
||||
},
|
||||
// 0.75 is cwebp's default and where these stop getting smaller for anything
|
||||
// the eye finds: 0.85 is a fifth more bytes for a difference that survives
|
||||
// only under magnification, and the landing page's first image is the one
|
||||
// somebody waits for.
|
||||
{ data: png.toString('base64'), width: 1800, quality: 0.75 }
|
||||
);
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the second person up through the invite the seed left behind, has them say
|
||||
* something, and hands their work back to the first.
|
||||
*
|
||||
* A context of its own because a session is a cookie: signing up in the page the
|
||||
* screenshots are taken from would replace the account they are taken as.
|
||||
*/
|
||||
async function addColleague(browser, ids) {
|
||||
const context = await browser.newContext(viewing());
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(ids.invite);
|
||||
// Danish labels again, and for the same reason: nobody is signed in yet.
|
||||
await page.getByLabel(/Navn/).fill(COLLEAGUE.name);
|
||||
await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
return await page.evaluate(handOver, ids);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the second person does, from inside their own session: answer the task they
|
||||
* were opened on, and take the three they are being waited for.
|
||||
*
|
||||
* The assignments are made here rather than by the owner because an editor may
|
||||
* assign, and doing it from this side proves it. Written as one function because it
|
||||
* is passed to the browser as source.
|
||||
*/
|
||||
async function handOver(ids) {
|
||||
const api = async (path, body, method = 'POST') => {
|
||||
const r = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH');
|
||||
|
||||
await api(`/tasks/${ids.detail}/comments`, {
|
||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||
});
|
||||
return { colleague: me.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo data, created through the app's own API from inside the page.
|
||||
*
|
||||
@@ -186,11 +350,11 @@ async function waitForHealth() {
|
||||
* header the API insists on come along without being reconstructed here. Written
|
||||
* as one function because it is passed to the browser as source.
|
||||
*
|
||||
* The content is ordinary Danish household and work tasks. Screenshots of "Task
|
||||
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
|
||||
* thing it is for.
|
||||
* Ordinary English household and work tasks, the same shape the Danish set had.
|
||||
* Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
|
||||
* is any good at the thing it is for.
|
||||
*/
|
||||
async function seed() {
|
||||
async function seed({ colleague, noteTitle }) {
|
||||
const api = async (path, body, method = 'POST') => {
|
||||
const r = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
@@ -210,63 +374,199 @@ async function seed() {
|
||||
};
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const arbejde = await api('/project-groups', { name: 'Arbejde' });
|
||||
const privat = await api('/project-groups', { name: 'Privat' });
|
||||
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
|
||||
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
const home = await api('/project-groups', { name: 'Home' });
|
||||
await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
|
||||
await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
|
||||
|
||||
const work = await api('/projects', {
|
||||
name: 'Sæsonstart',
|
||||
const launch = await api('/projects', {
|
||||
name: 'Autumn launch',
|
||||
color: 'indigo',
|
||||
view_mode: 'board',
|
||||
group_id: arbejde.id
|
||||
group_id: work.id
|
||||
});
|
||||
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
|
||||
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
|
||||
const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
|
||||
const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id });
|
||||
|
||||
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
|
||||
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
|
||||
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
|
||||
const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
|
||||
const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
|
||||
const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
|
||||
|
||||
const tasks = [
|
||||
// One thing that slipped. Today with an overdue band on it is what a Thursday
|
||||
// actually looks like, and it is the only way to photograph the band at all.
|
||||
{
|
||||
content: 'Chase the printer about the labels',
|
||||
due_date: day(-2),
|
||||
priority: 2,
|
||||
project_id: launch.id,
|
||||
section_id: inFlight.id,
|
||||
labels: ['waiting']
|
||||
},
|
||||
|
||||
// Today, in the Inbox and across the projects: the Today view is the first
|
||||
// picture and it has to look like a real day.
|
||||
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
|
||||
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
|
||||
{ content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
|
||||
{
|
||||
content: 'Skriv oplæg til sæsonmødet',
|
||||
content: 'Write the opening notes for the launch meeting',
|
||||
due_date: day(0),
|
||||
priority: 2,
|
||||
project_id: work.id,
|
||||
project_id: launch.id,
|
||||
section_id: planning.id,
|
||||
duration_min: 90
|
||||
duration_min: 90,
|
||||
labels: ['meeting']
|
||||
},
|
||||
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
|
||||
{ content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
|
||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||
|
||||
// The next couple of weeks, so Kommende and the month grid have something in
|
||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
|
||||
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
|
||||
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
|
||||
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
|
||||
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
|
||||
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
|
||||
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
|
||||
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
|
||||
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
|
||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||
{ content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
|
||||
{ content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
|
||||
{ content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
|
||||
{ content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
|
||||
{ content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
|
||||
{ content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
|
||||
{ content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
|
||||
{ content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id },
|
||||
|
||||
// A few without dates, so the board has cards that are not all deadlines.
|
||||
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
|
||||
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
|
||||
{ content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
|
||||
{ content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] }
|
||||
];
|
||||
for (const task of tasks) await api('/tasks', task);
|
||||
const made = {};
|
||||
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
||||
|
||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||
// a feature that does not work.
|
||||
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
|
||||
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
||||
|
||||
return { work: work.id, house: house.id, garden: garden.id };
|
||||
// The one task that is photographed open. Everything the drawer can hold, so the
|
||||
// picture answers "what is in there" rather than showing an empty form.
|
||||
const detail = made['Write the opening notes for the launch meeting'];
|
||||
await api(
|
||||
`/tasks/${detail.id}`,
|
||||
{
|
||||
description:
|
||||
'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.'
|
||||
},
|
||||
'PATCH'
|
||||
);
|
||||
for (const content of [
|
||||
'Pull the numbers from last season',
|
||||
'Ask Mette for the packaging quotes',
|
||||
'Book the room'
|
||||
]) {
|
||||
await api('/tasks', { content, parent_id: detail.id, project_id: launch.id });
|
||||
}
|
||||
await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 });
|
||||
await api(`/tasks/${detail.id}/comments`, {
|
||||
body: 'Keeping it to three points. Anything else goes on the list for the week after.'
|
||||
});
|
||||
|
||||
// A file on the task, because "comments, attachments" is a claim and an empty
|
||||
// Files heading is the opposite of proof.
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf');
|
||||
await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form });
|
||||
|
||||
// The notes, newest last: the list opens on what was touched most recently, so
|
||||
// the one the screenshots open is the one written last.
|
||||
const notes = [
|
||||
{
|
||||
body: `# Reading list
|
||||
|
||||
- *Shape Up* — the six-week bit, not the pitch bit
|
||||
- The SQLite WAL paper, again
|
||||
- Anything at all about pricing`
|
||||
},
|
||||
{
|
||||
project_id: garden.id,
|
||||
body: `# The bulb order
|
||||
|
||||
Two hundred, split three ways, in before the first frost:
|
||||
|
||||
1. Narcissus along the south wall
|
||||
2. Alliums in the bed by the shed
|
||||
3. Crocus wherever there is room
|
||||
|
||||
The bed by the shed is heavy — grit in the hole, or they rot. #Garden`
|
||||
},
|
||||
{
|
||||
body: `# What the roofer said
|
||||
|
||||
Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave
|
||||
over a winter either.
|
||||
|
||||
- Quote by the end of the month
|
||||
- Two days' work, scaffold on the north side
|
||||
- Ask whether the gutters can be done at the same time
|
||||
|
||||
#House`
|
||||
},
|
||||
{
|
||||
project_id: launch.id,
|
||||
body: `# Price list — what changes
|
||||
|
||||
Nothing has been looked at since March.
|
||||
|
||||
- 250 g bag: **79 → 85**
|
||||
- 1 kg bag: **265 → 279**
|
||||
- Subscription: **199 → 209**
|
||||
|
||||
Subscribers keep the old price for three months. That is the whole of the goodwill
|
||||
budget and it is worth it.`
|
||||
},
|
||||
{
|
||||
pinned: true,
|
||||
project_id: launch.id,
|
||||
body: `# ${noteTitle}
|
||||
|
||||
Thursday, fifteen minutes, no slides.
|
||||
|
||||
## What shipped over the summer
|
||||
|
||||
- The new packaging, minus the labels
|
||||
- A CalDAV feed nobody asked for and everybody uses
|
||||
|
||||
## What is left
|
||||
|
||||
- Labels. Still with the printer, chased on Thursday
|
||||
- The price list: [[Price list — what changes]]
|
||||
|
||||
> If it is not on the list by Friday it is not in the launch.
|
||||
|
||||
The build we cut the release from:
|
||||
|
||||
\`\`\`bash
|
||||
cd web && npm run build && cd ..
|
||||
go build -tags embedweb -o verdande ./cmd/verdande
|
||||
\`\`\`
|
||||
|
||||
Room is booked. Coffee is **not** my job this week.
|
||||
|
||||
The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
||||
}
|
||||
];
|
||||
for (const note of notes) await api('/notes', note);
|
||||
|
||||
// Shared with somebody, and an invite left standing to the instance itself: two
|
||||
// of the three states the members page has, and the one it never showed before.
|
||||
const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' });
|
||||
await api('/users', { email: 'anders@example.dk' });
|
||||
|
||||
return {
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
delegate: [
|
||||
made['Send the minutes round'].id,
|
||||
made['Update the price list'].id,
|
||||
made['Find a new packaging supplier'].id
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,7 +363,10 @@ button {
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
/* Ingen radius her. En omrids følger elementets egen afrunding af sig selv, og
|
||||
at tvinge en anden på tegner en firkantet bue uden om en rund kant — to
|
||||
streger, hvor der skulle være én. Det ses tydeligst om et pilleformet
|
||||
søgefelt. */
|
||||
}
|
||||
|
||||
::selection {
|
||||
|
||||
@@ -61,6 +61,10 @@ const MESSAGES = {
|
||||
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||
// this when it did not.
|
||||
mailbox_failed: 'error.mailboxRefused',
|
||||
// And for a calendar read from Google. Its own code, because the sentence this
|
||||
// is wrapped in names where it came from — and "Gmail said no" about a calendar
|
||||
// sends somebody to the wrong panel.
|
||||
calendar_failed: 'error.calendarRefused',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
@@ -84,6 +88,9 @@ export function humanMessage(err) {
|
||||
if (err.code === 'gmail_failed' && err.message) {
|
||||
return t('error.gmailSaid', { what: err.message });
|
||||
}
|
||||
if (err.code === 'calendar_failed' && err.message) {
|
||||
return t('error.calendarSaid', { what: err.message });
|
||||
}
|
||||
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
@@ -227,6 +234,9 @@ export const api = {
|
||||
gmailClient: () => get('/gmail/client'),
|
||||
setGmailClient: (data) => put('/gmail/client', data),
|
||||
|
||||
beacon: () => get('/beacon/settings'),
|
||||
setBeacon: (body) => put('/beacon/settings', body),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
// A link rather than a fetch: the browser's own download handles a file of
|
||||
@@ -334,6 +344,7 @@ export const api = {
|
||||
deleteComment: (id) => del(`/comments/${id}`),
|
||||
|
||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||
|
||||
@@ -361,6 +372,16 @@ export const api = {
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
// The Google Calendar connection. Read-only: there is nothing here that writes
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||
disconnectCalendar: () => del('/calendar'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
calendarEvents: (from, to) =>
|
||||
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
|
||||
// Notes. Listing and searching are one endpoint because they answer the same
|
||||
// question with different words.
|
||||
notes: (params = {}) => {
|
||||
@@ -391,6 +412,10 @@ export const api = {
|
||||
importTodoist: (file) => upload('/import/todoist', file),
|
||||
importCSV: (data) => post('/import/csv', data),
|
||||
exportAccountURL: () => '/api/v1/export/account',
|
||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
||||
// Through `upload`, not `request`: a multipart body has to set its own boundary,
|
||||
// and the browser only does that when nothing has claimed the header.
|
||||
importNotes: (file) => upload('/notes/import', file),
|
||||
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
|
||||
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
// The unsectioned column comes first, as it does in the list view: it is where
|
||||
// quick add puts things, so it is where new work appears.
|
||||
let columns = $derived([
|
||||
{ id: '', name: 'Uden sektion' },
|
||||
{ id: '', name: t('project.noSection') },
|
||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
||||
]);
|
||||
|
||||
|
||||
@@ -49,7 +49,19 @@
|
||||
* flight swallows the click that would page. A week that straddles the
|
||||
* 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
span = 'month'
|
||||
span = 'month',
|
||||
/**
|
||||
* Events read from somewhere else, laid over the same grid.
|
||||
*
|
||||
* An event is not a task, and the cell has to say so. It has a span of time
|
||||
* rather than a day, it cannot be ticked off, and — while the connection is
|
||||
* read-only — it cannot be moved: verdande holds a copy of somebody else's
|
||||
* calendar, and a chip that lets itself be dragged is a promise the server
|
||||
* has no way to keep.
|
||||
*
|
||||
* Empty everywhere but Kalender, so no other view pays for it.
|
||||
*/
|
||||
events = []
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
@@ -137,6 +149,66 @@
|
||||
(!projectId || t.project_id === projectId)
|
||||
);
|
||||
|
||||
/**
|
||||
* Which cells an event covers, rather than where it starts. A trip from Friday
|
||||
* to Monday is in all four, and asking only about the start day would draw it
|
||||
* once and leave three days looking clear.
|
||||
*
|
||||
* Ordered here rather than trusted from the caller. The server does sort, but a
|
||||
* cell that shows 14:00 above 09:15 does not look like a list in the wrong
|
||||
* order — it looks like the clock on the chip is wrong, which is a far worse
|
||||
* thing to have to rule out. All-day first, because an all-day event is a band
|
||||
* over the whole day rather than something that happens at a point in it.
|
||||
*/
|
||||
const eventsOn = (date) => {
|
||||
const day = iso(date);
|
||||
return events
|
||||
.filter((e) => e.start_day <= day && e.end_day >= day)
|
||||
.toSorted((a, b) => {
|
||||
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
|
||||
return (a.starts_at ?? '').localeCompare(b.starts_at ?? '');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The time on the chip, and only where it means something.
|
||||
*
|
||||
* An all-day event has no time. A timed one shows its start, and only on the
|
||||
* day it starts: a meeting that runs past midnight would otherwise claim to
|
||||
* begin at 20:00 on both days.
|
||||
*
|
||||
* The clock is read out of the string rather than parsed into a Date. Google
|
||||
* writes the event's own offset into it, and handing that to the browser asks
|
||||
* *this device* what time it is — so a phone in another time zone would show a
|
||||
* Copenhagen meeting an hour out.
|
||||
*/
|
||||
function timeOn(event, date) {
|
||||
if (event.all_day || !event.starts_at) return '';
|
||||
if (event.start_day !== iso(date)) return '';
|
||||
return event.starts_at.slice(11, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar's own colour, or nothing.
|
||||
*
|
||||
* Checked rather than trusted, even though it comes from Google: this goes into
|
||||
* an inline `style`, and a value that is not a colour is a value that can close
|
||||
* the declaration and open another. A hex triple is the whole of what Google
|
||||
* sends, so anything else is not a colour that failed to render — it is a
|
||||
* string that has no business being here.
|
||||
*/
|
||||
const HEX = /^#[0-9a-f]{3,8}$/i;
|
||||
const swatch = (colour) => (HEX.test(colour ?? '') ? colour : '');
|
||||
|
||||
/**
|
||||
* Whether the event links back to Google.
|
||||
*
|
||||
* `https:` and nothing else. An href is where a `javascript:` URL becomes a
|
||||
* click that runs, and the address in this field was written by a service, not
|
||||
* by the person looking at it.
|
||||
*/
|
||||
const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : '');
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||
);
|
||||
@@ -232,7 +304,14 @@
|
||||
|
||||
<div class="grid">
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@const dayEvents = eventsOn(date)}
|
||||
<!-- Events first, then whatever room is left goes to tasks. An event is
|
||||
fixed to a time somebody else chose; a task is only dated, and if
|
||||
something in a full cell has to fall behind "+3 mere", it should be
|
||||
the one that can be moved. -->
|
||||
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
|
||||
{@const hidden =
|
||||
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
|
||||
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -254,7 +333,36 @@
|
||||
{/if}
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#each tasks.slice(0, chipLimit) as task (task.id)}
|
||||
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||
{@const link = linkOf(event)}
|
||||
{@const label = t('cal.eventCannotMove', {
|
||||
name: event.summary,
|
||||
calendar: event.calendar_name || t('cal.title')
|
||||
})}
|
||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
||||
browser dragging an anchor of its own accord, and the title says
|
||||
why to anybody who tries. The drop targets would refuse it anyway
|
||||
— they only accept the task MIME type — but a chip that lifts off
|
||||
the page and then will not land reads as a bug rather than as a
|
||||
rule. It is also not a button: there is nothing here to complete,
|
||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'span'}
|
||||
class="event"
|
||||
class:allday={event.all_day}
|
||||
draggable="false"
|
||||
href={link || undefined}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noreferrer noopener' : undefined}
|
||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
||||
>
|
||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
||||
{event.summary}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
@@ -271,8 +379,8 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{#if hidden > 0}
|
||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -451,6 +559,58 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
words are read: a solid bar of colour where a task is a card with a coloured
|
||||
edge. That is the whole distinction the eye needs — a task is something you
|
||||
can move and finish, an event is something that is simply happening.
|
||||
|
||||
The colour is Google's own for that calendar, so two calendars in one grid
|
||||
are told apart by the colour the person already knows them by. `--line-strong`
|
||||
is the fallback for a calendar Google gave no colour, which is rare and not
|
||||
worth a second decision. */
|
||||
.event {
|
||||
--event-colour: var(--line-strong);
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* A drag that cannot land is worse than one that cannot start. The grab
|
||||
cursor is the promise; this withholds it. */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.event[href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An all-day event has no clock to show, so the colour carries it instead:
|
||||
filled rather than merely edged, which is what an all-day band looks like in
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
.at {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
@@ -482,6 +642,29 @@
|
||||
background: var(--p3);
|
||||
}
|
||||
|
||||
/* Counted rather than read, like the task chips beside it — a 45px column
|
||||
fits no title. A bar rather than a dot, so an event is still tellable from
|
||||
a task at a glance: what the month grid says on a phone is "how much is
|
||||
on this day, and how much of it is somebody else's". */
|
||||
.event {
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
border-radius: 1px;
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
background: var(--event-colour);
|
||||
}
|
||||
|
||||
.event.allday {
|
||||
background: var(--event-colour);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.at {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -536,6 +719,27 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
full-width row has room for a title, and a week somebody opened on
|
||||
purpose is one they want to read rather than count. */
|
||||
.calendar.week .event {
|
||||
font-size: var(--text-xs);
|
||||
height: auto;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.calendar.week .more {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
let query = $state('');
|
||||
let tasks = $state([]);
|
||||
let projects = $state([]);
|
||||
let notes = $state([]);
|
||||
let selected = $state(0);
|
||||
let input;
|
||||
let controller = null;
|
||||
@@ -32,13 +33,21 @@
|
||||
if (!value) {
|
||||
tasks = [];
|
||||
projects = [];
|
||||
notes = [];
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.search(value);
|
||||
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
|
||||
// søger ⌘K kun i halvdelen af programmet, hvilket er svært at gætte
|
||||
// som bruger: feltet ser ud til at kunne finde alt.
|
||||
const [result, found] = await Promise.all([
|
||||
api.search(value),
|
||||
api.notes({ q: value }).catch(() => ({ notes: [] }))
|
||||
]);
|
||||
tasks = result.tasks ?? [];
|
||||
projects = result.projects ?? [];
|
||||
notes = found.notes ?? [];
|
||||
selected = 0;
|
||||
} catch {
|
||||
// A failed search shows nothing rather than an error inside a
|
||||
@@ -49,6 +58,7 @@
|
||||
|
||||
let results = $derived([
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
kind: 'task',
|
||||
id: t.id,
|
||||
@@ -60,6 +70,7 @@
|
||||
|
||||
function choose(item) {
|
||||
open = false;
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||
}
|
||||
|
||||
@@ -109,7 +120,7 @@
|
||||
onclick={() => choose(item)}
|
||||
onmouseenter={() => (selected = i)}
|
||||
>
|
||||
<span class="kind">{item.kind === 'project' ? 'Projekt' : 'Opgave'}</span>
|
||||
<span class="kind">{t('palette.' + item.kind)}</span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -14,11 +14,17 @@
|
||||
* is the file to change.
|
||||
*/
|
||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
let list;
|
||||
let stylesOpen = $state(false);
|
||||
// Which marks are on where the caret is, so the toolbar shows state rather than
|
||||
// only offering actions.
|
||||
@@ -29,11 +35,29 @@
|
||||
let loadedId = $state(null);
|
||||
$effect(() => {
|
||||
if (!editor || !note || note.id === loadedId) return;
|
||||
editor.innerHTML = markdownToHtml(note.body) || '<p><br></p>';
|
||||
// An empty note opens on its title. That is what Apple Notes does, and it is
|
||||
// also what the list needs: the first line becomes the name of the note, so a
|
||||
// note whose first line is body text is a note called by its first sentence.
|
||||
//
|
||||
// Tested on the text and not on the rendered result: an empty body renders as
|
||||
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||
loadedId = note.id;
|
||||
active = {};
|
||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||
// uden at nogen har bedt om det.
|
||||
asSource = false;
|
||||
colourCode();
|
||||
});
|
||||
|
||||
const SYNTAX = [
|
||||
{ mark: '#', what: 'notes.syntaxProject' },
|
||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||
{ mark: '⌘B', what: 'notes.bold' },
|
||||
{ mark: '⌘U', what: 'notes.underline' }
|
||||
];
|
||||
|
||||
const STYLES = [
|
||||
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
||||
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
||||
@@ -87,12 +111,279 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Farver kodeblokkene.
|
||||
*
|
||||
* Kun dem, markøren ikke står i. At farve blokken, mens nogen skriver i den,
|
||||
* betyder at bygge dens indhold om ved hvert tastetryk og sætte markøren
|
||||
* tilbage bagefter — det er den slags, der virker i ni tilfælde ud af ti og
|
||||
* flytter markøren midt i en sætning i det tiende. Blokken er mørk og
|
||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||
* man læser den.
|
||||
*/
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
const inside = currentBlock();
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre === inside || pre.contains(inside)) continue;
|
||||
const code = pre.textContent ?? '';
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
// Kun hvis der er noget at ændre: at skrive innerHTML uændret ville
|
||||
// stadig rive noden op og lægge den tilbage, og det ses som et blink.
|
||||
const target = pre.querySelector('code') ?? pre;
|
||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
if (!editor) return;
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
readSuggestions();
|
||||
}
|
||||
|
||||
// --- kilden ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Noten som den Markdown, den er.
|
||||
*
|
||||
* Fladen er rig tekst, fordi det er den, folk kan. Men filen under er Markdown,
|
||||
* og der er to ting, den rige flade ikke kan: tage imod Markdown, nogen har
|
||||
* skrevet et andet sted — indsat i den rige flade er `## Overskrift` fem tegn og
|
||||
* ikke en overskrift — og vise, hvad der faktisk bliver gemt. Begge dele er det
|
||||
* samme svar: lad folk se kilden.
|
||||
*
|
||||
* Ikke en tredje tilstand at holde styr på: teksten går gennem de samme to
|
||||
* funktioner som altid, kun den anden vej. Det, man ser i kildefeltet, er ord for
|
||||
* ord det, der ligger i databasen.
|
||||
*/
|
||||
let asSource = $state(false);
|
||||
let source = $state('');
|
||||
|
||||
function toggleSource() {
|
||||
if (!asSource) {
|
||||
source = htmlToMarkdown(editor);
|
||||
asSource = true;
|
||||
return;
|
||||
}
|
||||
asSource = false;
|
||||
onchange?.(source);
|
||||
// Fyldt fra kilden, ikke fra `note.body`.
|
||||
//
|
||||
// Noten i lageret er den sidst gemte, og gemningen venter på sin pause — så
|
||||
// den vej ville kaste det, der lige er skrevet i kildefeltet, væk. `loadedId`
|
||||
// røres ikke: $effect ser stadig en note, den har indlæst, og skriver ikke
|
||||
// hen over det her.
|
||||
queueMicrotask(() => {
|
||||
if (!editor) return;
|
||||
editor.innerHTML = source.trim() ? markdownToHtml(source) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
});
|
||||
}
|
||||
|
||||
/** Kilden skrives direkte igennem: det er filen, man redigerer. */
|
||||
function sourceChanged(event) {
|
||||
source = event.currentTarget.value;
|
||||
onchange?.(source);
|
||||
}
|
||||
|
||||
// --- suggesting a project ------------------------------------------------------
|
||||
|
||||
let suggestions = $state([]);
|
||||
let chosen = $state(0);
|
||||
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||
let menuAt = $state(null);
|
||||
let wrap;
|
||||
|
||||
/**
|
||||
* The word being typed after a #, if that is what is happening.
|
||||
*
|
||||
* Read from the text node the caret is in rather than from the whole document:
|
||||
* a note can mention twenty projects, and only the one under the cursor is being
|
||||
* written.
|
||||
*/
|
||||
function partialTag() {
|
||||
const selection = window.getSelection();
|
||||
const node = selection?.anchorNode;
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvor `#` står, målt i wrappens koordinater.
|
||||
*
|
||||
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||
*
|
||||
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||
*/
|
||||
function tagPosition(partial) {
|
||||
if (!wrap) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const at = range.getBoundingClientRect();
|
||||
const box = wrap.getBoundingClientRect();
|
||||
if (!at.width && !at.height) return null;
|
||||
|
||||
const MENU = { w: 210, h: 240 };
|
||||
const below = at.bottom - box.top + 6;
|
||||
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||
return {
|
||||
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||
};
|
||||
}
|
||||
|
||||
function readSuggestions() {
|
||||
const partial = partialTag();
|
||||
if (!partial) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
const term = partial.term.toLowerCase();
|
||||
|
||||
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
|
||||
/** The block the caret is standing in. */
|
||||
function currentBlock() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.anchorNode || !editor) return null;
|
||||
let node = selection.anchorNode;
|
||||
while (node && node.parentNode !== editor) node = node.parentNode;
|
||||
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title for the first line, body for everything after it.
|
||||
*
|
||||
* Run after the browser has split the block on Enter, because until then there
|
||||
* is no second block to change. Pressing return at the end of a heading leaves
|
||||
* the caret in another heading in most browsers, and a note where every line is
|
||||
* a title is a note with no title at all.
|
||||
*/
|
||||
function keepTitleFirst() {
|
||||
const block = currentBlock();
|
||||
if (!block) return;
|
||||
|
||||
// A heading that is not the first line becomes body.
|
||||
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
return;
|
||||
}
|
||||
|
||||
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||
// return rather than choosing Brødtekst from the menu.
|
||||
if (block.tagName === 'DIV') {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruller det valgte forslag frem, når listen er længere end sin egen kasse.
|
||||
*
|
||||
* Efter opdateringen, ikke før: `chosen` er netop sat, og rækken bærer klassen
|
||||
* først når Svelte har skrevet den ud. Uden ventetiden ruller den til den
|
||||
* forrige.
|
||||
*/
|
||||
function keepChosenInView() {
|
||||
requestAnimationFrame(() => {
|
||||
list?.querySelector('button.on')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
if (suggestions.length) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen + 1) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
accept(suggestions[chosen]);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// After the split, not before it.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
changed();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// The shortcuts people already have in their fingers. execCommand handles
|
||||
// bold and italic itself; underline and strikethrough are claimed here so
|
||||
// they behave the same way.
|
||||
@@ -109,15 +400,95 @@
|
||||
|
||||
// Pasted text arrives as whatever the source was — a web page brings its fonts,
|
||||
// its colours and its layout with it. Only the words are wanted.
|
||||
/**
|
||||
* Filer ind i noten — indsat eller trukket.
|
||||
*
|
||||
* Importen kunne tage billeder med, og intet andet kunne. At indsætte et
|
||||
* skærmbillede i en note er det mest almindelige, der findes i et noteprogram,
|
||||
* og det gjorde ingenting overhovedet: indsætningen tog kun `text/plain` og
|
||||
* smed resten væk, og der var slet ingen håndtering af et træk.
|
||||
*
|
||||
* Filen lægges op og skrives ind som ``. Ikke som en data-URL: en note
|
||||
* er Markdown i en database, og et fotografi fra en telefon som base64 midt i
|
||||
* teksten ville gøre noten til fire megabyte tekst, som skal læses hver gang
|
||||
* listen tegnes.
|
||||
*
|
||||
* En note, der ikke er gemt endnu, kan ikke bære en fil — den har intet id at
|
||||
* hænge den på. Derfor gemmes den først, og det siges ikke til nogen, fordi
|
||||
* det er en teknisk detalje og ikke noget, brugeren har bedt om at vide.
|
||||
*/
|
||||
async function insertFiles(files) {
|
||||
const list = [...files].filter((f) => f && f.size > 0);
|
||||
if (!list.length || !note?.id) return false;
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
for (const file of list) {
|
||||
const saved = await api.uploadNoteFile(note.id, file);
|
||||
// execCommand frem for at bygge noden selv: det er det, der lægger
|
||||
// handlingen i browserens egen fortryd-historik, så ⌘Z tager billedet
|
||||
// ud igen ligesom det tager et ord ud.
|
||||
editor?.focus();
|
||||
document.execCommand('insertHTML', false,
|
||||
`<img src="${saved.url}" alt="">`);
|
||||
}
|
||||
changed();
|
||||
onsave?.();
|
||||
return true;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
return false;
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let uploading = $state(false);
|
||||
|
||||
function onpaste(event) {
|
||||
// Filer først. Et skærmbillede i udklipsholderen kommer med som en fil og
|
||||
// som ingenting andet, så hvis der er en, er det den, der skal ind.
|
||||
const files = event.clipboardData?.files ?? [];
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
insertFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
document.execCommand('insertText', false, text);
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trukket ind udefra.
|
||||
*
|
||||
* `preventDefault` på begge: uden den på dragover kommer der ingen drop, og
|
||||
* uden den på drop åbner browseren filen i stedet — man mister det, man var i
|
||||
* gang med at skrive, til en fane med et billede i.
|
||||
*
|
||||
* Kun filer. Et træk inde fra appen selv — en opgave, en note — bærer vores
|
||||
* egen nyttelast og skal falde igennem til den, der lyttede efter den.
|
||||
*/
|
||||
function ondragover(event) {
|
||||
if (!event.dataTransfer?.types?.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
dropping = true;
|
||||
}
|
||||
|
||||
function ondrop(event) {
|
||||
const files = event.dataTransfer?.files ?? [];
|
||||
dropping = false;
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
insertFiles(files);
|
||||
}
|
||||
|
||||
let dropping = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="wrap" bind:this={wrap}>
|
||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||
collapses the selection first, so the format lands on nothing — the button
|
||||
appears to do nothing at all, which is how it read the first time. -->
|
||||
@@ -168,11 +539,37 @@
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</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å. -->
|
||||
<button
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
aria-label={t('notes.showSource')}
|
||||
title={t('notes.showSource')}></></button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if asSource}
|
||||
<!-- Et almindeligt tekstfelt, fordi det er almindelig tekst. Ingen
|
||||
contenteditable, ingen execCommand: her er der intet at formatere. -->
|
||||
<textarea
|
||||
class="page source"
|
||||
aria-label={t('notes.source')}
|
||||
spellcheck="false"
|
||||
value={source}
|
||||
oninput={sourceChanged}
|
||||
onblur={() => onsave?.()}
|
||||
></textarea>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="page"
|
||||
class:hidden={asSource}
|
||||
bind:this={editor}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
@@ -180,12 +577,62 @@
|
||||
aria-label={t('notes.body')}
|
||||
spellcheck="true"
|
||||
oninput={changed}
|
||||
onblur={onsave}
|
||||
onkeyup={readState}
|
||||
onblur={() => {
|
||||
colourCode();
|
||||
onsave?.();
|
||||
}}
|
||||
onkeyup={() => {
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
{ondragover}
|
||||
{ondrop}
|
||||
ondragleave={() => (dropping = false)}
|
||||
class:dropping
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||
står stille, mens listen snævres ind. -->
|
||||
<ul
|
||||
class="suggestions"
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
role="option"
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- What the text understands, said where it is being typed. The same idea as
|
||||
the line under the quick-add box: a note is the other place in this program
|
||||
where what you type is read for meaning. -->
|
||||
<p class="legend">
|
||||
{#each SYNTAX as item}
|
||||
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -295,13 +742,111 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s1);
|
||||
min-width: 200px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
/* Nu hvor alle der passer står på listen, skal den kunne rulle. Otte rækker
|
||||
er nok til at det ses, at der er flere, uden at menuen dækker noten. */
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1) var(--s3);
|
||||
padding-top: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.legend kbd {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page {
|
||||
/* Et token eller en URL er ét ord, der er bredere end skærmen. Uden det her
|
||||
får siden en vandret rullebjælke, og teksten forsvinder ud til højre. */
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
min-height: 50vh;
|
||||
padding: var(--s3) 0;
|
||||
outline: none;
|
||||
line-height: 1.55;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Linjelængden har en grænse, som skærmen ikke har.
|
||||
*
|
||||
* En note i fuld bredde på en stor skærm er linjer på hundrede og fyrre tegn,
|
||||
* og øjet mister hvilken linje det var på, når det springer tilbage. Alle,
|
||||
* der laver noget, man læser i, sætter det her — Apple Noter, Bear, Notion —
|
||||
* og de sætter det omkring det samme sted.
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mens en fil holdes over noten. En ramme frem for en farvet flade: teksten
|
||||
under skal stadig kunne læses, så man kan se hvor billedet lander. */
|
||||
.page.dropping {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
|
||||
liste er indhold her og ikke pynt: den skal kunne tælles. */
|
||||
.source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
@@ -330,16 +875,39 @@
|
||||
margin: 0 0 var(--s2);
|
||||
}
|
||||
|
||||
/* Renden skal kunne rumme det bredeste mærke.
|
||||
*
|
||||
* Mærket sættes uden for tekstkanten, inde i listens egen indrykning, og en fast
|
||||
* indrykning på 16px rakte til et punkttegn og ikke til "10." — resten hang ud
|
||||
* over kanten, hvor `.page`, som ruller lodret og derfor beskærer vandret,
|
||||
* klippede det af. Man så et punktum uden sit tal.
|
||||
*
|
||||
* Samme rende til begge slags, frem for en bredere til den nummererede: mærket
|
||||
* lægger sig op ad teksten uanset hvor bred renden er, så deler de den, flugter
|
||||
* punkter og tal — og gør de ikke, står to lister under hinanden med hver sin
|
||||
* venstrekant.
|
||||
*
|
||||
* Målt i em, så den følger skriftstørrelsen, og bred nok til tre cifre. */
|
||||
.page :global(ul),
|
||||
.page :global(ol) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s4);
|
||||
padding-left: 2.2em;
|
||||
}
|
||||
|
||||
.page :global(li) {
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
/* Et billede fylder sin egen bredde og ikke mere. Et foto fra en telefon er
|
||||
fire tusind pixels bredt, og uden det her stikker det ud af arket. */
|
||||
.page :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
display: block;
|
||||
margin: var(--s2) 0;
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
@@ -347,10 +915,84 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
||||
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
|
||||
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||
uden at man skal læse et ord. Farverne er de samme i lys og mørk tilstand:
|
||||
en terminal er sort, også midt på dagen. */
|
||||
.page :global(pre) {
|
||||
margin: 0 0 var(--s2);
|
||||
margin: 0 0 var(--s3);
|
||||
padding: var(--s3);
|
||||
border-radius: var(--radius);
|
||||
background: #14181b;
|
||||
color: #d7dde3;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sproget i hjørnet, når blokken siger hvad den er. */
|
||||
.page :global(pre[data-lang]:not([data-lang=''])::after) {
|
||||
content: attr(data-lang);
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #5a656e;
|
||||
}
|
||||
|
||||
.page :global(pre code) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page :global(.tok-comment) {
|
||||
color: #6b7a85;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.page :global(.tok-string) {
|
||||
color: #9ecb8a;
|
||||
}
|
||||
|
||||
.page :global(.tok-number) {
|
||||
color: #d9a25f;
|
||||
}
|
||||
|
||||
.page :global(.tok-keyword) {
|
||||
color: #7fb3e8;
|
||||
}
|
||||
|
||||
.page :global(.tok-key) {
|
||||
color: #7fb3e8;
|
||||
}
|
||||
|
||||
.page :global(.tok-bool) {
|
||||
color: #c48fd6;
|
||||
}
|
||||
|
||||
.page :global(.tok-variable) {
|
||||
color: #d9a25f;
|
||||
}
|
||||
|
||||
.page :global(.tok-flag) {
|
||||
color: #c48fd6;
|
||||
}
|
||||
|
||||
.page :global(.tok-punct) {
|
||||
color: #8a949c;
|
||||
}
|
||||
|
||||
/* Prompten i en terminaludskrift: det første øjet finder, når man leder efter
|
||||
hvor en kommando begynder. */
|
||||
.page :global(.tok-prompt) {
|
||||
color: #6fbf8f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page :global(code) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -131,7 +131,30 @@
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
{ key: 'inbox', label: 'nav.inbox' },
|
||||
// Noter hører til her, og bærer et ark frem for en prik.
|
||||
//
|
||||
// Den stod før for sig selv over en streg, hvilket sagde det rigtige og
|
||||
// kostede halvfjerds pixels: en streg, luft over og luft under, og
|
||||
// projekterne skubbet så langt ned, at de to første var alt, man så uden
|
||||
// at rulle. Grunden til at skille den ud var, at den ellers læste som
|
||||
// endnu et filter — men det, der gør den til et filter for øjet, er
|
||||
// prikken, ikke pladsen. Et andet mærke siger "en anden slags ting" og
|
||||
// koster ingenting.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' },
|
||||
// Kalenderen bærer et gitter, af samme grund som Noter bærer et ark: den
|
||||
// er ikke endnu et filter over de samme opgaver. Den er de samme opgaver
|
||||
// med noget andet lagt over — Googles begivenheder — og en prik ville
|
||||
// sige, at den hørte til blandt I dag og Kommende.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -335,16 +358,27 @@
|
||||
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
|
||||
|
||||
function onTaskDragOver(event, project) {
|
||||
if (!carries(event, TASK) || !canReceive(project)) return;
|
||||
if (!(carries(event, TASK) || carries(event, NOTE)) || !canReceive(project)) return;
|
||||
accept(event);
|
||||
overProject = project.id;
|
||||
}
|
||||
|
||||
async function onTaskDrop(event, project) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
overProject = null;
|
||||
if (!id || !canReceive(project)) return;
|
||||
if (!canReceive(project)) return;
|
||||
|
||||
// The same target takes both. A note dropped on a project is filed there,
|
||||
// which is also how it gets shared — one gesture, one meaning, and the
|
||||
// sidebar already reads as "put this here".
|
||||
const noteID = dragged(event, NOTE);
|
||||
if (noteID) {
|
||||
await app.moveNoteToProject(noteID, project.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = dragged(event, TASK);
|
||||
if (!id) return;
|
||||
await app.moveToProject(id, project.id);
|
||||
}
|
||||
|
||||
@@ -452,7 +486,40 @@
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{#if item.key === 'notes'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M4 1.5h5.2L12.5 5v9.5H4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else if item.key === 'calendar'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
x="2.2"
|
||||
y="3.2"
|
||||
width="11.6"
|
||||
height="10.6"
|
||||
rx="1.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<path
|
||||
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
@@ -460,17 +527,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
They are not a fourth way of looking at the same tasks, which is what
|
||||
everything above is; they are the other half of the program. Sitting in that
|
||||
run made them read as one more filter. -->
|
||||
<div class="apart">
|
||||
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('notes.title')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
shared ones — because all three are somewhere a task can be dropped, and
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
@@ -481,14 +537,22 @@
|
||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||
stopped landing, because the row had claimed the gesture. A grip claims
|
||||
nothing until it is grabbed. -->
|
||||
<!-- Håndtaget har ingen prik i sig længere.
|
||||
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
|
||||
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
|
||||
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
|
||||
draggable, ville alt indeni også blive det — og en opgave trukket hen på
|
||||
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
|
||||
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
|
||||
er hele forklaringen. -->
|
||||
{#snippet grip(key)}
|
||||
<span
|
||||
class="grip"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onNavDragStart(e, key)}
|
||||
ondragend={clearNavDrag}
|
||||
aria-hidden="true">⠿</span
|
||||
>
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectRow(project, sortable)}
|
||||
@@ -977,19 +1041,15 @@
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
@@ -1014,15 +1074,20 @@
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: var(--s3) 0;
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
|
||||
— mærket skifter, kolonnen gør ikke. */
|
||||
.mark {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
|
||||
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
|
||||
margin: 0 -3.5px;
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -29,20 +30,10 @@
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
let members = $state([]);
|
||||
|
||||
// The rules worth a menu entry. Anything more specific is typed into the
|
||||
// title, where the parser understands far more than a list ever could.
|
||||
const REPEATS = [
|
||||
{ rule: '', label: 'Aldrig' },
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
|
||||
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
let attachments = $state([]);
|
||||
let notes = $state([]);
|
||||
let reminders = $state([]);
|
||||
|
||||
let newSubtask = $state('');
|
||||
@@ -80,6 +71,7 @@
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
attachments = [];
|
||||
notes = [];
|
||||
reminders = [];
|
||||
|
||||
const id = task.id;
|
||||
@@ -96,6 +88,11 @@
|
||||
api.listReminders(id)
|
||||
.then((r) => id === seededId && (reminders = r.reminders))
|
||||
.catch(() => {});
|
||||
// Quietly, because a task with no notes is the ordinary case and a failure
|
||||
// here must not put an error in front of somebody reading a task.
|
||||
api.notesLinking('task', id)
|
||||
.then((r) => id === seededId && (notes = r.notes ?? []))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -445,13 +442,13 @@
|
||||
<label for="repeat">{t('detail.repeats')}</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
<option value={option.rule}>{t(option.label)}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
@@ -540,6 +537,22 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- What has been written about this task.
|
||||
The other direction of the same link: a note that mentions a task shows
|
||||
it, and this is the task showing the note. Without it the connection only
|
||||
works if you happen to be reading the note — which is the half nobody
|
||||
needs help with. -->
|
||||
{#if notes.length}
|
||||
<section class="linked-notes">
|
||||
<h3>{t('notes.title')}</h3>
|
||||
{#each notes as note (note.id)}
|
||||
<a class="linked-note" href="/noter?note={note.id}">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>{t('detail.comments')}</h3>
|
||||
|
||||
@@ -582,6 +595,24 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.linked-note {
|
||||
display: block;
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linked-note:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.linked-note strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { repeatLabel } from '$lib/when.js';
|
||||
|
||||
let { task, onedit } = $props();
|
||||
|
||||
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
|
||||
|
||||
let leaving = $state(false);
|
||||
|
||||
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
||||
@@ -84,7 +87,7 @@
|
||||
<span class="description">{task.description}</span>
|
||||
{/if}
|
||||
|
||||
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
|
||||
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||
<span class="meta">
|
||||
{#if assignee}
|
||||
<!-- First in the row, because "who" changes what the rest of the line
|
||||
@@ -102,15 +105,15 @@
|
||||
{due.text}{#if time} {time}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrence_text}
|
||||
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
|
||||
{#if repeats}
|
||||
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
||||
</svg>
|
||||
{task.recurrence_text}
|
||||
{repeats}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export const TASK = 'application/x-verdande-task';
|
||||
export const PROJECT = 'application/x-verdande-project';
|
||||
export const GROUP = 'application/x-verdande-group';
|
||||
export const NOTE = 'application/x-verdande-note';
|
||||
|
||||
/** Starts a drag carrying one id of the given kind. */
|
||||
export function startDrag(event, type, id) {
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
/**
|
||||
* What each recorded activity event is called.
|
||||
*
|
||||
* The keys are the server's and they are stable; this is the only place they
|
||||
* become Danish. It lives in its own module because two surfaces read the same log
|
||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
||||
* The keys are the server's and they are stable; this is the only place they get a
|
||||
* name. It lives in its own module because two surfaces read the same log now — a
|
||||
* project's own history panel, and the administrator's instance-wide audit page —
|
||||
* and two copies of twenty strings is two places for them to drift apart.
|
||||
*
|
||||
* The names themselves are in the dictionaries rather than here. They were written
|
||||
* out in Danish, which made the whole of both history pages Danish for somebody
|
||||
* reading the rest of the app in English.
|
||||
*
|
||||
* An unknown key falls through to the key itself rather than being hidden: a build
|
||||
* that records something this table has not learned yet should still show that it
|
||||
* happened.
|
||||
*/
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
export const EVENTS = {
|
||||
'project.created': 'oprettede projektet',
|
||||
'project.updated': 'ændrede projektet',
|
||||
'project.deleted': 'slettede projektet',
|
||||
'project.imported': 'importerede projektet',
|
||||
'section.created': 'oprettede en sektion',
|
||||
'section.updated': 'ændrede en sektion',
|
||||
'section.deleted': 'slettede en sektion',
|
||||
'member.invited': 'inviterede',
|
||||
'member.added': 'tilføjede',
|
||||
'member.removed': 'fjernede',
|
||||
'member.role_changed': 'ændrede rollen for',
|
||||
'task.created': 'oprettede en opgave',
|
||||
'task.updated': 'ændrede en opgave',
|
||||
'task.completed': 'lukkede en opgave',
|
||||
'task.reopened': 'genåbnede en opgave',
|
||||
'task.moved': 'flyttede en opgave',
|
||||
'task.deleted': 'slettede en opgave',
|
||||
'task.split': 'delte en opgave op',
|
||||
'comment.created': 'skrev en kommentar'
|
||||
'project.created': 'event.projectCreated',
|
||||
'project.updated': 'event.projectUpdated',
|
||||
'project.deleted': 'event.projectDeleted',
|
||||
'project.imported': 'event.projectImported',
|
||||
'section.created': 'event.sectionCreated',
|
||||
'section.updated': 'event.sectionUpdated',
|
||||
'section.deleted': 'event.sectionDeleted',
|
||||
'member.invited': 'event.memberInvited',
|
||||
'member.added': 'event.memberAdded',
|
||||
'member.removed': 'event.memberRemoved',
|
||||
'member.role_changed': 'event.memberRoleChanged',
|
||||
'task.created': 'event.taskCreated',
|
||||
'task.updated': 'event.taskUpdated',
|
||||
'task.completed': 'event.taskCompleted',
|
||||
'task.reopened': 'event.taskReopened',
|
||||
'task.moved': 'event.taskMoved',
|
||||
'task.deleted': 'event.taskDeleted',
|
||||
'task.split': 'event.taskSplit',
|
||||
'comment.created': 'event.commentCreated',
|
||||
'attachment.added': 'event.attachmentAdded'
|
||||
};
|
||||
|
||||
/** The name of an event, or the raw key when it is one this build does not know. */
|
||||
export function eventName(event) {
|
||||
return EVENTS[event] ?? event;
|
||||
const key = EVENTS[event];
|
||||
return key ? t(key) : event;
|
||||
}
|
||||
|
||||
/** The bit of context an entry carries, when it has one worth reading. */
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Farver til en kodeblok.
|
||||
*
|
||||
* Ikke et bibliotek: et fuldt syntaksbibliotek er hundredvis af kilobytes for at
|
||||
* gøre nogle ord blå i en note. Det her er et lille sæt regler pr. sprog, der
|
||||
* rammer det, øjet leder efter — strenge, tal, kommentarer, nøgleord — og som er
|
||||
* ligeglad med resten. En farve for lidt er ingenting; en forkert er heller ikke
|
||||
* meget, når teksten stadig står der, som den blev skrevet.
|
||||
*
|
||||
* Reglerne prøves i rækkefølge, og den første, der rammer, vinder. Strenge og
|
||||
* kommentarer står derfor først: et nøgleord inde i en streng er ikke et nøgleord.
|
||||
*/
|
||||
|
||||
const COMMON = {
|
||||
string: /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/,
|
||||
number: /^\b\d+(\.\d+)?\b/
|
||||
};
|
||||
|
||||
const LANGUAGES = {
|
||||
yaml: [
|
||||
['comment', /^#[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
// Nøglen, som er det, man scanner efter i en YAML-fil.
|
||||
['key', /^[ \t]*[-\w.]+(?=\s*:)/],
|
||||
['punct', /^[:\-|>]/],
|
||||
['number', COMMON.number],
|
||||
['bool', /^\b(true|false|null|yes|no|on|off)\b/i]
|
||||
],
|
||||
json: [
|
||||
['string', COMMON.string],
|
||||
['punct', /^[{}[\],:]/],
|
||||
['number', COMMON.number],
|
||||
['bool', /^\b(true|false|null)\b/]
|
||||
],
|
||||
bash: [
|
||||
['comment', /^#[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
// Prompten er ikke kode, men den er det første øjet finder i en udskrift.
|
||||
['prompt', /^\S+@\S+:[^$#]*[$#]/],
|
||||
['variable', /^\$\{?\w+\}?/],
|
||||
['keyword', /^\b(if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|sudo)\b/],
|
||||
['flag', /^\s-{1,2}[\w-]+/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
go: [
|
||||
['comment', /^\/\/[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(func|package|import|var|const|type|struct|interface|if|else|for|range|return|defer|go|chan|select|switch|case|default|map|nil|true|false)\b/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
js: [
|
||||
['comment', /^\/\/[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(const|let|var|function|return|if|else|for|while|import|export|from|class|new|await|async|try|catch|throw|typeof|null|undefined|true|false)\b/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
sql: [
|
||||
['comment', /^--[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(select|from|where|insert|into|values|update|set|delete|create|table|index|join|left|inner|on|and|or|not|null|primary|key|references|order|by|group|limit)\b/i],
|
||||
['number', COMMON.number]
|
||||
]
|
||||
};
|
||||
|
||||
// Det, folk skriver, mod det, filen hedder.
|
||||
const ALIASES = {
|
||||
sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash', terminal: 'bash',
|
||||
yml: 'yaml', javascript: 'js', ts: 'js', typescript: 'js', golang: 'go'
|
||||
};
|
||||
|
||||
const escape = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* Gætter sproget, når blokken ikke siger det.
|
||||
*
|
||||
* Med vilje forsigtigt: at gætte forkert farver de forkerte ord, og en
|
||||
* terminaludskrift uden farver er stadig læselig. Kun mønstre, der er svære at
|
||||
* forveksle, tæller med.
|
||||
*/
|
||||
export function guessLanguage(code) {
|
||||
if (/^\s*[{[]/.test(code) && /["}\]]\s*$/.test(code.trim())) return 'json';
|
||||
if (/^\s*\S+@\S+:.*[$#]/m.test(code)) return 'bash';
|
||||
if (/^\s*(package|func)\s+\w/m.test(code)) return 'go';
|
||||
if (/^\s*[-\w.]+:\s*(\S|$)/m.test(code) && !/[;{}]/.test(code)) return 'yaml';
|
||||
if (/^\s*(SELECT|INSERT|UPDATE|CREATE TABLE)\b/im.test(code)) return 'sql';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Kode til HTML med spans. Ukendt sprog giver teksten uændret, blot undsluppet. */
|
||||
export function highlight(code, language) {
|
||||
const rules = LANGUAGES[ALIASES[language] ?? language];
|
||||
if (!rules) return escape(code);
|
||||
|
||||
let out = '';
|
||||
let rest = code;
|
||||
let guard = 0;
|
||||
|
||||
while (rest && guard++ < 20000) {
|
||||
let matched = false;
|
||||
for (const [kind, re] of rules) {
|
||||
const m = re.exec(rest);
|
||||
if (!m || !m[0]) continue;
|
||||
out += `<span class="tok-${kind}">${escape(m[0])}</span>`;
|
||||
rest = rest.slice(m[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (!matched) {
|
||||
// Ét tegn frem. Langsommere end at springe til næste interessante sted og
|
||||
// umuligt at få til at gå i stå, hvilket er den rigtige byttehandel i noget,
|
||||
// der kører på hvert tastetryk i en note, nogen sidder og skriver i.
|
||||
out += escape(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
}
|
||||
return out + escape(rest);
|
||||
}
|
||||
@@ -25,7 +25,28 @@ export const da = {
|
||||
'notes.bold': 'Fed',
|
||||
'notes.italic': 'Kursiv',
|
||||
'notes.underline': 'Understreget',
|
||||
'notes.sortBy': 'Rækkefølge',
|
||||
'notes.sortUpdated': 'Senest rørt',
|
||||
'notes.sortCreated': 'Oprettet',
|
||||
'notes.sortTitle': 'Navn',
|
||||
'beacon.title': 'Tælling af installationer',
|
||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||
'beacon.send': 'Send en anonym daglig melding',
|
||||
'beacon.exactly': 'Præcis det her — ikke mere:',
|
||||
'beacon.lastPing': 'Sidst sendt: {when}',
|
||||
'beacon.collector': 'Modtagerens adresse',
|
||||
'beacon.collectorHint': 'Hvor meldingen sendes hen. Lad den stå, medmindre du kører din egen tæller.',
|
||||
'beacon.beCollector': 'Denne instans er tælleren',
|
||||
'beacon.receiving': 'Modtager anonyme meldinger — id og version, aldrig en IP.',
|
||||
'beacon.total': 'I alt',
|
||||
'beacon.active7': 'Aktive 7 dage',
|
||||
'beacon.active30': 'Aktive 30 dage',
|
||||
'beacon.byVersion': 'Efter version (30 dage):',
|
||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
'notes.styleSubheading': 'Underrubrik',
|
||||
@@ -36,6 +57,9 @@ export const da = {
|
||||
'notes.styleNumbered': 'Nummereret liste',
|
||||
'notes.favourite': 'Gør til favorit',
|
||||
'notes.unfavourite': 'Fjern som favorit',
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'notes.yesterday': 'i går',
|
||||
'notes.title': 'Noter',
|
||||
'notes.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
@@ -49,10 +73,14 @@ export const da = {
|
||||
'notes.saved': 'Gemt',
|
||||
'notes.delete': 'Slet',
|
||||
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
|
||||
'palette.project': 'Projekt',
|
||||
'palette.task': 'Opgave',
|
||||
'palette.note': 'Note',
|
||||
'nav.today': 'I dag',
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
'nav.inbox': 'Indbakke',
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
@@ -291,6 +319,9 @@ export const da = {
|
||||
'users.taskOne': 'opgave',
|
||||
'users.taskMany': 'opgaver',
|
||||
'users.never': 'aldrig set',
|
||||
'users.toProject': 'til {name}',
|
||||
'users.toInstance': 'til instansen',
|
||||
'users.invitedBy': 'inviteret af {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'Historik',
|
||||
@@ -306,6 +337,30 @@ export const da = {
|
||||
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
||||
'history.nothing': 'Der er ikke registreret noget endnu.',
|
||||
'history.deletedAccount': 'En slettet konto',
|
||||
|
||||
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
|
||||
// events.js; her står de ord, de bliver til.
|
||||
'event.projectCreated': 'oprettede projektet',
|
||||
'event.projectUpdated': 'ændrede projektet',
|
||||
'event.projectDeleted': 'slettede projektet',
|
||||
'event.projectImported': 'importerede projektet',
|
||||
'event.sectionCreated': 'oprettede en sektion',
|
||||
'event.sectionUpdated': 'ændrede en sektion',
|
||||
'event.sectionDeleted': 'slettede en sektion',
|
||||
'event.memberInvited': 'inviterede',
|
||||
'event.memberAdded': 'tilføjede',
|
||||
'event.memberRemoved': 'fjernede',
|
||||
'event.memberRoleChanged': 'ændrede rollen for',
|
||||
'event.taskCreated': 'oprettede en opgave',
|
||||
'event.taskUpdated': 'ændrede en opgave',
|
||||
'event.taskCompleted': 'lukkede en opgave',
|
||||
'event.taskReopened': 'genåbnede en opgave',
|
||||
'event.taskMoved': 'flyttede en opgave',
|
||||
'event.taskDeleted': 'slettede en opgave',
|
||||
'event.taskSplit': 'delte en opgave op',
|
||||
'event.commentCreated': 'skrev en kommentar',
|
||||
'event.attachmentAdded': 'vedhæftede en fil',
|
||||
|
||||
'errors.title': 'Serverfejl',
|
||||
'errors.hint':
|
||||
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
|
||||
@@ -363,6 +418,10 @@ export const da = {
|
||||
'data.swept': 'ryddet væk',
|
||||
'data.empty': 'tom',
|
||||
'data.export': 'Eksport',
|
||||
'data.importNotes': 'Importér noter (zip med Markdown-filer)',
|
||||
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
|
||||
'data.notesImported': '{n} noter og {files} filer hentet ind.',
|
||||
'data.exportNotes': 'Alle noter som Markdown-filer',
|
||||
'data.exportAccount': 'Hele kontoen som JSON',
|
||||
'data.exportOne': 'Ét projekt',
|
||||
'data.pickProject': 'Vælg et projekt',
|
||||
@@ -467,6 +526,7 @@ export const da = {
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.notFound': 'Opgaven findes ikke, eller den er ikke din.',
|
||||
'detail.task': 'Opgave',
|
||||
'detail.close': 'Luk',
|
||||
'detail.text': 'Opgavens tekst',
|
||||
@@ -480,8 +540,13 @@ export const da = {
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
'detail.repeats': 'Gentages',
|
||||
'detail.never': 'Aldrig',
|
||||
'detail.daily': 'Hver dag',
|
||||
'detail.weekly': 'Hver uge',
|
||||
'detail.fortnightly': 'Hver anden uge',
|
||||
'detail.monthly': 'Hver måned',
|
||||
'detail.yearly': 'Hvert år',
|
||||
'detail.asWritten': 'Som skrevet',
|
||||
'detail.assignee': 'Ansvarlig',
|
||||
'detail.subtasks': 'Undertasks',
|
||||
'detail.addSubtask': 'Tilføj en undertask',
|
||||
@@ -529,7 +594,7 @@ export const da = {
|
||||
'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.',
|
||||
'int.oauthClient': 'OAuth-klient hos Google',
|
||||
'int.registerHint':
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, slå Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, og indsæt de to værdier her. Den samme klient bruges til både Gmail og Google Kalender: slå det API til, du skal bruge, og tilføj begge redirect-URI\'er — den nedenfor og kalenderens, som står i panelet Google Kalender. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'int.redirectURI': 'Godkendt redirect-URI',
|
||||
'int.clientID': 'Klient-id',
|
||||
'int.clientSecret': 'Klienthemmelighed',
|
||||
@@ -569,6 +634,32 @@ export const da = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.calendar': 'Google Kalender',
|
||||
'int.calendarHint':
|
||||
'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.',
|
||||
'int.connectCalendar': 'Forbind Google Kalender',
|
||||
'int.calendarConnectedAs': 'Forbundet som',
|
||||
'int.calendarSameClient':
|
||||
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
'int.calendarFetchedMany': '{n} begivenheder hentet.',
|
||||
'int.calendarNothingNew': 'Ingen begivenheder i vinduet.',
|
||||
'int.calendarConnected': 'Google Kalender er forbundet.',
|
||||
'int.calendarState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.',
|
||||
'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
|
||||
'int.calendarFailed': 'Google afviste ombytningen af koden.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
|
||||
'int.calendarDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.',
|
||||
'int.calendarOther': 'Google svarede: {what}',
|
||||
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||
@@ -583,6 +674,18 @@ export const da = {
|
||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.gmailOther': 'Gmail svarede: {what}',
|
||||
|
||||
// --- kalenderen, med Googles begivenheder lagt over ---------------------------------
|
||||
'cal.title': 'Kalender',
|
||||
'cal.notConnected':
|
||||
'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.',
|
||||
'cal.settings': 'Indstillinger',
|
||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||
'cal.allDay': 'Hele dagen',
|
||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||
'cal.beyondWindow':
|
||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||
'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'Projektets navn',
|
||||
'project.clickToRename': 'Klik for at omdøbe',
|
||||
@@ -671,6 +774,7 @@ export const da = {
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'view.overdue': 'Forsinket',
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
@@ -724,6 +828,10 @@ export const da = {
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||
|
||||
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||
'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.',
|
||||
'error.calendarSaid': 'Google svarede: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.',
|
||||
|
||||
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||
|
||||
@@ -21,7 +21,28 @@ export const en = {
|
||||
'notes.bold': 'Bold',
|
||||
'notes.italic': 'Italic',
|
||||
'notes.underline': 'Underline',
|
||||
'notes.sortBy': 'Order',
|
||||
'notes.sortUpdated': 'Last touched',
|
||||
'notes.sortCreated': 'Created',
|
||||
'notes.sortTitle': 'Name',
|
||||
'beacon.title': 'Counting installations',
|
||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||
'beacon.send': 'Send an anonymous daily report',
|
||||
'beacon.exactly': 'Exactly this — nothing more:',
|
||||
'beacon.lastPing': 'Last sent: {when}',
|
||||
'beacon.collector': 'Collector address',
|
||||
'beacon.collectorHint': 'Where the report goes. Leave it unless you run your own collector.',
|
||||
'beacon.beCollector': 'This instance is the collector',
|
||||
'beacon.receiving': 'Receiving anonymous reports — id and version, never an IP.',
|
||||
'beacon.total': 'Total',
|
||||
'beacon.active7': 'Active 7 days',
|
||||
'beacon.active30': 'Active 30 days',
|
||||
'beacon.byVersion': 'By version (30 days):',
|
||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
'notes.styleSubheading': 'Subheading',
|
||||
@@ -32,6 +53,9 @@ export const en = {
|
||||
'notes.styleNumbered': 'Numbered list',
|
||||
'notes.favourite': 'Make a favourite',
|
||||
'notes.unfavourite': 'Remove from favourites',
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.yesterday': 'yesterday',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
@@ -45,10 +69,14 @@ export const en = {
|
||||
'notes.saved': 'Saved',
|
||||
'notes.delete': 'Delete',
|
||||
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
|
||||
'palette.project': 'Project',
|
||||
'palette.task': 'Task',
|
||||
'palette.note': 'Note',
|
||||
'nav.today': 'Today',
|
||||
'nav.upcoming': 'Upcoming',
|
||||
'nav.delegated': 'Waiting on others',
|
||||
'nav.inbox': 'Inbox',
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.main': 'Main menu',
|
||||
@@ -286,6 +314,9 @@ export const en = {
|
||||
'users.taskOne': 'task',
|
||||
'users.taskMany': 'tasks',
|
||||
'users.never': 'never seen',
|
||||
'users.toProject': 'to {name}',
|
||||
'users.toInstance': 'to the instance',
|
||||
'users.invitedBy': 'invited by {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'History',
|
||||
@@ -301,6 +332,30 @@ export const en = {
|
||||
'history.noMatch': 'Nothing matches the filters.',
|
||||
'history.nothing': 'Nothing has been recorded yet.',
|
||||
'history.deletedAccount': 'A deleted account',
|
||||
|
||||
// What each recorded event is called. The keys are the server's and live in
|
||||
// events.js; these are the words they become.
|
||||
'event.projectCreated': 'created the project',
|
||||
'event.projectUpdated': 'changed the project',
|
||||
'event.projectDeleted': 'deleted the project',
|
||||
'event.projectImported': 'imported the project',
|
||||
'event.sectionCreated': 'created a section',
|
||||
'event.sectionUpdated': 'changed a section',
|
||||
'event.sectionDeleted': 'deleted a section',
|
||||
'event.memberInvited': 'invited',
|
||||
'event.memberAdded': 'added',
|
||||
'event.memberRemoved': 'removed',
|
||||
'event.memberRoleChanged': 'changed the role of',
|
||||
'event.taskCreated': 'created a task',
|
||||
'event.taskUpdated': 'changed a task',
|
||||
'event.taskCompleted': 'closed a task',
|
||||
'event.taskReopened': 'reopened a task',
|
||||
'event.taskMoved': 'moved a task',
|
||||
'event.taskDeleted': 'deleted a task',
|
||||
'event.taskSplit': 'split a task up',
|
||||
'event.commentCreated': 'wrote a comment',
|
||||
'event.attachmentAdded': 'attached a file',
|
||||
|
||||
'errors.title': 'Server errors',
|
||||
'errors.hint':
|
||||
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
|
||||
@@ -358,6 +413,10 @@ export const en = {
|
||||
'data.swept': 'swept away',
|
||||
'data.empty': 'empty',
|
||||
'data.export': 'Export',
|
||||
'data.importNotes': 'Import notes (a zip of Markdown files)',
|
||||
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
|
||||
'data.notesImported': '{n} notes and {files} files brought in.',
|
||||
'data.exportNotes': 'Every note as Markdown files',
|
||||
'data.exportAccount': 'The whole account as JSON',
|
||||
'data.exportOne': 'One project',
|
||||
'data.pickProject': 'Pick a project',
|
||||
@@ -463,6 +522,7 @@ export const en = {
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.notFound': 'That task does not exist, or it is not yours.',
|
||||
'detail.task': 'Task',
|
||||
'detail.close': 'Close',
|
||||
'detail.text': 'The task’s text',
|
||||
@@ -476,8 +536,13 @@ export const en = {
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
'detail.repeats': 'Repeats',
|
||||
'detail.never': 'Never',
|
||||
'detail.daily': 'Every day',
|
||||
'detail.weekly': 'Every week',
|
||||
'detail.fortnightly': 'Every other week',
|
||||
'detail.monthly': 'Every month',
|
||||
'detail.yearly': 'Every year',
|
||||
'detail.asWritten': 'As written',
|
||||
'detail.assignee': 'Assigned to',
|
||||
'detail.subtasks': 'Sub-tasks',
|
||||
'detail.addSubtask': 'Add a sub-task',
|
||||
@@ -525,7 +590,7 @@ export const en = {
|
||||
'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.',
|
||||
'int.oauthClient': 'OAuth client at Google',
|
||||
'int.registerHint':
|
||||
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, enable the Gmail API, and paste the two values here. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
|
||||
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application and paste the two values here. The same client serves both Gmail and Google Calendar: enable whichever API you need, and add both redirect URIs — the one below and the calendar\'s, which is shown in the Google Calendar panel. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
|
||||
'int.redirectURI': 'Authorised redirect URI',
|
||||
'int.clientID': 'Client ID',
|
||||
'int.clientSecret': 'Client secret',
|
||||
@@ -565,6 +630,31 @@ export const en = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'The username is your email. The password is an API token — not your real password.',
|
||||
'int.calendar': 'Google Calendar',
|
||||
'int.calendarHint':
|
||||
'Read, never written. The events appear in Calendar beside your own tasks with due dates — they cannot be moved or completed from here, because they belong to Google.',
|
||||
'int.connectCalendar': 'Connect Google Calendar',
|
||||
'int.calendarConnectedAs': 'Connected as',
|
||||
'int.calendarSameClient':
|
||||
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
|
||||
'int.whichCalendars': 'Which calendars are shown',
|
||||
'int.noCalendars': 'The account has no calendars to show.',
|
||||
'int.calendarLastSync': 'Last fetched {when}.',
|
||||
'int.calendarNeverSynced': 'Not fetched yet.',
|
||||
'int.calendarFetchedOne': '{n} event fetched.',
|
||||
'int.calendarFetchedMany': '{n} events fetched.',
|
||||
'int.calendarNothingNew': 'No events in the window.',
|
||||
'int.calendarConnected': 'Google Calendar is connected.',
|
||||
'int.calendarState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||
'int.calendarExpired': 'The attempt expired on the way. Try again.',
|
||||
'int.calendarInvalid': 'Google’s reply could not be read. Try again.',
|
||||
'int.calendarFailed': 'Google refused to exchange the code.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
|
||||
'int.calendarDenied': 'Access was refused at Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'The Google app is registered as Internal and only accepts accounts inside the organisation. A private account cannot consent to it, whatever the screen says.',
|
||||
'int.calendarOther': 'Google replied: {what}',
|
||||
'int.gmailFetchedOne': '{n} task fetched.',
|
||||
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||
'int.gmailNothingNew': 'No new messages.',
|
||||
@@ -578,6 +668,17 @@ export const en = {
|
||||
'int.gmailDenied': 'Access was refused at Google.',
|
||||
'int.gmailOther': 'Gmail replied: {what}',
|
||||
|
||||
// --- the calendar, with Google’s events laid over ------------------------------------
|
||||
'cal.title': 'Calendar',
|
||||
'cal.notConnected': 'No calendar is connected yet. That is done under Settings → Integrations.',
|
||||
'cal.settings': 'Settings',
|
||||
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
||||
'cal.allDay': 'All day',
|
||||
'cal.openInGoogle': 'Open in Google Calendar',
|
||||
'cal.beyondWindow':
|
||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||
'cal.eventCannotMove': '{name} — an event in {calendar}. It cannot be moved from here.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'The project’s name',
|
||||
'project.clickToRename': 'Click to rename',
|
||||
@@ -666,6 +767,7 @@ export const en = {
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'view.overdue': 'Overdue',
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
@@ -719,6 +821,10 @@ export const en = {
|
||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||
|
||||
'error.gmailSaid': 'Gmail said: {what}',
|
||||
'error.calendarRefused': 'Google refused the call. Try connecting the calendar again.',
|
||||
'error.calendarSaid': 'Google replied: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Disconnect Google Calendar? The tokens are forgotten and the fetched copy of the events is deleted. Your own tasks are untouched.',
|
||||
|
||||
'error.mailboxRefused': 'The mailbox could not be read.',
|
||||
'error.mailboxSaid': 'The mailbox answered: {what}',
|
||||
|
||||
@@ -37,14 +37,61 @@ const BLOCKS = [
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* En værdi, der skal stå inde i en attribut.
|
||||
*
|
||||
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
|
||||
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
|
||||
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
|
||||
* forskellen mellem tekst og et onerror.
|
||||
*/
|
||||
const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Billeder løftes ud af teksten, før noget andet rører den.
|
||||
*
|
||||
* De stod før først i kæden, og så kørte de fire resterende regler hen over en
|
||||
* streng, der nu indeholdt rigtig HTML — ind i den `alt`, der lige var lavet.
|
||||
* Det gik godt, så længe ingen af erstatningerne indeholdt et anførselstegn, og
|
||||
* det er en betingelse, der stod skrevet ingen steder: den dag nogen tilføjer en
|
||||
* regel for links, er den brudt, og det er præcis den vej, XSS-en kom ind ad.
|
||||
*
|
||||
* Værre endnu var undslupningen. `escapeHtml` kørte på hele teksten, og `attr`
|
||||
* kørte den igen på `alt` — så et og-tegn blev til en dobbelt undslupning, der
|
||||
* voksede ved hver eneste åbning og gemning. En note med "Kaffe & te" i et
|
||||
* billede blev længere, hver gang nogen så på den.
|
||||
*
|
||||
* En pladsholder løser begge dele. Billedet tages ud af den rå tekst, resten
|
||||
* undslippes og formateres som altid, og billedet lægges tilbage til sidst med en
|
||||
* `alt`, der er undsluppet nøjagtig én gang. Rækkefølgen af de øvrige regler kan
|
||||
* så ikke længere betyde noget.
|
||||
*
|
||||
* Nul-tegnet som mærke, fordi det er det ene tegn, en note ikke kan indeholde:
|
||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||
*/
|
||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
||||
const MARK = "\u0000";
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
return escapeHtml(text)
|
||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
||||
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
||||
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||
const images = [];
|
||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
||||
return `${MARK}${images.length - 1}${MARK}`;
|
||||
});
|
||||
|
||||
const marked = escapeHtml(lifted)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,31 +105,113 @@ function inlineToHtml(text) {
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = (markdown ?? '').split('\n');
|
||||
const out = [];
|
||||
let list = null;
|
||||
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||
|
||||
const closeList = () => {
|
||||
if (list) {
|
||||
out.push(`</${list}>`);
|
||||
list = null;
|
||||
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
|
||||
//
|
||||
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
|
||||
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
|
||||
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
|
||||
// var væk efter én gemning.
|
||||
//
|
||||
// Underlisten lægges *inde i* sit `<li>`, ikke ved siden af. Browsere viser
|
||||
// begge dele ens, men kun den ene er en liste i en liste, når den læses tilbage
|
||||
// — og det er læsningen tilbage, der skriver filen.
|
||||
const stack = [];
|
||||
let liOpen = false;
|
||||
|
||||
const closeItem = () => {
|
||||
if (liOpen) {
|
||||
out.push('</li>');
|
||||
liOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||
const closeList = (toDepth = 0) => {
|
||||
while (stack.length > toDepth) {
|
||||
closeItem();
|
||||
out.push(`</${stack.pop()}>`);
|
||||
// Efter et niveau lukkes, står vi igen inde i ophavets `<li>`.
|
||||
liOpen = stack.length > 0;
|
||||
}
|
||||
if (stack.length === 0) liOpen = false;
|
||||
};
|
||||
|
||||
if (bullet || numbered) {
|
||||
const want = bullet ? 'ul' : 'ol';
|
||||
if (list !== want) {
|
||||
closeList();
|
||||
out.push(`<${want}>`);
|
||||
list = want;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
|
||||
// begynder med - inde i et shell-script er ikke et punkt i en liste.
|
||||
const fenced = /^```(\w*)\s*$/.exec(line);
|
||||
if (fence !== null) {
|
||||
if (fenced) {
|
||||
out.push('</code></pre>');
|
||||
fence = null;
|
||||
} else {
|
||||
out.push(escapeHtml(line) + '\n');
|
||||
}
|
||||
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
if (fenced) {
|
||||
closeList();
|
||||
fence = fenced[1] ?? '';
|
||||
// attr, ikke escapeHtml: værdien står inde i en attribut. Den er sikker i
|
||||
// dag alene fordi hegn-regexen er \w*, som ikke rummer anførselstegn — og
|
||||
// den dag nogen udvider den for at tage imod "c++" eller "f#", er det en
|
||||
// XSS. Undslupningen skal passe til stedet, ikke til det, der
|
||||
// tilfældigvis kan stå der nu.
|
||||
out.push(`<pre data-lang="${attr(fence)}"><code>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
|
||||
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
|
||||
|
||||
if (item) {
|
||||
const indent = item[1].replace(/\t/g, ' ').length;
|
||||
const depth = Math.floor(indent / 2);
|
||||
const want = item[2] ? 'ul' : 'ol';
|
||||
|
||||
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
|
||||
// `</ul>` lander inde i det `<li>`, det hører til.
|
||||
closeList(depth + 1);
|
||||
|
||||
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
|
||||
// nummereret er to lister, ikke én med blandede mærker.
|
||||
if (stack.length === depth + 1 && stack[depth] !== want) {
|
||||
closeList(depth);
|
||||
}
|
||||
|
||||
while (stack.length < depth + 1) {
|
||||
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
|
||||
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
|
||||
// gør en gemning uden ændringer noten om.
|
||||
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
|
||||
out.push(`<${want}${want === 'ol' ? start : ''}>`);
|
||||
stack.push(want);
|
||||
liOpen = false;
|
||||
}
|
||||
|
||||
closeItem();
|
||||
out.push(`<li>${inlineToHtml(item[4])}`);
|
||||
liOpen = true;
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
|
||||
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
|
||||
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
|
||||
// det blev en liste i et citat til seks citater med et bindestreg i hver.
|
||||
if (/^>(?: |$)/.test(line)) {
|
||||
const inner = [];
|
||||
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^> ?/, ''));
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
out.push(`<blockquote>${markdownToHtml(inner.join('\n'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = BLOCKS.find((b) =>
|
||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||
);
|
||||
@@ -96,6 +225,7 @@ export function markdownToHtml(markdown) {
|
||||
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
if (fence !== null) out.push('</code></pre>');
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
@@ -106,6 +236,11 @@ function inlineToMarkdown(node) {
|
||||
|
||||
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||
switch (node.tagName) {
|
||||
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||
// nogen havde rørt det.
|
||||
case 'IMG':
|
||||
return ` ?? ''})`;
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
@@ -146,13 +281,25 @@ function inlineToMarkdown(node) {
|
||||
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||
* over that is a guess.
|
||||
*/
|
||||
/** The tags that are a block of their own, and must never be read as inline text. */
|
||||
const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
||||
|
||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
const walk = (node) => {
|
||||
/**
|
||||
* @param node hvad der læses
|
||||
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
|
||||
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
|
||||
* indeholde afsnit og lister uden at de bliver til én linje.
|
||||
*/
|
||||
const walk = (node, emit) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.textContent.trim()) lines.push(child.textContent);
|
||||
if (child.textContent.trim()) emit(child.textContent);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
@@ -160,32 +307,93 @@ export function htmlToMarkdown(root) {
|
||||
switch (child.tagName) {
|
||||
case 'UL':
|
||||
case 'OL': {
|
||||
let n = 1;
|
||||
for (const li of child.children) {
|
||||
const text = inlineToMarkdown(li).trim();
|
||||
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
||||
list(child, 0, emit);
|
||||
break;
|
||||
}
|
||||
|
||||
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
|
||||
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
|
||||
// — det var sådan en note kom tilbage som én lang linje efter en
|
||||
// gemning, uden at nogen havde rørt teksten.
|
||||
case 'BLOCKQUOTE': {
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
|
||||
} else {
|
||||
emit('> ' + inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DIV':
|
||||
// The browser's own container when nothing else applies. Read through
|
||||
// it rather than treating it as a block, or every paste adds a level.
|
||||
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
|
||||
walk(child);
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
} else {
|
||||
lines.push(inlineToMarkdown(child));
|
||||
emit(inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
case 'PRE': {
|
||||
// Back out as a fence, with the language it came in with. The old
|
||||
// four-space form is still read on the way in; it is not written
|
||||
// out any more, because a fence survives being pasted somewhere
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
emit('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
emit('```');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
|
||||
// citatet: læst som én linje forsvandt hvert punktmærke.
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
break;
|
||||
}
|
||||
const text = inlineToMarkdown(child);
|
||||
lines.push(block ? block.prefix + text : text);
|
||||
emit(block ? block.prefix + text : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
/**
|
||||
* Én liste, og de lister der ligger i den.
|
||||
*
|
||||
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
|
||||
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
|
||||
* fejl, fordi den sker uden at nogen har rørt noget.
|
||||
*/
|
||||
const list = (el, depth, emit) => {
|
||||
let n = Number(el.getAttribute('start') || 1);
|
||||
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
|
||||
let text;
|
||||
if (nested.length) {
|
||||
const clone = li.cloneNode(true);
|
||||
for (const sub of [...clone.children]) {
|
||||
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
|
||||
}
|
||||
text = inlineToMarkdown(clone).trim();
|
||||
} else {
|
||||
text = inlineToMarkdown(li).trim();
|
||||
}
|
||||
|
||||
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
|
||||
for (const sub of nested) list(sub, depth + 1, emit);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, (l) => lines.push(l));
|
||||
// Trailing blank lines are what an editor accumulates from people pressing
|
||||
// return at the end; they are not content and they grow every time.
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
|
||||
@@ -49,6 +49,8 @@ class AppState {
|
||||
* small request against modelling a count in two places.
|
||||
*/
|
||||
labelsChanged = $state(0);
|
||||
/** Bumped when a note moves, so a list showing them knows to look again. */
|
||||
notesChanged = $state(0);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
@@ -421,6 +423,17 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Files a note under a project, which is what sharing it means. */
|
||||
async moveNoteToProject(noteID, projectID) {
|
||||
try {
|
||||
await api.updateNote(noteID, { project_id: projectID });
|
||||
this.notesChanged++;
|
||||
this.toast(t('notes.filed'));
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProjects() {
|
||||
const { projects } = await api.listProjects();
|
||||
this.projects = projects;
|
||||
@@ -822,6 +835,43 @@ function read() {
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
/**
|
||||
* Month or week on Kalender.
|
||||
*
|
||||
* Its own key rather than Kommende's, even though the two switchers look alike.
|
||||
* They are not the same choice: Kommende opens on a list of the next seven days
|
||||
* and offers a grid, and Kalender is the grid — sharing the value would mean
|
||||
* choosing a week on one and finding the other had changed, and one of them would
|
||||
* have to answer for the `list` mode the other does not have.
|
||||
*
|
||||
* 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 not the answer on a wide monitor.
|
||||
*/
|
||||
const CALENDAR_MODES = ['week', 'month'];
|
||||
|
||||
class CalendarViewMode {
|
||||
mode = $state(readCalendarMode());
|
||||
|
||||
set(next) {
|
||||
if (!CALENDAR_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:calendar', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCalendarMode() {
|
||||
if (typeof localStorage === 'undefined') return 'month';
|
||||
const stored = localStorage.getItem('verdande:calendar');
|
||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||
}
|
||||
|
||||
export const calendarView = new CalendarViewMode();
|
||||
|
||||
/**
|
||||
* Whether the lists show what has already been finished.
|
||||
*
|
||||
|
||||
@@ -49,3 +49,32 @@ export function fullDate(iso) {
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeat rules worth a menu entry, and what to call them.
|
||||
*
|
||||
* Here rather than in the drawer that draws the menu, because the row draws the
|
||||
* same rule as a badge and the two had already disagreed: the menu said "Hver
|
||||
* dag" in both languages, and the badge said whatever the server said.
|
||||
*
|
||||
* The server sends `recurrence_text` with every task and sends it in Danish. It
|
||||
* has to: one payload goes over the WebSocket to everybody in the project at
|
||||
* once, and they do not all read the same language. So the rules that have a name
|
||||
* are named here, on the side that knows whose screen this is, and anything more
|
||||
* specific — "hver anden tirsdag", typed into the title — falls back to what the
|
||||
* server wrote.
|
||||
*/
|
||||
export const REPEATS = [
|
||||
{ rule: '', label: 'detail.never' },
|
||||
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||
];
|
||||
|
||||
/** What a repeating task says on its row. */
|
||||
export function repeatLabel(rule, fallback = '') {
|
||||
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||
return known ? t(known.label) : fallback;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
{#if liveOverdue.length}
|
||||
<section>
|
||||
<h2 class="overdue">Forsinket</h2>
|
||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||
{#each liveOverdue as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<section>
|
||||
{#if liveOverdue.length}
|
||||
<h2>I dag</h2>
|
||||
<h2>{t('nav.today')}</h2>
|
||||
{/if}
|
||||
{#each liveToday as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
|
||||
@@ -35,10 +35,29 @@
|
||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
||||
* how anybody looking for something they just closed would describe it.
|
||||
*/
|
||||
/**
|
||||
* Dagen en opgave blev lukket, målt hvor personen står.
|
||||
*
|
||||
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
|
||||
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
|
||||
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
|
||||
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
|
||||
*
|
||||
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
|
||||
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
|
||||
*/
|
||||
const localDay = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
};
|
||||
|
||||
let days = $derived.by(() => {
|
||||
const groups = new Map();
|
||||
for (const task of done) {
|
||||
const key = (task.completed_at ?? '').slice(0, 10);
|
||||
const key = localDay(task.completed_at);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(task);
|
||||
}
|
||||
|
||||
@@ -177,9 +177,11 @@
|
||||
<div class="what">
|
||||
<span class="primary-line">{pending.email}</span>
|
||||
<span class="secondary">
|
||||
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
||||
{pending.project_name
|
||||
? t('users.toProject', { name: pending.project_name })
|
||||
: t('users.toInstance')}
|
||||
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
|
||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||
{#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
|
||||
@@ -209,7 +211,8 @@
|
||||
</span>
|
||||
<span class="secondary">
|
||||
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
|
||||
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
||||
{#if user.project_count}·
|
||||
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||