Compare commits
+437
@@ -0,0 +1,437 @@
|
|||||||
|
# Notes, as verdande builds them
|
||||||
|
|
||||||
|
A description of one working notes system: the data model, the syntax, the search,
|
||||||
|
the rendering contract, the import and export formats, and — more useful than any
|
||||||
|
of it — the decisions that look arbitrary until you know what they cost.
|
||||||
|
|
||||||
|
It is written to be lifted into another program. Nothing here depends on Go,
|
||||||
|
SQLite or Svelte except where it says so; where an answer was forced by the tool,
|
||||||
|
that is called out so you can pick a different one on purpose rather than by
|
||||||
|
accident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read this part first: what to ask before you build
|
||||||
|
|
||||||
|
**This document is not a drop-in. It describes a notes system that lives inside a
|
||||||
|
task app with projects, roles, a trash and a search box, and about a third of its
|
||||||
|
design is inherited from that.** Lifting it into a program with different
|
||||||
|
surroundings without deciding which parts to keep produces something that works
|
||||||
|
for a week and then fights you.
|
||||||
|
|
||||||
|
**If you are an assistant working from this file: ask these questions and get
|
||||||
|
answers before writing code.** Do not guess a default. Each one changes what you
|
||||||
|
build, not just how you build it.
|
||||||
|
|
||||||
|
### About the surroundings
|
||||||
|
|
||||||
|
1. **Does the host app already have a place to file things** — projects, folders,
|
||||||
|
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
|
||||||
|
filed them under the projects that already existed. If your app has nothing,
|
||||||
|
you need an answer for where a note lives, and "a folder tree" is the expensive
|
||||||
|
one.
|
||||||
|
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
|
||||||
|
through the whole model below. Single-user changes the schema and removes about
|
||||||
|
half the permission surface.
|
||||||
|
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
|
||||||
|
it, deleting is either permanent or you are building a trash.
|
||||||
|
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
|
||||||
|
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
|
||||||
|
ranking section below changes accordingly.
|
||||||
|
|
||||||
|
### About the notes themselves
|
||||||
|
|
||||||
|
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
|
||||||
|
That decision is defended below; it is also the one that most constrains
|
||||||
|
everything else.
|
||||||
|
6. **Is the first line the title, or is there a title field?** Both work. Deriving
|
||||||
|
it means one less thing to keep in step and one less thing a person can leave
|
||||||
|
blank, and it forces the rule "the first line is a heading" on the editor.
|
||||||
|
7. **What links to what?** `#project`, `[[note]]` and task references exist here
|
||||||
|
because the app has projects and tasks. Yours may have none of them, or others.
|
||||||
|
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
|
||||||
|
authorisation path and an XSS surface. Skipping them is a legitimate answer.
|
||||||
|
|
||||||
|
### About scale and language
|
||||||
|
|
||||||
|
9. **How many notes, realistically?** Several choices below flip at around ten
|
||||||
|
thousand: the list payload, the search fallback, rendering the whole list.
|
||||||
|
10. **Which languages?** The folding described under *Search* is Danish. Every
|
||||||
|
language has its own version of that problem and the generic answer (strip
|
||||||
|
diacritics) is wrong for several of them.
|
||||||
|
11. **Where does the text end up besides the screen?** Search indexes, AI
|
||||||
|
features, exports and backups each read it. Plaintext makes all of them cheap
|
||||||
|
and makes a stolen backup expensive — see *What is still open*.
|
||||||
|
|
||||||
|
### About integration, specifically
|
||||||
|
|
||||||
|
12. **Who owns the note's identity — you or the other system?** If notes will sync
|
||||||
|
with something else, ids and timestamps are the whole problem, and the
|
||||||
|
`updated_at` rule below becomes the first thing you have to change.
|
||||||
|
13. **Is this a read of somebody else's notes, or the store of record?** A copy
|
||||||
|
needs conflict rules; a store of record needs export.
|
||||||
|
14. **What happens to a note whose project, task or link target is deleted?**
|
||||||
|
Answered here as "the link goes dead and the note is untouched". If your host
|
||||||
|
app cascades instead, say so before the first delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape of a note
|
||||||
|
|
||||||
|
```
|
||||||
|
id an opaque string, unique
|
||||||
|
project_id nullable — where it is filed, or loose
|
||||||
|
title derived, never set by a caller
|
||||||
|
body Markdown, the whole note
|
||||||
|
created_by nullable — survives the account being deleted
|
||||||
|
pinned favourite; the list puts these first
|
||||||
|
archived_at nullable timestamp — put away, not thrown away
|
||||||
|
deleted_at nullable timestamp — the trash
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown in a column
|
||||||
|
|
||||||
|
Not rich text, and not a format of our own.
|
||||||
|
|
||||||
|
Export was a requirement before a line was written, and **if the stored form is
|
||||||
|
the exported form there is no conversion left to lose anything in.** A note stays
|
||||||
|
readable by any editor on the day the program is gone. It is also what makes
|
||||||
|
search and anything an AI does over notes cheap: both read text.
|
||||||
|
|
||||||
|
The cost is real and you should know it before choosing: everything the editor can
|
||||||
|
do has to survive a round trip through Markdown, and anything Markdown cannot say,
|
||||||
|
the editor must not offer. There is no place to hide a feature.
|
||||||
|
|
||||||
|
### The title is the first line
|
||||||
|
|
||||||
|
Always, and re-derived on every save — not "when it is empty".
|
||||||
|
|
||||||
|
Derived once and then left alone, a title 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 no
|
||||||
|
caller can set it, so every caller gets the same answer.
|
||||||
|
|
||||||
|
This forces one editor rule: a new note opens on its title, and the first line is
|
||||||
|
styled as a heading. Otherwise a note whose first line is a sentence is a note
|
||||||
|
named by its first sentence.
|
||||||
|
|
||||||
|
### Archive and trash are two different bits
|
||||||
|
|
||||||
|
The trash says *this was a mistake, and in thirty days it is gone*. The archive
|
||||||
|
says *this is finished, and I do not want to read past it.*
|
||||||
|
|
||||||
|
A twelve-hundred-note import is exactly the case where most notes are worth
|
||||||
|
keeping and almost none are worth looking at. Without an archive, the list is the
|
||||||
|
whole archive forever, and finding this week's note means reading past a decade.
|
||||||
|
|
||||||
|
Both are nullable timestamps rather than booleans: *when was this put away* answers
|
||||||
|
questions a flag cannot, and it costs the same byte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filing: no hierarchy of its own
|
||||||
|
|
||||||
|
A note belongs to a project, or to nobody.
|
||||||
|
|
||||||
|
The reasoning is worth repeating because it is easy to talk yourself out of:
|
||||||
|
projects already exist, are already shared, already have roles, already have a
|
||||||
|
trash. **A second hierarchy to file things in is one too many**, and a note filed
|
||||||
|
under a project inherits every one of those answers instead of needing its own.
|
||||||
|
|
||||||
|
- A note with a project: everybody who can read the project can read the note; an
|
||||||
|
editor can change it.
|
||||||
|
- A note without one: the author's, and nobody else's.
|
||||||
|
|
||||||
|
Sharing a note *is* filing it in a project. There is no access list of its own —
|
||||||
|
a second way to give somebody access is a second place to get it wrong, and the
|
||||||
|
two would disagree the day one of them changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Links: three kinds, parsed out of the text
|
||||||
|
|
||||||
|
The body is the truth. Links are an index over it, rebuilt on every save.
|
||||||
|
|
||||||
|
| Syntax | Means | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
|
||||||
|
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
|
||||||
|
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
|
||||||
|
|
||||||
|
Patterns as used here:
|
||||||
|
|
||||||
|
```
|
||||||
|
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
|
||||||
|
note \[\[([^\]\n]{1,200})\]\]
|
||||||
|
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
|
||||||
|
```
|
||||||
|
|
||||||
|
Three decisions inside that table:
|
||||||
|
|
||||||
|
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
|
||||||
|
second kind of tag that happens to look alike. That is the whole of "tag a note
|
||||||
|
and see it in the project".
|
||||||
|
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
|
||||||
|
and the set is small; a diff is a second description of the same fact, and it
|
||||||
|
can disagree with the text. That is the one thing this must never do.
|
||||||
|
- **The target is not a foreign key.** A link to something deleted is a fact about
|
||||||
|
the note. It should read as a dead link rather than vanish, and it must not
|
||||||
|
block the delete.
|
||||||
|
|
||||||
|
The index exists so the question can be asked **backwards**: *what has been written
|
||||||
|
about this task?* Answering that by searching every note's prose would be too slow
|
||||||
|
to leave a panel open on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
Three layers, in the order they answer.
|
||||||
|
|
||||||
|
### 1. Full-text, ranked
|
||||||
|
|
||||||
|
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
|
||||||
|
|
||||||
|
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
|
||||||
|
is what the note is *about*; the same word once in a long body is a mention.
|
||||||
|
|
||||||
|
This matters more than it sounds. Ordering by "last touched" is invisible with ten
|
||||||
|
notes and useless with twelve hundred imported the same evening: searching "Tesla"
|
||||||
|
answered with a note about nginx first and the note actually called "Tesla Model Y"
|
||||||
|
second — which reads as a search that does not work, because from where the person
|
||||||
|
is sitting it is one.
|
||||||
|
|
||||||
|
### 2. Folding, for the language you are in
|
||||||
|
|
||||||
|
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
|
||||||
|
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
|
||||||
|
typing "gron", which is how people type when the keyboard is not theirs.
|
||||||
|
|
||||||
|
The answer here is a generated column carrying a transliterated copy:
|
||||||
|
|
||||||
|
```
|
||||||
|
ø,Ø → o æ,Æ → ae å,Å → aa
|
||||||
|
```
|
||||||
|
|
||||||
|
indexed alongside the body and **weighted the same as the body** — otherwise a
|
||||||
|
folded match outranks an exact one.
|
||||||
|
|
||||||
|
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
|
||||||
|
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
|
||||||
|
> naive lowercasing entirely. Get the mapping from somebody who types the
|
||||||
|
> language.
|
||||||
|
|
||||||
|
### 3. A word inside a word
|
||||||
|
|
||||||
|
An index is built of tokens, and a token is whatever the tokenizer decided was a
|
||||||
|
word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||||
|
`keepinventory` — so a search for "keep inventory" asks for two words that are
|
||||||
|
nowhere and gets nothing, while the note sits right there.
|
||||||
|
|
||||||
|
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
|
||||||
|
are ANDed, so the half that works is thrown away by the half that cannot.
|
||||||
|
|
||||||
|
**This is not exotic in a notes app.** A decade of notes is full of commands,
|
||||||
|
identifiers and file names, and every one of them is several words the index
|
||||||
|
thinks is one.
|
||||||
|
|
||||||
|
So: the index answers first; when it has nothing, the text is read, and every term
|
||||||
|
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
|
||||||
|
column covers title, body and language folding at once.
|
||||||
|
|
||||||
|
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
|
||||||
|
notes that is a few megabytes and a millisecond or two, and it only runs when the
|
||||||
|
fast path already came back empty — which is exactly the search that was about to
|
||||||
|
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
|
||||||
|
answer then is a trigram index, not a bigger scan.
|
||||||
|
|
||||||
|
It does not rank. There is no match to rank — that is the whole situation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The list, and why the body is empty in it
|
||||||
|
|
||||||
|
A list showing a title and ninety characters of preview must not fetch whole
|
||||||
|
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
|
||||||
|
see.
|
||||||
|
|
||||||
|
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
|
||||||
|
|
||||||
|
A field called `body` holding a *piece* of the body is a trap. The editor opens
|
||||||
|
it, the autosave pause writes it back, and the note is now cut down to its own
|
||||||
|
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
|
||||||
|
its own field and leave `body` empty; the client sees the empty body and fetches
|
||||||
|
the whole note.
|
||||||
|
|
||||||
|
That costs one more separation, and it is not optional:
|
||||||
|
|
||||||
|
> **Which row is selected and which note is loaded are two different things.**
|
||||||
|
|
||||||
|
The editor loads its text when the note's id changes. Hand it the excerpt first
|
||||||
|
and the whole note second and the id is the same both times — so the second one
|
||||||
|
never renders, and somebody sits looking at a note that had a title a moment ago.
|
||||||
|
|
||||||
|
Highlight the row immediately from the list; give the editor the note only when it
|
||||||
|
is whole.
|
||||||
|
|
||||||
|
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
|
||||||
|
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
|
||||||
|
virtual list, no scroll maths, and no list that can fall out of step with itself.
|
||||||
|
Try that before building windowing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The editor contract
|
||||||
|
|
||||||
|
Rich text on top, Markdown underneath, and a source view that shows the file.
|
||||||
|
|
||||||
|
The supported subset — deliberately small, because everything in it has to survive
|
||||||
|
the round trip:
|
||||||
|
|
||||||
|
| Block | Markdown |
|
||||||
|
|---|---|
|
||||||
|
| Title | `# ` |
|
||||||
|
| Heading | `## ` |
|
||||||
|
| Subheading | `### ` |
|
||||||
|
| Quote | `> ` |
|
||||||
|
| Code block | ```` ``` ```` with an optional language |
|
||||||
|
| Bullet and numbered lists | `- `, `1. `, nested |
|
||||||
|
|
||||||
|
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
|
||||||
|
and images as ``.
|
||||||
|
|
||||||
|
Five rules that were each learned the hard way:
|
||||||
|
|
||||||
|
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
|
||||||
|
lines into a paragraph. Here it must not: the person typed a line and expects
|
||||||
|
to get it back, and an editor that silently reflows what somebody wrote is an
|
||||||
|
editor they stop trusting.
|
||||||
|
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
|
||||||
|
the caret back at the start, which mid-sentence is unusable.
|
||||||
|
3. **One definition of what a code block contains.** A copy button inside the
|
||||||
|
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
|
||||||
|
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
|
||||||
|
the code and the next save put it on disk. Have one function that says what a
|
||||||
|
block holds, used by the highlighter and the Markdown serialiser both.
|
||||||
|
4. **A note that cannot be rendered must not take the pane with it.** One note
|
||||||
|
arriving in an unexpected shape threw inside the editor's reactive effect, and
|
||||||
|
from then on *no* note would open — the previous one just stayed on screen. Put
|
||||||
|
a guard around the render, fall back to showing the raw text, and let the rest
|
||||||
|
of the pane keep working.
|
||||||
|
5. **Escaping between tags and escaping inside an attribute are different jobs.**
|
||||||
|
An escaper written for text between two tags handles `&`, `<`, `>` — and not
|
||||||
|
quotes, because between tags they are harmless. Inside an attribute they close
|
||||||
|
it. `` is the whole exploit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
Images pasted or dragged into a note, stored content-addressed by hash, referenced
|
||||||
|
from the body as ordinary Markdown images.
|
||||||
|
|
||||||
|
The security shape, which is the part worth copying:
|
||||||
|
|
||||||
|
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
|
||||||
|
are the one place a user supplies content another user opens. An uploaded SVG or
|
||||||
|
HTML file rendered inline runs its own script on your origin with the session
|
||||||
|
cookie attached.
|
||||||
|
- **One exception, as a named allowlist:** a short list of raster image types
|
||||||
|
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
|
||||||
|
A note carries its pictures in the text, and served as octet-stream every one of
|
||||||
|
them is a broken image.
|
||||||
|
- **The list is named one type at a time, never matched on an `image/` prefix.**
|
||||||
|
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
|
||||||
|
script.
|
||||||
|
- **The stored path is checked against the files directory again at read time.** A
|
||||||
|
traversal that somehow got into the database must not become one that reads
|
||||||
|
`/etc/passwd`.
|
||||||
|
- **Reaching an attachment follows the note, which follows its project.** Same
|
||||||
|
chain as everything else. Do not let a client hand you the parent id and take it
|
||||||
|
on trust.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import and export
|
||||||
|
|
||||||
|
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
|
||||||
|
front matter block.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
created: 2019-04-02T10:15:00Z
|
||||||
|
modified: 2024-11-30T21:04:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Møde med Anders
|
||||||
|
|
||||||
|
Han vil gerne have kaffe hver uge.
|
||||||
|
```
|
||||||
|
|
||||||
|
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
|
||||||
|
all accepted on the way in. Obsidian and Bear write this block; so should you.
|
||||||
|
- Images travel in the zip beside the notes, and the links in the text are
|
||||||
|
relative — **a note and its pictures must end up in the same archive**, which
|
||||||
|
matters the moment an export is split into parts.
|
||||||
|
- File names collide: two notes called "Møde" are two notes, and a zip with one
|
||||||
|
entry twice is a zip that loses one.
|
||||||
|
|
||||||
|
Limits that a real import taught, all of them cheap to add and expensive to omit:
|
||||||
|
|
||||||
|
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
|
||||||
|
64 MB of zip expanded to 66 GB is a normal shape for an attack.
|
||||||
|
- A per-file limit as well as a whole-archive budget: one enormous photograph and
|
||||||
|
ten thousand small ones are different attacks with the same ending.
|
||||||
|
- The entry name is never used as a path. Store by hash; the name is only a key
|
||||||
|
for rewriting links.
|
||||||
|
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
|
||||||
|
dropped silently by a filter looking for `..` — one out of 1141, found only by
|
||||||
|
counting at both ends.
|
||||||
|
|
||||||
|
### Timestamps on the way in
|
||||||
|
|
||||||
|
An importer needs to write `created_at` and `updated_at` from the file, and the
|
||||||
|
normal save path must refuse to let it.
|
||||||
|
|
||||||
|
`updated_at` means *when did this program last write this note*. Let any caller set
|
||||||
|
it and the column means whatever the last writer felt like. Give the importer its
|
||||||
|
own narrow entry point instead, and say in the comment why it exists.
|
||||||
|
|
||||||
|
Without those dates, a move of twelve hundred notes is a pile that was all written
|
||||||
|
the same evening — and the order they were written in is half of what an archive
|
||||||
|
is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is still open here
|
||||||
|
|
||||||
|
Written down because a specification that only lists what works is a specification
|
||||||
|
that lies by omission.
|
||||||
|
|
||||||
|
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
|
||||||
|
they are in every backup anybody can download. Encrypting them is bigger than it
|
||||||
|
sounds: the body is in the full-text index and the folded column is generated
|
||||||
|
from it, so both are a second plaintext copy in the same database. Encrypt and
|
||||||
|
both have to go, and search has to be rewritten to decrypt in application code —
|
||||||
|
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
|
||||||
|
point the answer is a keyed index (an HMAC per word).
|
||||||
|
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
|
||||||
|
- **There is no sync.** Export and the API are the way out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A short list of the traps, for the person in a hurry
|
||||||
|
|
||||||
|
1. A `body` field holding an excerpt will be saved over the real one.
|
||||||
|
2. Selected row and loaded note are two different pieces of state.
|
||||||
|
3. The title must be re-derived on every save or it goes stale.
|
||||||
|
4. Diacritic stripping is not folding, and folding is language-specific.
|
||||||
|
5. A tokenizer cannot see a word inside a word; decide what you do about it.
|
||||||
|
6. An attachment served inline is a script on your origin unless you allowlist by
|
||||||
|
exact type and send `nosniff`.
|
||||||
|
7. Escaping for text and escaping for attributes are different functions.
|
||||||
|
8. One render failure must not be allowed to freeze the whole pane.
|
||||||
|
9. An unpacked-size limit is not optional on an archive import.
|
||||||
|
10. Let the importer set timestamps; let nobody else.
|
||||||
@@ -103,15 +103,16 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| Mail-to-task | Done, tested |
|
| Mail-to-task | Done, tested |
|
||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
| Gmail: OAuth2 with PKCE, polling, 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 |
|
| Update notice for administrators | Done, tested |
|
||||||
| Personal API tokens | Done, tested |
|
| Personal API tokens | Done, tested |
|
||||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||||
implemented against their specifications and have only ever been exercised there,
|
API calls are implemented against their specifications and have only ever been
|
||||||
not against live services. Everything else has an interface: opening a task shows
|
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
|
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||||
templates.
|
templates.
|
||||||
|
|||||||
+21
-6
@@ -10,13 +10,28 @@ reply within a week.
|
|||||||
|
|
||||||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||||||
be raised later without locking anyone out.
|
be raised later without locking anyone out.
|
||||||
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
|
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||||||
stored only as a SHA-256 hash. A database dump contains nothing that can be
|
database dump contains nothing that can be presented.
|
||||||
presented.
|
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||||||
|
are looked up by their exact value, and they are shown in the interface more
|
||||||
|
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||||||
|
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||||||
|
address files tasks into their inbox. Rotating either from **Settings →
|
||||||
|
Integrations** invalidates the old one. This is written down rather than fixed
|
||||||
|
because fixing it means the address can only ever be shown once, which is a
|
||||||
|
decision about how the feature works and not only about how it is stored.
|
||||||
|
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||||||
|
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||||||
|
what makes a stray copy of the database less than a stray copy of everything:
|
||||||
|
the backup does not carry the key.
|
||||||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||||||
- **Uploads** are stored content-addressed by hash and always served as
|
- **Uploads** are stored content-addressed by hash and served as
|
||||||
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
|
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||||||
on this origin.
|
note can show the pictures in it: a short allowlist of raster image types is
|
||||||
|
served inline as its own type. The list is named one format at a time rather
|
||||||
|
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||||||
|
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||||||
|
browser must read the file as that image or not at all.
|
||||||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||||||
concatenated into a statement.
|
concatenated into a statement.
|
||||||
- **Permission checks** are route middleware, and anything you may not see answers
|
- **Permission checks** are route middleware, and anything you may not see answers
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func run() error {
|
|||||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||||
runner.SyncGmail = api.SyncGmail
|
runner.SyncGmail = api.SyncGmail
|
||||||
runner.SyncMailbox = api.SyncMailbox
|
runner.SyncMailbox = api.SyncMailbox
|
||||||
|
runner.SyncCalendars = api.SyncCalendars
|
||||||
runner.Push = api.PushToUser
|
runner.Push = api.PushToUser
|
||||||
runner.SendBeacon = api.SendBeacon
|
runner.SendBeacon = api.SendBeacon
|
||||||
runner.Start(jobCtx)
|
runner.Start(jobCtx)
|
||||||
|
|||||||
+44
-7
@@ -1,13 +1,14 @@
|
|||||||
# Calendar and CalDAV
|
# 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) |
|
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Direction | Read only | Two-way |
|
| 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⁵ |
|
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||||
| You can tick things off | No | Yes |
|
| You can tick things off | No | Yes | No — an event is not a task |
|
||||||
| Setup | Paste a URL | Username and token |
|
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
|
||||||
|
|
||||||
## Subscribing to a feed
|
## Subscribing to a feed
|
||||||
|
|
||||||
@@ -39,6 +40,42 @@ most people actually keep.
|
|||||||
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
||||||
immediately.
|
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
|
## Connecting a CalDAV client
|
||||||
|
|
||||||
This is the one where ticking something off in Apple Reminders ticks it off here.
|
This is the one where ticking something off in Apple Reminders ticks it off here.
|
||||||
|
|||||||
+26
-7
@@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`.
|
|||||||
yourself, password resets are written to the log, and reminders arrive in the
|
yourself, password resets are written to the log, and reminders arrive in the
|
||||||
app and as Web Push rather than by email.
|
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
|
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
|
||||||
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
|
registration covers both: register an OAuth client in Google Cloud
|
||||||
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
|
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
|
||||||
and add the `gmail.readonly` scope.
|
**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 |
|
| Variable | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -52,8 +64,15 @@ and add the `gmail.readonly` scope.
|
|||||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
||||||
|
|
||||||
One registration serves every user on the instance; each person authorises their
|
One registration serves every user on the instance; each person authorises their
|
||||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
|
||||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
**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
|
## Secrets
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ administrator who can read yours.
|
|||||||
**Settings → Integrations → Gmail → Connect.**
|
**Settings → Integrations → Gmail → Connect.**
|
||||||
|
|
||||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
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.
|
Once it is set up, each person connects their own mailbox through it.
|
||||||
|
|
||||||
Choose what makes a task:
|
Choose what makes a task:
|
||||||
|
|||||||
@@ -1396,6 +1396,57 @@ paths:
|
|||||||
description: The environment variables that are not set.
|
description: The environment variables that are not set.
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/v1/notes/bulk:
|
||||||
|
post:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Archive, unarchive or delete a set of notes in one call.
|
||||||
|
description: |
|
||||||
|
One call rather than one per note, because the thing being asked is one
|
||||||
|
thing: "put these away". Fifty separate requests would be fifty round
|
||||||
|
trips, fifty chances for half of them to fail, and a list that redraws
|
||||||
|
fifty times — and after an import of twelve hundred notes, selecting fifty
|
||||||
|
is the ordinary case rather than the extreme one.
|
||||||
|
|
||||||
|
Each note is still checked on its own. A set is a convenience for the
|
||||||
|
caller and never a way to touch something they could not have touched one
|
||||||
|
at a time; anything they may not write to is counted in `skipped` rather
|
||||||
|
than failing the whole call, so one note somebody else just deleted does
|
||||||
|
not stop the other forty-nine.
|
||||||
|
|
||||||
|
Archiving is not deleting. The trash says "this was a mistake, and in
|
||||||
|
thirty days it is gone"; the archive says "this is finished, and I want to
|
||||||
|
keep it".
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: At most 500.
|
||||||
|
archived:
|
||||||
|
type: boolean
|
||||||
|
description: Put away, or bring back out.
|
||||||
|
delete:
|
||||||
|
type: boolean
|
||||||
|
description: Into the trash instead. Wins over `archived`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: How many were changed, and how many were passed over.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
done: { type: integer }
|
||||||
|
skipped: { type: integer }
|
||||||
|
"422":
|
||||||
|
description: No ids, too many, or neither archived nor delete was asked for.
|
||||||
|
|
||||||
/api/v1/notes/{noteID}/attachments:
|
/api/v1/notes/{noteID}/attachments:
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -1998,6 +2049,83 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/projects/{projectID}/sections/reorder:
|
||||||
|
parameters:
|
||||||
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Set the order the project's sections appear in. Editors and owners.
|
||||||
|
description: |
|
||||||
|
The whole list at once, for the same reasons as `/projects/reorder`: a
|
||||||
|
project has a handful of sections, so sending the order you want is
|
||||||
|
simpler than midpoints and cannot land half-applied.
|
||||||
|
|
||||||
|
Ids belonging to another project are skipped rather than refused.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
items: { type: string }
|
||||||
|
description: Section ids, top to bottom.
|
||||||
|
responses:
|
||||||
|
"204": { description: Reordered. }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/push/test:
|
||||||
|
post:
|
||||||
|
tags: [notifications]
|
||||||
|
summary: Send one notification to your own devices, and say what happened.
|
||||||
|
description: |
|
||||||
|
The answer to "no notifications arrive". A push that a service refuses is
|
||||||
|
the ordinary failure — a stale subscription, a browser that revoked
|
||||||
|
permission without telling the server — and every one of them looks
|
||||||
|
exactly like silence from the outside.
|
||||||
|
|
||||||
|
It goes through the same path a real notification takes, so what it proves
|
||||||
|
is the thing that has to work. The title and body come from the caller
|
||||||
|
because the dictionaries live in the interface.
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title: { type: string, maxLength: 200 }
|
||||||
|
body: { type: string, maxLength: 500 }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: What each device did with it.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
subscriptions:
|
||||||
|
type: integer
|
||||||
|
description: Devices registered for this account.
|
||||||
|
sent:
|
||||||
|
type: integer
|
||||||
|
failed:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: The push service's host — never the endpoint, which is a credential.
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
description: What the service said.
|
||||||
|
|
||||||
/api/v1/sections/{sectionID}:
|
/api/v1/sections/{sectionID}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -2654,6 +2782,126 @@ paths:
|
|||||||
created: { type: integer }
|
created: { type: integer }
|
||||||
"502": { $ref: "#/components/responses/Upstream" }
|
"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:
|
/api/v1/export/notes.zip:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -3297,6 +3545,26 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
"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:
|
/mcp:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -3479,6 +3747,40 @@ components:
|
|||||||
email: { type: string }
|
email: { type: string }
|
||||||
trigger: { type: string, enum: ["", starred, label, both] }
|
trigger: { type: string, enum: ["", starred, label, both] }
|
||||||
label: { type: string }
|
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:
|
ImportResult:
|
||||||
description: Created
|
description: Created
|
||||||
content:
|
content:
|
||||||
@@ -3496,6 +3798,62 @@ components:
|
|||||||
items: { type: string }
|
items: { type: string }
|
||||||
|
|
||||||
schemas:
|
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:
|
Note:
|
||||||
type: object
|
type: object
|
||||||
description: A page of Markdown. The body is the stored form and the exported form.
|
description: A page of Markdown. The body is the stored form and the exported form.
|
||||||
|
|||||||
+37
-2
@@ -19,6 +19,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -76,7 +78,16 @@ func New(cfg Config) *Client {
|
|||||||
// Generous: a local model on modest hardware can take a while to produce
|
// Generous: a local model on modest hardware can take a while to produce
|
||||||
// its first token, and the alternative is a timeout that only ever fires
|
// its first token, and the alternative is a timeout that only ever fires
|
||||||
// for the people running their own.
|
// for the people running their own.
|
||||||
http: &http.Client{Timeout: 120 * time.Second},
|
// safedial, not a plain client. `base_url` is typed into a field in the
|
||||||
|
// interface, so it is a user-supplied address — and a server that fetches a
|
||||||
|
// user-supplied address is a way to ask it to reach things the caller
|
||||||
|
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||||
|
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||||
|
//
|
||||||
|
// The check is on the resolved address rather than on the URL, because a
|
||||||
|
// name answers whatever its owner says — and can answer differently the
|
||||||
|
// second time, after a parse-time check has passed.
|
||||||
|
http: safedial.Client(120 * time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,11 +279,35 @@ func (c *Client) do(req *http.Request, out any) error {
|
|||||||
// The provider's own message is included: for a local model it is usually
|
// The provider's own message is included: for a local model it is usually
|
||||||
// the actual problem ("model not found"), and hiding it would leave the
|
// the actual problem ("model not found"), and hiding it would leave the
|
||||||
// operator with nothing to go on.
|
// operator with nothing to go on.
|
||||||
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
|
// The provider's body is no longer passed back to the caller.
|
||||||
|
//
|
||||||
|
// It was, and combined with a free-text base URL that made a working
|
||||||
|
// internal scanner: point it at a host, read the first three hundred bytes
|
||||||
|
// of whatever answered. The operator still needs the message — for a local
|
||||||
|
// model it is usually the actual problem — so it goes to the log, where it
|
||||||
|
// is available to the person running the instance and to nobody else.
|
||||||
|
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
|
||||||
}
|
}
|
||||||
return json.Unmarshal(raw, out)
|
return json.Unmarshal(raw, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upstreamError keeps the provider's own words for the log and away from the
|
||||||
|
// response. Error() is what reaches a caller; Detail() is what is written down.
|
||||||
|
type upstreamError struct {
|
||||||
|
Host string
|
||||||
|
Status string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *upstreamError) Error() string {
|
||||||
|
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail is the whole of it, for the instance's own error log.
|
||||||
|
func (e *upstreamError) Detail() string {
|
||||||
|
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string, max int) string {
|
func truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -59,10 +59,19 @@ type Config struct {
|
|||||||
// without spending the real one on every run.
|
// without spending the real one on every run.
|
||||||
GmailSyncBudget time.Duration
|
GmailSyncBudget time.Duration
|
||||||
|
|
||||||
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
||||||
// in production, where it means Google.
|
// the same reason: a slow Google held the request open past what Cloudflare is
|
||||||
GmailTokenURL string
|
// 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
|
GmailAPIURL string
|
||||||
|
CalendarAPIURL string
|
||||||
|
|
||||||
PanelURL string
|
PanelURL string
|
||||||
PanelToken string
|
PanelToken string
|
||||||
@@ -100,6 +109,7 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||||
GmailSyncBudget: 25 * time.Second,
|
GmailSyncBudget: 25 * time.Second,
|
||||||
|
CalendarSyncBudget: 25 * time.Second,
|
||||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
@@ -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.
|
// configured separately and left to drift out of step with it.
|
||||||
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
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 {
|
func env(key, def string) string {
|
||||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
|
|||||||
if len(p.tokens) == 0 {
|
if len(p.tokens) == 0 {
|
||||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||||
}
|
}
|
||||||
|
// Refused before it is parsed, not while.
|
||||||
|
//
|
||||||
|
// `combine` rebuilds the whole accumulated SQL string and re-copies the
|
||||||
|
// argument slice per operator, so the cost is quadratic in the number of
|
||||||
|
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
|
||||||
|
// query string — took seven seconds of CPU and forty-six gigabytes of
|
||||||
|
// allocation in one request. The preview endpoint runs this on whatever is
|
||||||
|
// typed, and there is no write timeout on the server to cut it off.
|
||||||
|
//
|
||||||
|
// Two hundred terms is a filter nobody has ever written and far below where it
|
||||||
|
// costs anything.
|
||||||
|
if len(p.tokens) > maxTokens {
|
||||||
|
return Compiled{}, fmt.Errorf("filteret er for langt")
|
||||||
|
}
|
||||||
|
|
||||||
node, err := p.parseExpression()
|
node, err := p.parseExpression()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
|
|||||||
|
|
||||||
// --- parser -------------------------------------------------------------------
|
// --- parser -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The ceilings on one filter. See Parse for why.
|
||||||
|
const (
|
||||||
|
maxTokens = 200
|
||||||
|
maxDepth = 32
|
||||||
|
)
|
||||||
|
|
||||||
type parser struct {
|
type parser struct {
|
||||||
tokens []token
|
tokens []token
|
||||||
pos int
|
pos int
|
||||||
ctx Context
|
ctx Context
|
||||||
|
// depth guards against a filter that is only parentheses. Each level is a
|
||||||
|
// stack frame, and nothing else stops somebody sending a megabyte of them.
|
||||||
|
depth int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||||
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
|
|||||||
if p.done() {
|
if p.done() {
|
||||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||||
}
|
}
|
||||||
|
// Nesting is stack depth. A filter made of half a million opening parentheses
|
||||||
|
// is a megabyte of text and a stack frame for each.
|
||||||
|
p.depth++
|
||||||
|
defer func() { p.depth-- }()
|
||||||
|
if p.depth > maxDepth {
|
||||||
|
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
|
||||||
|
}
|
||||||
|
|
||||||
switch t := p.peek(); t.kind {
|
switch t := p.peek(); t.kind {
|
||||||
case tokNot:
|
case tokNot:
|
||||||
|
|||||||
@@ -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": "[email protected]", "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-212
@@ -1,5 +1,5 @@
|
|||||||
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||||
// Google, and the small slice of the Gmail API verdande needs.
|
// it asks for.
|
||||||
//
|
//
|
||||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
// 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
|
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||||
@@ -9,199 +9,50 @@
|
|||||||
//
|
//
|
||||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
// 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.
|
// 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
|
package gmail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
"github.com/kristianwind/verdande/internal/google"
|
||||||
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.
|
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
const 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.
|
var (
|
||||||
//
|
NewPKCE = google.NewPKCE
|
||||||
// A field rather than a constant so a test can point it at a server it controls.
|
ErrUnauthorized = google.ErrUnauthorized
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the API ---------------------------------------------------------------------
|
// --- the API ---------------------------------------------------------------------
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
accessToken string
|
accessToken string
|
||||||
endpoints Endpoints
|
// 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
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// At points the client at another server. Tests only; the zero value is Google.
|
// At points the client at another server. Tests only; the zero value is Google.
|
||||||
func (c *Client) At(e Endpoints) *Client {
|
func (c *Client) At(base string) *Client {
|
||||||
c.endpoints = e
|
c.base = base
|
||||||
return c
|
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.
|
// Message is the part of a Gmail message a task is made from.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
|||||||
var parsed struct {
|
var parsed struct {
|
||||||
EmailAddress string `json:"emailAddress"`
|
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 "", err
|
||||||
}
|
}
|
||||||
return parsed.EmailAddress, nil
|
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"`
|
ID string `json:"id"`
|
||||||
} `json:"messages"`
|
} `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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
|||||||
} `json:"headers"`
|
} `json:"headers"`
|
||||||
} `json:"payload"`
|
} `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
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,30 +166,8 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 <[email protected]>",
|
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
||||||
// falling back to the address when there is no display name.
|
// falling back to the address when there is no display name.
|
||||||
@@ -375,11 +211,3 @@ func Query(trigger, label string) string {
|
|||||||
terms = append(terms, "newer_than:30d")
|
terms = append(terms, "newer_than:30d")
|
||||||
return strings.Join(terms, " ")
|
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
|
package gmail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||||
// Getting this wrong means Google refuses every exchange with a message that does
|
// one the person reads on the consent screen. The flow itself is exercised in
|
||||||
// not say which end is at fault.
|
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
func TestTheScopeIsReadOnlyMail(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.
|
|
||||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
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) {
|
func TestQuery(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
trigger, label string
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bucket is cleared only when the login is *finished*.
|
||||||
|
//
|
||||||
|
// It used to be cleared here, on the strength of the password alone — before
|
||||||
|
// the second factor had been asked for at all. That handed somebody who
|
||||||
|
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||||
|
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||||
|
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||||
|
// second, that is even odds inside an afternoon — against the one control that
|
||||||
|
// exists precisely for when the password is already gone.
|
||||||
|
//
|
||||||
|
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||||
|
// it, once the code is right.
|
||||||
|
if !user.TOTPEnabled {
|
||||||
s.loginLimiter.reset(clientIP(r))
|
s.loginLimiter.reset(clientIP(r))
|
||||||
|
}
|
||||||
|
|
||||||
// A session that still needs a code gets a short life of its own: long enough
|
// A session that still needs a code gets a short life of its own: long enough
|
||||||
// to fetch a phone, not long enough to be worth stealing.
|
// to fetch a phone, not long enough to be worth stealing.
|
||||||
@@ -191,6 +205,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
session := sessionFrom(r.Context())
|
session := sessionFrom(r.Context())
|
||||||
|
|
||||||
|
// Counted per account as well as per address.
|
||||||
|
//
|
||||||
|
// The address bucket alone is defeated by anybody with more than one, which is
|
||||||
|
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||||
|
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||||
|
// scarcity has to be counted against the thing being attacked.
|
||||||
|
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||||
|
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||||
|
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||||
|
"too many verification codes; wait a moment and try again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A recovery code is accepted in the same field. Making somebody find a
|
// A recovery code is accepted in the same field. Making somebody find a
|
||||||
@@ -198,6 +225,15 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// introduce a second concept.
|
// introduce a second concept.
|
||||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||||
if rerr != nil || !used {
|
if rerr != nil || !used {
|
||||||
|
// The pending session is left alive on purpose.
|
||||||
|
//
|
||||||
|
// Ending it on the first wrong digit was considered and rejected: it
|
||||||
|
// would make somebody who mistyped one number type their password again,
|
||||||
|
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||||
|
// guesses are scarce, and the per-account bucket above is what makes
|
||||||
|
// them scarce — ten an hour, however many times the password is
|
||||||
|
// re-entered. The attack this closes was never "one session, many
|
||||||
|
// guesses"; it was "a fresh bucket for every password check".
|
||||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||||
"that verification code is not valid")
|
"that verification code is not valid")
|
||||||
return
|
return
|
||||||
@@ -205,6 +241,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.log.Info("recovery code used", "user", user.ID)
|
s.log.Info("recovery code used", "user", user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||||
|
// fumbled the code twice is not still waiting once they get it right.
|
||||||
|
s.loginLimiter.reset(clientIP(r))
|
||||||
|
s.totpLimiter.reset(user.ID)
|
||||||
|
|
||||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||||
s.internal(w, r, "promote session", err)
|
s.internal(w, r, "promote session", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -301,7 +301,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
|||||||
|
|
||||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||||
// not lock the account.
|
// not lock the account.
|
||||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||||
|
// at slå den fra, og den har altid spurgt.
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "et langt kodeord",
|
||||||
|
})
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
@@ -368,7 +372,9 @@ func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
|||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|
||||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "et langt kodeord",
|
||||||
|
})
|
||||||
secret := body["secret"].(string)
|
secret := body["secret"].(string)
|
||||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||||
"code": codeFor(t, secret),
|
"code": codeFor(t, secret),
|
||||||
@@ -897,3 +903,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||||
|
//
|
||||||
|
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||||
|
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||||
|
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||||
|
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||||
|
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||||
|
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "det forkerte kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Fatal("en tom adgangskode blev accepteret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||||
|
//
|
||||||
|
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
|
||||||
|
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
|
||||||
|
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||||
|
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||||
|
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
secret := body["secret"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||||
|
|
||||||
|
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||||
|
// her kunne blive ved i det uendelige.
|
||||||
|
refused := 0
|
||||||
|
for i := 0; i < 14; i++ {
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "[email protected]", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refused == 0 {
|
||||||
|
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if getErr == nil {
|
if getErr == nil {
|
||||||
|
// The task decides, not the URL.
|
||||||
|
//
|
||||||
|
// The check above is about the project the *client named*, and a client
|
||||||
|
// names both halves of this address. Asking the project for permission and
|
||||||
|
// then taking the task id from the same string ties nothing together: a
|
||||||
|
// viewer on a shared project could name a project of their own — the Inbox,
|
||||||
|
// which everybody owns and may edit — and put the shared task's id in the
|
||||||
|
// filename. The project check passed on the Inbox and the write landed on a
|
||||||
|
// task they may only read.
|
||||||
|
//
|
||||||
|
// GetTask does not close it either: it answers "may this person see it",
|
||||||
|
// and a viewer may. `CanEdit` is the question, and it is the same one
|
||||||
|
// handleUpdateTask asks — the API door has always asked it.
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// And it has to live where the address says it does. Without this an editor
|
||||||
|
// on two projects can reach either through the other's URL — nothing is
|
||||||
|
// escalated by that on its own, but a URL allowed to lie about where a
|
||||||
|
// resource lives is how the case above got in.
|
||||||
|
if existing.ProjectID != projectID {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// An update. Only the fields a task client can express are touched;
|
// An update. Only the fields a task client can express are touched;
|
||||||
// everything else the task carries stays as it is.
|
// everything else the task carries stays as it is.
|
||||||
content := parsed.Summary
|
content := parsed.Summary
|
||||||
|
|||||||
@@ -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": "[email protected]", "summary": "[email protected]", "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(), "[email protected]")
|
||||||
|
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["[email protected]"] = []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"] != "[email protected]" {
|
||||||
|
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, "[email protected]", "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(), "[email protected]")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,17 +225,32 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
a := &store.Attachment{
|
a := &store.Attachment{
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
CommentID: r.FormValue("comment_id"),
|
|
||||||
Filename: safeFilename(header.Filename),
|
Filename: safeFilename(header.Filename),
|
||||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||||
Size: size,
|
Size: size,
|
||||||
Path: stored,
|
Path: stored,
|
||||||
UploadedBy: user.ID,
|
UploadedBy: user.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||||
// so, and the form may have supplied a comment id.
|
// so, and the form may have supplied a comment id.
|
||||||
if a.CommentID != "" {
|
//
|
||||||
a.TaskID = ""
|
// The comment is resolved and checked against the task in the URL. It was
|
||||||
|
// taken on trust, which meant the permission check above guarded the *task
|
||||||
|
// somebody named* while the file landed on *whatever comment they asked for*.
|
||||||
|
// A viewer on a shared project can list its comments, so she could read an id
|
||||||
|
// there and then post to a task in her own project with that id attached: the
|
||||||
|
// file appears under somebody else's comment, in a project she may only read,
|
||||||
|
// and is served to everyone in it from this origin under a name she chose.
|
||||||
|
// Somebody removed from a project could do the same with ids they kept.
|
||||||
|
if id := r.FormValue("comment_id"); id != "" {
|
||||||
|
owner, err := s.db.CommentTask(r.Context(), id)
|
||||||
|
if err != nil || owner != taskID {
|
||||||
|
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.CommentID, a.TaskID = id, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||||
|
//
|
||||||
|
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
|
||||||
|
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
|
||||||
|
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
|
||||||
|
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
|
||||||
|
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||||
|
policy := resp.Header.Get("Content-Security-Policy")
|
||||||
|
if policy == "" {
|
||||||
|
t.Fatal("der er ingen Content-Security-Policy på svaret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Målt på script-src alene, og ikke på hele politikken.
|
||||||
|
//
|
||||||
|
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
|
||||||
|
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
|
||||||
|
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
|
||||||
|
// bliver til et kørende script, og det er derfor den, prøven ser på.
|
||||||
|
scriptSrc := ""
|
||||||
|
for _, part := range strings.Split(policy, ";") {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
|
||||||
|
scriptSrc = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scriptSrc == "" {
|
||||||
|
t.Fatal("der er ingen script-src i politikken")
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
|
||||||
|
if strings.Contains(scriptSrc, forbidden) {
|
||||||
|
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og de fire, der skal være der.
|
||||||
|
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||||
|
if !strings.Contains(policy, want) {
|
||||||
|
t.Errorf("politikken mangler %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et bilag må ikke kunne blive læst som et dokument.
|
||||||
|
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||||
|
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||||
|
if inlineImage[mime] {
|
||||||
|
t.Errorf("%s står på listen over det, der vises inline", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
|
||||||
|
for _, mime := range []string{"image/png", "image/jpeg"} {
|
||||||
|
if !inlineImage[mime] {
|
||||||
|
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,11 @@ const (
|
|||||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
// 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.
|
// is not there — and it says it in its own words.
|
||||||
CodeMailboxFailed = "mailbox_failed"
|
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
|
// 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.
|
// to redeploy a container to paste a client id.
|
||||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||||
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
|
||||||
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
|
||||||
cfg := gmail.Config{
|
cfg := gmail.Config{
|
||||||
ClientID: s.cfg.GmailClientID,
|
ClientID: s.cfg.GmailClientID,
|
||||||
ClientSecret: s.cfg.GmailClientSecret,
|
ClientSecret: s.cfg.GmailClientSecret,
|
||||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||||
Endpoints: endpoints,
|
Scope: gmail.Scope,
|
||||||
|
TokenURL: s.cfg.GoogleTokenURL,
|
||||||
}
|
}
|
||||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -271,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
|
// 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
|
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||||
// one marker.
|
// one marker.
|
||||||
unlock := s.lockMailbox(box.ID)
|
unlock := s.lockSync(box.ID)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||||
box = fresh
|
box = fresh
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
|||||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
cfg.GmailClientID = "test-client"
|
cfg.GmailClientID = "test-client"
|
||||||
cfg.GmailClientSecret = "test-secret"
|
cfg.GmailClientSecret = "test-secret"
|
||||||
cfg.GmailTokenURL = google.URL + "/token"
|
cfg.GoogleTokenURL = google.URL + "/token"
|
||||||
cfg.GmailAPIURL = google.URL
|
cfg.GmailAPIURL = google.URL
|
||||||
})
|
})
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -97,7 +97,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|||||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
cfg.GmailClientID = "test-client"
|
cfg.GmailClientID = "test-client"
|
||||||
cfg.GmailClientSecret = "test-secret"
|
cfg.GmailClientSecret = "test-secret"
|
||||||
cfg.GmailTokenURL = google.URL + "/token"
|
cfg.GoogleTokenURL = google.URL + "/token"
|
||||||
cfg.GmailAPIURL = google.URL
|
cfg.GmailAPIURL = google.URL
|
||||||
// A second rather than the real twenty-five: what is being tested is that a
|
// 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
|
// budget is applied at all, and spending the production one on every run
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/ai"
|
"github.com/kristianwind/verdande/internal/ai"
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/quickadd"
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,6 +215,24 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The address is checked where it is typed, as well as when it is dialled.
|
||||||
|
//
|
||||||
|
// safedial refuses a private destination at connect time and is the actual
|
||||||
|
// wall; this is so the person entering it hears about it now rather than
|
||||||
|
// discovering it later as a failed summary. A scheme other than http or https
|
||||||
|
// is refused outright — file:// and gopher:// are not providers.
|
||||||
|
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reason := safedial.CheckURL(url); reason != "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.BaseURL = url
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.aiConfig(r, user.ID)
|
existing, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- MCP -----------------------------------------------------------------------------
|
// --- MCP -----------------------------------------------------------------------------
|
||||||
@@ -250,6 +252,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The promise of sharing has to hold at every door, not just the API's.
|
||||||
|
//
|
||||||
|
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
|
||||||
|
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
|
||||||
|
// authorised differently: it checked the caller's standing in the project *named
|
||||||
|
// in the URL* and then took the task id from the URL as well. Nothing tied the
|
||||||
|
// two together.
|
||||||
|
//
|
||||||
|
// So a viewer on somebody else's project could name a project of their own —
|
||||||
|
// their Inbox, which everybody owns and everybody may edit — and put the other
|
||||||
|
// project's task id in the filename. The project check passed on the Inbox, and
|
||||||
|
// the write landed on a task in a project they may only read.
|
||||||
|
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "[email protected]", "Viewer")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "en opgave", "project_id": projectID,
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "[email protected]", "role": "viewer",
|
||||||
|
}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
them, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, them.ID)
|
||||||
|
|
||||||
|
// Their own Inbox: a project they own and may edit, and the one every account
|
||||||
|
// has. It is what makes the URL check pass.
|
||||||
|
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var inbox string
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.IsInbox {
|
||||||
|
inbox = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inbox == "" {
|
||||||
|
t.Fatal("the viewer has no inbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||||
|
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
|
||||||
|
token, them.Email, vtodo)
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the task is as its owner left it. The status alone is not the assertion:
|
||||||
|
// the question is whether anything moved.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["content"] != "en opgave" {
|
||||||
|
t.Errorf("the task was renamed by a viewer: %v", after["content"])
|
||||||
|
}
|
||||||
|
if after["completed"] != false {
|
||||||
|
t.Error("the task was completed by a viewer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task may only be addressed under the project it is actually in.
|
||||||
|
//
|
||||||
|
// The same hole seen from the other side: an editor on two projects could reach a
|
||||||
|
// task in one of them through the other's URL. Nothing is escalated by it — they
|
||||||
|
// may edit both — but it is the check that was missing, and a URL that lies about
|
||||||
|
// where a resource lives is how the viewer case got in.
|
||||||
|
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, user.ID)
|
||||||
|
|
||||||
|
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
|
||||||
|
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "hører til Et", "project_id": one["id"].(string),
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||||
|
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
|
||||||
|
token, user.Email, vtodo)
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["content"] != "hører til Et" {
|
||||||
|
t.Errorf("the task changed: %v", after["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalDAVRequiresCredentials(t *testing.T) {
|
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -562,6 +674,48 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connecting something else must not delete the AI provider.
|
||||||
|
//
|
||||||
|
// Reported from use as "the AI settings are not saved": they were saved, and then
|
||||||
|
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
|
||||||
|
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
|
||||||
|
// one row and each write deleted the other two. Every write returned 204.
|
||||||
|
//
|
||||||
|
// Through the handlers, because that is where it was seen. The shape is asserted
|
||||||
|
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
|
||||||
|
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
|
||||||
|
})
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// What the calendar and the mailbox handlers do when a handshake starts: write
|
||||||
|
// their own scope for the same person.
|
||||||
|
for _, scope := range []string{"calendar", "gmail"} {
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
|
||||||
|
map[string]any{"pkce_state": "s-" + scope}); err != nil {
|
||||||
|
t.Fatalf("save %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||||
|
if body["provider"] != "anthropic" {
|
||||||
|
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
|
||||||
|
}
|
||||||
|
if body["model"] != "claude-sonnet-5" {
|
||||||
|
t.Errorf("the model is %v", body["model"])
|
||||||
|
}
|
||||||
|
if body["has_key"] != true {
|
||||||
|
t.Error("the key was taken with it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGmailSettings(t *testing.T) {
|
func TestGmailSettings(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -1316,3 +1470,62 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
|||||||
t.Error("the second rotation returned the same address")
|
t.Error("the second rotation returned the same address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||||
|
//
|
||||||
|
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||||
|
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||||
|
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||||
|
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||||
|
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||||
|
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||||
|
"title": "Prøve", "body": "kom den frem?",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["subscriptions"] != float64(0) {
|
||||||
|
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||||
|
// som et navngivent afslag og ikke som tavshed.
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||||
|
UserID: user.ID,
|
||||||
|
Endpoint: "https://push.example.invalid/abc",
|
||||||
|
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||||
|
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||||
|
if body["subscriptions"] != float64(1) {
|
||||||
|
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||||
|
}
|
||||||
|
failed, _ := body["failed"].([]any)
|
||||||
|
if len(failed) != 1 {
|
||||||
|
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||||
|
}
|
||||||
|
first, _ := failed[0].(map[string]any)
|
||||||
|
if first["service"] != "push.example.invalid" {
|
||||||
|
t.Errorf("service = %v", first["service"])
|
||||||
|
}
|
||||||
|
if first["reason"] == "" || first["reason"] == nil {
|
||||||
|
t.Error("afslaget kom uden en grund")
|
||||||
|
}
|
||||||
|
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||||
|
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||||
|
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||||
|
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
// 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
|
// releasing before the marker is written would leave exactly the gap this is
|
||||||
// here to close.
|
// here to close.
|
||||||
unlock := s.lockMailbox(m.ID)
|
unlock := s.lockSync(m.ID)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
// Re-read under the lock. The row that came in may have been fetched before the
|
// Re-read under the lock. The row that came in may have been fetched before the
|
||||||
@@ -250,8 +250,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
// lockSync serialises one thing that polls against itself and returns the release.
|
||||||
func (s *Server) lockMailbox(id string) func() {
|
//
|
||||||
|
// 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{})
|
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||||
mu := value.(*sync.Mutex)
|
mu := value.(*sync.Mutex)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "search notes", err)
|
s.internal(w, r, "search notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "notes in project", err)
|
s.internal(w, r, "notes in project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive is the same list asked the other way round, so it is the same
|
||||||
|
// endpoint with a flag rather than a second one that would have to be kept in
|
||||||
|
// step with this.
|
||||||
|
if r.URL.Query().Get("archived") != "" {
|
||||||
|
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list archived notes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefly replaces each note's body with the first of it.
|
||||||
|
//
|
||||||
|
// The list shows a title and about ninety characters. It was being sent whole
|
||||||
|
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||||
|
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||||
|
// parsed and held in memory on every visit.
|
||||||
|
//
|
||||||
|
// Body is emptied rather than shortened, and that is the important half. A field
|
||||||
|
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||||
|
// it, the debounce would save it, and the note would be cut down to its own
|
||||||
|
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||||
|
// check, and it does — see the notes page, which fetches the whole note when it
|
||||||
|
// opens one.
|
||||||
|
//
|
||||||
|
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||||
|
// it shows anything, and a heading marker and a bold word are characters that
|
||||||
|
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||||
|
// is meant to fill.
|
||||||
|
const previewChars = 400
|
||||||
|
|
||||||
|
func briefly(notes []store.Note) []store.Note {
|
||||||
|
out := make([]store.Note, 0, len(notes))
|
||||||
|
for _, n := range notes {
|
||||||
|
body := []rune(n.Body)
|
||||||
|
if len(body) > previewChars {
|
||||||
|
body = body[:previewChars]
|
||||||
|
}
|
||||||
|
n.Preview = string(body)
|
||||||
|
n.Body = ""
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// readable drops what this person may not see.
|
// readable drops what this person may not see.
|
||||||
@@ -294,6 +341,14 @@ func noteFilename(title string) string {
|
|||||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||||
const maxNoteImportBytes = 64 << 20
|
const maxNoteImportBytes = 64 << 20
|
||||||
|
|
||||||
|
// What the archive may become once it is unpacked, and how many entries it may
|
||||||
|
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
||||||
|
// nothing at all about what it expands to.
|
||||||
|
const (
|
||||||
|
maxNoteImportUnpacked = 2 << 30
|
||||||
|
maxNoteImportFiles = 20_000
|
||||||
|
)
|
||||||
|
|
||||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
// 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
|
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||||
@@ -332,24 +387,57 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
// note is read its pictures already have addresses to be rewritten to — a note
|
// 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
|
// and its images arrive in whatever order the archive happens to hold them, and
|
||||||
// one pass would leave half the links pointing at nothing.
|
// one pass would leave half the links pointing at nothing.
|
||||||
|
// The archive as a whole, not just the upload.
|
||||||
|
//
|
||||||
|
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
||||||
|
// MB can become sixty-six gigabytes on the way out — written into the same
|
||||||
|
// volume the database lives on. The upload limit says nothing about that, and
|
||||||
|
// nothing else did either.
|
||||||
|
//
|
||||||
|
// Two ceilings: one per file, and one across the archive. A single enormous
|
||||||
|
// photograph and ten thousand small ones are different attacks with the same
|
||||||
|
// ending.
|
||||||
|
if len(zr.File) > maxNoteImportFiles {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||||
|
"that archive holds too many files")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
budget := int64(maxNoteImportUnpacked)
|
||||||
|
|
||||||
stored := map[string]string{}
|
stored := map[string]string{}
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
name := f.Name
|
name := f.Name
|
||||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// The header's own claim first, which costs nothing to check and rejects
|
||||||
|
// the obvious case before a byte is written. It is only a claim, so the
|
||||||
|
// reader below is limited as well.
|
||||||
|
if f.UncompressedSize64 > maxUploadBytes {
|
||||||
|
s.log.Warn("import: a file in the archive is too large", "file", name)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hashed, size, err := s.storeUpload(rc)
|
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
||||||
rc.Close()
|
rc.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("import note file", "err", err, "file", name)
|
s.log.Warn("import note file", "err", err, "file", name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if size > maxUploadBytes || size > budget {
|
||||||
|
// Over the line once it was actually read: remove what was written
|
||||||
|
// rather than keeping a truncated file that would open as nothing.
|
||||||
|
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
||||||
|
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
budget -= size
|
||||||
stored[name] = hashed
|
stored[name] = hashed
|
||||||
_ = size
|
|
||||||
files++
|
files++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- putting notes away, one or many ---------------------------------------------
|
||||||
|
|
||||||
|
type noteBulkRequest struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Archived *bool `json:"archived"`
|
||||||
|
Delete bool `json:"delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
||||||
|
// and few enough that the loop below cannot become somebody's afternoon.
|
||||||
|
const maxNoteBulk = 500
|
||||||
|
|
||||||
|
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
||||||
|
//
|
||||||
|
// One call rather than one per note, because the thing being asked is one thing:
|
||||||
|
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
||||||
|
// chances for half of them to fail, and a list that redraws fifty times — and
|
||||||
|
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
||||||
|
// not the extreme one.
|
||||||
|
//
|
||||||
|
// Each note is still checked on its own. A set is a convenience for the caller
|
||||||
|
// and must never become a way to touch something they could not have touched one
|
||||||
|
// at a time.
|
||||||
|
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req noteBulkRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) > maxNoteBulk {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done, skipped := 0, 0
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
n, err := s.db.Note(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not an error, and not a 404 for the whole call: a set can contain a note
|
||||||
|
// somebody else deleted a second ago, and the other forty-nine should still
|
||||||
|
// be put away. Counted so the answer is honest about it.
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case req.Delete:
|
||||||
|
err = s.db.DeleteNote(r.Context(), id)
|
||||||
|
case req.Archived != nil:
|
||||||
|
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
||||||
|
default:
|
||||||
|
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "note bulk", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
|
||||||
|
// på.
|
||||||
|
//
|
||||||
|
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
|
||||||
|
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
|
||||||
|
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
|
||||||
|
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
|
||||||
|
//
|
||||||
|
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
|
||||||
|
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
|
||||||
|
// færdig.
|
||||||
|
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
w, err := zw.Create("bombe.bin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
|
||||||
|
// uden at prøven selv bruger en gigabyte.
|
||||||
|
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packed := int64(buf.Len())
|
||||||
|
if packed > 64<<10 {
|
||||||
|
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
|
||||||
|
}
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := zr.File[0]
|
||||||
|
if f.UncompressedSize64 != 4<<20 {
|
||||||
|
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
|
||||||
|
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
budget := int64(1 << 20)
|
||||||
|
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n <= budget {
|
||||||
|
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
|
||||||
|
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
|
||||||
|
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
|
||||||
|
if maxNoteImportUnpacked <= maxUploadBytes {
|
||||||
|
t.Error("arkivets budget er ikke større end én enkelt fil")
|
||||||
|
}
|
||||||
|
if maxNoteImportFiles < 2000 {
|
||||||
|
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
|||||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||||
|
// fetched by id, which is what the interface does when it opens one.
|
||||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||||
notes := listed["notes"].([]any)
|
notes := listed["notes"].([]any)
|
||||||
if len(notes) != 1 {
|
if len(notes) != 1 {
|
||||||
t.Fatalf("found %d notes", len(notes))
|
t.Fatalf("found %d notes", len(notes))
|
||||||
}
|
}
|
||||||
body := notes[0].(map[string]any)["body"].(string)
|
noteID := notes[0].(map[string]any)["id"].(string)
|
||||||
|
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||||
|
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||||
|
}
|
||||||
|
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||||
|
body := whole["body"].(string)
|
||||||
|
|
||||||
// The link points at the file where it now lives, not at a path inside an
|
// The link points at the file where it now lives, not at a path inside an
|
||||||
// archive nobody kept.
|
// archive nobody kept.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/push"
|
"github.com/kristianwind/verdande/internal/push"
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
|||||||
s.pushToUser(userID, title, body, projectID)
|
s.pushToUser(userID, title, body, projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
// pushOutcome is what happened, per device.
|
||||||
|
//
|
||||||
|
// It exists because "nothing arrives" was unanswerable. A push that a service
|
||||||
|
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
||||||
|
// service will not take, a browser that revoked permission without telling the
|
||||||
|
// server — and every one of them looked exactly like silence.
|
||||||
|
type pushOutcome struct {
|
||||||
|
Subscriptions int `json:"subscriptions"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
Failed []pushFailure `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushFailure names the service and quotes what it said.
|
||||||
|
//
|
||||||
|
// The host and not the endpoint: the rest of that URL is the credential the push
|
||||||
|
// service issued for this device, and it does not belong in an answer, a log line
|
||||||
|
// or a screenshot.
|
||||||
|
type pushFailure struct {
|
||||||
|
Service string `json:"service"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var out pushOutcome
|
||||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
return
|
return out
|
||||||
}
|
}
|
||||||
|
out.Subscriptions = len(subs)
|
||||||
|
|
||||||
public, private, err := s.vapidKeys(ctx)
|
public, private, err := s.vapidKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("vapid keys", "err", err)
|
s.log.Warn("vapid keys", "err", err)
|
||||||
return
|
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||||
@@ -126,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
|
|||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
out.Sent++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
// Warn, not Debug.
|
||||||
|
//
|
||||||
|
// This was the one line that would have said why no notification ever
|
||||||
|
// arrived, and at Debug it is not printed on an instance running at the
|
||||||
|
// default level. A delivery that fails silently and a delivery that does
|
||||||
|
// not happen look the same from the outside, and the difference is the
|
||||||
|
// whole diagnosis.
|
||||||
|
reason := pushReason(sub.Endpoint, err)
|
||||||
|
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
||||||
|
out.Failed = append(out.Failed, pushFailure{
|
||||||
|
Service: pushHost(sub.Endpoint),
|
||||||
|
Reason: reason,
|
||||||
|
})
|
||||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||||
s.log.Warn("record push failure", "err", recErr)
|
s.log.Warn("record push failure", "err", recErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushReason is what went wrong, with the endpoint taken back out of it.
|
||||||
|
//
|
||||||
|
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
||||||
|
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
||||||
|
// endpoint, which is the credential the push service issued for that device. It
|
||||||
|
// would have gone into the answer, the log line and any screenshot of either. A
|
||||||
|
// test written to prove the host was reported instead is what found it.
|
||||||
|
//
|
||||||
|
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
||||||
|
// anyway: the next error type to carry it will not come with a warning.
|
||||||
|
func pushReason(endpoint string, err error) string {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||||
|
err = urlErr.Err
|
||||||
|
}
|
||||||
|
reason := err.Error()
|
||||||
|
if endpoint != "" {
|
||||||
|
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
||||||
|
func pushHost(endpoint string) string {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "ukendt"
|
||||||
|
}
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePushTest sends one notification to this account's devices and says what
|
||||||
|
// happened to it.
|
||||||
|
//
|
||||||
|
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
||||||
|
// settings page said "on for this device" as soon as a subscription existed, and a
|
||||||
|
// refusal from the push service went into a Debug line nobody sees. There was no
|
||||||
|
// way to tell "the browser never subscribed" from "the service refused it" from
|
||||||
|
// "nothing has come due yet" — three different problems with one appearance.
|
||||||
|
//
|
||||||
|
// It goes through the same path as a real notification rather than a shortcut, so
|
||||||
|
// what it proves is the thing that actually has to work.
|
||||||
|
//
|
||||||
|
// The words come from the client, which is where the dictionaries are. A server
|
||||||
|
// that carries its own copy of "This is a test" carries it in one language, and
|
||||||
|
// this one deliberately keeps its prose in the interface — see
|
||||||
|
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
||||||
|
// it, on their own devices.
|
||||||
|
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = "verdande"
|
||||||
|
}
|
||||||
|
// Bounded: this is written into a notification on somebody's lock screen, and
|
||||||
|
// a megabyte of title is not a notification.
|
||||||
|
if len(title) > 200 {
|
||||||
|
title = title[:200]
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(req.Body)
|
||||||
|
if len(body) > 500 {
|
||||||
|
body = body[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||||
@@ -170,6 +288,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The endpoint is an address this server will POST to, on a schedule the
|
||||||
|
// caller chooses — set a reminder a minute out and the push job fetches it,
|
||||||
|
// again every minute. Unchecked, that is a way to make the instance reach
|
||||||
|
// anything it can route to, on demand.
|
||||||
|
//
|
||||||
|
// https only: a push service is on the public internet and speaks TLS. Nothing
|
||||||
|
// legitimate needs http here, and allowing it is the difference between a
|
||||||
|
// blind request and a readable one.
|
||||||
|
if !strings.HasPrefix(req.Endpoint, "https://") {
|
||||||
|
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||||
UserID: userFrom(r.Context()).ID,
|
UserID: userFrom(r.Context()).ID,
|
||||||
Endpoint: req.Endpoint,
|
Endpoint: req.Endpoint,
|
||||||
@@ -189,7 +324,10 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
||||||
|
// endpoint could unsubscribe their device — a small thing to do to somebody
|
||||||
|
// and no reason at all to be able to.
|
||||||
|
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
||||||
s.internal(w, r, "delete push subscription", err)
|
s.internal(w, r, "delete push subscription", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||||
// until told to let go.
|
// until told to let go.
|
||||||
run := func(done chan<- struct{}) {
|
run := func(done chan<- struct{}) {
|
||||||
unlock := srv.lockMailbox("box-1")
|
unlock := srv.lockSync("box-1")
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
inside++
|
inside++
|
||||||
if inside > most {
|
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
|
// And two different mailboxes must not wait for each other: one slow host is
|
||||||
// not everybody's problem.
|
// not everybody's problem.
|
||||||
a := srv.lockMailbox("box-a")
|
a := srv.lockSync("box-a")
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
srv.lockMailbox("box-b")()
|
srv.lockSync("box-b")()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleReorderSections sets the order of the sections inside one project.
|
||||||
|
//
|
||||||
|
// Under /projects/{projectID}/ rather than beside the other section routes, which
|
||||||
|
// hang off a section id: the thing being changed here is the project's list, not
|
||||||
|
// any one section in it. That also puts it behind the same project permission
|
||||||
|
// check as everything else in this subtree, instead of resolving a section to its
|
||||||
|
// project one id at a time and hoping they all named the same one.
|
||||||
|
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reorderProjectsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The same ceiling as the projects list, and a bound on the transaction rather
|
||||||
|
// than an opinion about how many sections a project may have.
|
||||||
|
if len(req.IDs) > 500 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
|
||||||
|
s.internal(w, r, "reorder sections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "section.updated", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// sectionAccess resolves a section to its project and checks the caller may edit
|
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||||
// it. Sections have no permissions of their own — they belong to a project, and
|
// it. Sections have no permissions of their own — they belong to a project, and
|
||||||
// that is where access is decided.
|
// that is where access is decided.
|
||||||
|
|||||||
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sections can be dragged into another order, and the order is the project's.
|
||||||
|
//
|
||||||
|
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
|
||||||
|
// order the work happens in — and until this endpoint existed the only way to
|
||||||
|
// change it was to delete a section and make it again, which moves its tasks to
|
||||||
|
// the unsectioned area on the way past.
|
||||||
|
func TestSectionsCanBeReordered(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||||
|
id := project["id"].(string)
|
||||||
|
|
||||||
|
ids := map[string]string{}
|
||||||
|
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||||
|
for _, name := range names {
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
|
||||||
|
ids[name] = body["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
|
||||||
|
order := make([]string, len(want))
|
||||||
|
for i, name := range want {
|
||||||
|
order[i] = ids[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||||
|
map[string]any{"ids": order})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||||
|
got, _ := listed["sections"].([]any)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, raw := range got {
|
||||||
|
if name := raw.(map[string]any)["name"]; name != want[i] {
|
||||||
|
t.Errorf("section %d is %v, want %q", i, name, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A section belonging to somebody else's project is not moved by naming it
|
||||||
|
// here. The ids in the body are not checked one by one — the WHERE clause is
|
||||||
|
// what refuses them — so this is the assertion that it actually does.
|
||||||
|
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
|
||||||
|
otherID := other["id"].(string)
|
||||||
|
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
|
||||||
|
map[string]any{"name": "Havearbejde"})
|
||||||
|
foreignID := foreign["id"].(string)
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||||
|
map[string]any{"ids": []string{foreignID}})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
|
||||||
|
elsewhere, _ := stillThere["sections"].([]any)
|
||||||
|
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
|
||||||
|
t.Errorf("the other project's section did not survive: %v", elsewhere)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A project can be created straight into a group.
|
// A project can be created straight into a group.
|
||||||
//
|
//
|
||||||
// `group_id` was declared on the request type, documented on it, and applied by
|
// `group_id` was declared on the request type, documented on it, and applied by
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ type Server struct {
|
|||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// than the rest of the API.
|
||||||
loginLimiter *limiter
|
loginLimiter *limiter
|
||||||
|
mailLimiter *limiter
|
||||||
resetLimiter *limiter
|
resetLimiter *limiter
|
||||||
|
// Second factors are counted against the *account*, not the address. An
|
||||||
|
// address bucket is defeated by anybody with more than one address, which is
|
||||||
|
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||||
|
// scarce, so the scarcity has to be counted against the thing being attacked.
|
||||||
|
totpLimiter *limiter
|
||||||
|
|
||||||
// Computed once from the built index.html; see csp.go.
|
// Computed once from the built index.html; see csp.go.
|
||||||
csp string
|
csp string
|
||||||
@@ -80,7 +86,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||||
hub: realtime.NewHub(log),
|
hub: realtime.NewHub(log),
|
||||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||||
|
// Generous on purpose: the caller is the mail server, one address delivering
|
||||||
|
// everything, and a personal instance gets a handful of forwarded mails a
|
||||||
|
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||||
|
// on the one public write path that has none.
|
||||||
|
mailLimiter: newLimiter(60, time.Minute),
|
||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
|
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||||
|
// their own code gets it right on the first or second try; ten in an hour is
|
||||||
|
// generous for a person and hopeless for a search through a million.
|
||||||
|
totpLimiter: newLimiter(10, time.Hour),
|
||||||
}
|
}
|
||||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||||
@@ -113,7 +128,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
r.Post("/inbound/mail", s.handleInboundMail)
|
//
|
||||||
|
// Rate limited for the same reason the beacon is: it is reachable by anybody
|
||||||
|
// who can route to this instance. It is the weaker case of the two — the
|
||||||
|
// beacon writes a row for a stranger, this one writes nothing without a
|
||||||
|
// 256-bit address it has to already know — but an address is shared with mail
|
||||||
|
// servers and printed on a settings page, and once it is known there was no
|
||||||
|
// ceiling at all on what could be made with it.
|
||||||
|
r.Method(http.MethodPost, "/inbound/mail",
|
||||||
|
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||||
|
|
||||||
// MCP with the token in the query string, for clients that cannot send a
|
// MCP with the token in the query string, for clients that cannot send a
|
||||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
@@ -133,6 +156,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireAuth)
|
r.Use(s.requireAuth)
|
||||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
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)
|
r.Route("/caldav", s.caldavRoutes)
|
||||||
|
|
||||||
@@ -194,11 +223,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Put("/nav-order", s.handleSetNavOrder)
|
r.Put("/nav-order", s.handleSetNavOrder)
|
||||||
r.Post("/logout", s.handleLogout)
|
r.Post("/logout", s.handleLogout)
|
||||||
r.Post("/password/change", s.handleChangePassword)
|
r.Post("/password/change", s.handleChangePassword)
|
||||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
|
||||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
|
||||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
|
||||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
|
||||||
|
|
||||||
// Behind requireSession, like the API tokens and for the same
|
// Behind requireSession, like the API tokens and for the same
|
||||||
// reason: a leaked token must not be able to end the sessions of
|
// reason: a leaked token must not be able to end the sessions of
|
||||||
@@ -208,6 +233,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/sessions", s.handleListSessions)
|
r.Get("/sessions", s.handleListSessions)
|
||||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||||
|
|
||||||
|
// Turning the second factor on, and the codes that get you back
|
||||||
|
// in. Sessions only, and setup asks for the password.
|
||||||
|
//
|
||||||
|
// These sat outside this group, which meant the door opened with
|
||||||
|
// a token and closed only with a password: `disable` has always
|
||||||
|
// asked for the password, `setup` asked for nothing. A stolen
|
||||||
|
// token could therefore fetch the secret, load it into its own
|
||||||
|
// authenticator, confirm it, and keep the only copy of the
|
||||||
|
// recovery codes. The owner's next login would stop at a code
|
||||||
|
// they do not have, and they could not turn it off without
|
||||||
|
// logging in first. A theft becomes a tenancy — the same
|
||||||
|
// sentence written next door about passkeys, and the same rule.
|
||||||
|
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||||
|
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||||
|
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||||
|
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||||
|
|
||||||
// Registering a key, and the list of them. Sessions only, for
|
// Registering a key, and the list of them. Sessions only, for
|
||||||
// the same reason: a leaked token must not be able to add its
|
// the same reason: a leaked token must not be able to add its
|
||||||
// own way in — that would turn a theft into a tenancy.
|
// own way in — that would turn a theft into a tenancy.
|
||||||
@@ -253,6 +295,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/key", s.handlePushKey)
|
r.Get("/key", s.handlePushKey)
|
||||||
r.Post("/subscribe", s.handleSubscribePush)
|
r.Post("/subscribe", s.handleSubscribePush)
|
||||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||||
|
// One notification, to this account's own devices, reporting what
|
||||||
|
// each of them did with it. The answer to "der kommer ikke nogen".
|
||||||
|
r.Post("/test", s.handlePushTest)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||||
@@ -305,6 +350,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// files in and nothing else could, which made the most ordinary act
|
// files in and nothing else could, which made the most ordinary act
|
||||||
// in a notes app — paste a screenshot — do nothing at all.
|
// in a notes app — paste a screenshot — do nothing at all.
|
||||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||||
|
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||||
|
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||||
|
r.Post("/bulk", s.handleNoteBulk)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/mailboxes", func(r chi.Router) {
|
r.Route("/mailboxes", func(r chi.Router) {
|
||||||
@@ -333,6 +381,19 @@ 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.Route("/import", func(r chi.Router) {
|
||||||
r.Post("/todoist", s.handleImportTodoist)
|
r.Post("/todoist", s.handleImportTodoist)
|
||||||
r.Post("/csv", s.handleImportCSV)
|
r.Post("/csv", s.handleImportCSV)
|
||||||
@@ -382,6 +443,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireProject(store.RoleEditor))
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
r.Post("/sections", s.handleCreateSection)
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
r.Post("/sections/reorder", s.handleReorderSections)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1777,3 +1778,42 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
|||||||
t.Errorf("content = %v", moved["content"])
|
t.Errorf("content = %v", moved["content"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A word inside a word, on the task side.
|
||||||
|
//
|
||||||
|
// The notes search grew this first — a note holding `keepInventory` could not be
|
||||||
|
// found by typing "keep inventory" — and a task is the same sentence with a
|
||||||
|
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
|
||||||
|
// two words that are nowhere.
|
||||||
|
//
|
||||||
|
// Having it in one of the two searches would be worse than having it in neither:
|
||||||
|
// "it finds it in my notes but not in my tasks" is the next report.
|
||||||
|
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
query string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"speed admin", 1},
|
||||||
|
{"admin", 1},
|
||||||
|
{"speedadmin", 1},
|
||||||
|
{"kulturskolen", 1},
|
||||||
|
{"vandhane", 0},
|
||||||
|
} {
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
|
||||||
|
}
|
||||||
|
tasks, _ := body["tasks"].([]any)
|
||||||
|
if len(tasks) != tc.want {
|
||||||
|
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,24 @@ type totpSetupResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// The password, the same as disable has always asked for.
|
||||||
|
//
|
||||||
|
// Symmetry is the point. Turning the second factor on is at least as
|
||||||
|
// consequential as turning it off — it decides which device gets to say yes
|
||||||
|
// from now on — and a door that opens more easily than it closes is not a
|
||||||
|
// door. Together with the move behind requireSession, this is what stops a
|
||||||
|
// stolen credential from enrolling its own authenticator and keeping the only
|
||||||
|
// copy of the recovery codes.
|
||||||
|
var req totpDisableRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if user.TOTPEnabled {
|
if user.TOTPEnabled {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
"two-factor authentication is already switched on")
|
"two-factor authentication is already switched on")
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ type Runner struct {
|
|||||||
// reason as SyncGmail: it owns turning a message into a task.
|
// reason as SyncGmail: it owns turning a message into a task.
|
||||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
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
|
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||||
// layer, which owns the VAPID keys and the subscription list.
|
// layer, which owns the VAPID keys and the subscription list.
|
||||||
Push func(userID, title, body, projectID string)
|
Push func(userID, title, body, projectID string)
|
||||||
@@ -74,6 +78,13 @@ func (r *Runner) Start(ctx context.Context) {
|
|||||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||||
// starred and slow enough not to look like abuse.
|
// starred and slow enough not to look like abuse.
|
||||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
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
|
// 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
|
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||||
// skip the day. SendBeacon decides whether a day has passed.
|
// skip the day. SendBeacon decides whether a day has passed.
|
||||||
@@ -342,6 +353,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
|||||||
return nil
|
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 -----------------------------------------------------------------
|
// --- housekeeping -----------------------------------------------------------------
|
||||||
|
|
||||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||||
|
|||||||
+15
-1
@@ -32,6 +32,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
@@ -101,7 +103,19 @@ func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) e
|
|||||||
req.Header.Set("Urgency", "normal")
|
req.Header.Set("Urgency", "normal")
|
||||||
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
||||||
|
|
||||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
// safedial, not a plain client. The endpoint comes from the browser's
|
||||||
|
// subscription, which is to say from whoever is signed in, and this server
|
||||||
|
// POSTs to it on a schedule they choose — set a reminder a minute out and the
|
||||||
|
// job goes again every minute.
|
||||||
|
//
|
||||||
|
// The subscribe handler already refuses a private address, and that check is
|
||||||
|
// not enough on its own: it runs on the URL as written, and a name it approves
|
||||||
|
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
|
||||||
|
// *resolved* address at connect time, which is the only moment the address
|
||||||
|
// that will actually be used is known — and it checks every redirect hop the
|
||||||
|
// same way. The parse-time check stays, because it says no while somebody is
|
||||||
|
// looking at the screen.
|
||||||
|
resp, err := safedial.Client(15 * time.Second).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A subscription must not be able to point the server at its own network.
|
||||||
|
//
|
||||||
|
// The endpoint is whatever the browser handed over, which is to say whatever the
|
||||||
|
// person signed in handed over, and this server POSTs to it on a schedule they
|
||||||
|
// choose. The subscribe handler refuses a private address when it is written
|
||||||
|
// down — and that check runs on the URL as written, so a name it approves can
|
||||||
|
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
|
||||||
|
// address that will actually be dialled, checked at the moment of dialling.
|
||||||
|
func TestSendRefusesToReachInside(t *testing.T) {
|
||||||
|
reached := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
// httptest listens on 127.0.0.1, which is exactly the destination in question.
|
||||||
|
if !strings.Contains(srv.URL, "127.0.0.1") {
|
||||||
|
t.Skipf("the test server is not on loopback: %s", srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send reached a loopback address without complaining")
|
||||||
|
}
|
||||||
|
if reached {
|
||||||
|
t.Error("the request arrived: the wall let it through and only the answer failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscription(t *testing.T, endpoint string) Subscription {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(auth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Subscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
|
||||||
|
Auth: b64.EncodeToString(auth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVAPID(t *testing.T) VAPID {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return VAPID{Public: pub, Private: priv, Subject: "mailto:[email protected]"}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -104,3 +105,32 @@ func Client(timeout time.Duration) *http.Client {
|
|||||||
func AllowPrivate(timeout time.Duration) *http.Client {
|
func AllowPrivate(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{Timeout: timeout}
|
return &http.Client{Timeout: timeout}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckURL reports why an address must not be fetched, or "" if it may be.
|
||||||
|
//
|
||||||
|
// A convenience for saying no at the moment somebody types an address, rather
|
||||||
|
// than later when a request quietly fails. It is NOT the protection — a name can
|
||||||
|
// resolve one way now and another way when it is dialled, which is why Control
|
||||||
|
// exists and runs every time. Anything this rejects is certainly wrong; anything
|
||||||
|
// it accepts is merely not obviously wrong.
|
||||||
|
func CheckURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "that is not a URL"
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
|
||||||
|
return "that address is on this machine or its private network"
|
||||||
|
}
|
||||||
|
// A bare name is looked up once so an obvious "localhost" is caught here. A
|
||||||
|
// failure to resolve is not an error: the host may simply not exist yet, and
|
||||||
|
// refusing to save an address because DNS was slow would be its own bug.
|
||||||
|
if ips, err := net.LookupIP(host); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
if Blocked(ip) {
|
||||||
|
return "that address is on this machine or its private network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: "[email protected]",
|
||||||
|
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 != "[email protected]" {
|
||||||
|
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: "[email protected]",
|
||||||
|
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: "[email protected]", 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: "[email protected]", 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: "[email protected]", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||||
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
|
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
|
||||||
|
// The conflict target is (user_id, scope) since 0025 — before that the table
|
||||||
|
// held one row per person and the upsert renamed its scope, which is the bug
|
||||||
|
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
|
||||||
|
// against the schema this test is running on.
|
||||||
if _, err := db.ExecContext(context.Background(), `
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, 'gmail', ?, 0)
|
VALUES (?, 'gmail', ?, 0)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
|
||||||
userID, values); err != nil {
|
userID, values); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- A note can be put away without being thrown away.
|
||||||
|
--
|
||||||
|
-- The trash already exists and is the wrong tool. It says "this was a mistake,
|
||||||
|
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
|
||||||
|
-- an archive of twelve hundred imported notes is exactly the case where most of
|
||||||
|
-- them are worth keeping and almost none are worth looking at. Without somewhere
|
||||||
|
-- to put them, the list is the whole archive forever, and finding this week's note
|
||||||
|
-- means reading past a decade.
|
||||||
|
--
|
||||||
|
-- A column rather than a folder. Verdande already refused a second hierarchy for
|
||||||
|
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
|
||||||
|
-- note is current, and it has to be answerable next to `deleted_at`, which is the
|
||||||
|
-- other bit of the same kind.
|
||||||
|
--
|
||||||
|
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
|
||||||
|
-- one: "when was this put away" answers questions a flag cannot, and it costs the
|
||||||
|
-- same byte.
|
||||||
|
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
|
||||||
|
|
||||||
|
-- The list asks "not deleted, not archived, newest first" on every load, and with
|
||||||
|
-- twelve hundred notes that is the query that has to be quick.
|
||||||
|
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- One row per person *and scope*, which is what the table always meant.
|
||||||
|
--
|
||||||
|
-- `user_settings` was created with `user_id` as its whole primary key, and its
|
||||||
|
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
|
||||||
|
-- and whichever scope was written last was the only one that survived: saving an
|
||||||
|
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
|
||||||
|
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
|
||||||
|
-- row it landed on.
|
||||||
|
--
|
||||||
|
-- This was seen coming twice. Migration 0011 gave the instance's settings their
|
||||||
|
-- own table partly for this reason and wrote the warning down in as many words;
|
||||||
|
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
|
||||||
|
-- time". The third time was the calendar, which added a `calendar` scope beside
|
||||||
|
-- `ai` and `gmail` — and walked in.
|
||||||
|
--
|
||||||
|
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
|
||||||
|
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
|
||||||
|
-- left to warn about.
|
||||||
|
--
|
||||||
|
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
|
||||||
|
-- rows are copied as they are: each person has at most one, and it holds whatever
|
||||||
|
-- scope won last. What the earlier writes lost is already gone and cannot be
|
||||||
|
-- recovered here — the values were overwritten, not orphaned.
|
||||||
|
CREATE TABLE user_settings_new (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'general',
|
||||||
|
values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
|
||||||
|
SELECT user_id, scope, values_json, updated_at FROM user_settings;
|
||||||
|
|
||||||
|
DROP TABLE user_settings;
|
||||||
|
|
||||||
|
ALTER TABLE user_settings_new RENAME TO user_settings;
|
||||||
+154
-12
@@ -19,6 +19,17 @@ type Note struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
|
||||||
|
// Preview is the first of the body, for a list that shows one line of it.
|
||||||
|
//
|
||||||
|
// It exists so a list of twelve hundred notes does not have to carry twelve
|
||||||
|
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
|
||||||
|
// characters of each. When it is set, Body is deliberately empty — see
|
||||||
|
// handleListNotes. That is not tidiness but a guard: an editor handed a
|
||||||
|
// truncated body would save the truncation, and the note would be cut down to
|
||||||
|
// its own preview.
|
||||||
|
Preview string `json:"preview,omitempty"`
|
||||||
|
|
||||||
// Links is what the body points at, read out of the text on the way in. Never
|
// Links is what the body points at, read out of the text on the way in. Never
|
||||||
// set by a caller: it is derived, and a caller that set it would be describing
|
// set by a caller: it is derived, and a caller that set it would be describing
|
||||||
@@ -33,7 +44,16 @@ type NoteLink struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||||
created_at, updated_at, deleted_at`
|
created_at, updated_at, deleted_at, archived_at`
|
||||||
|
|
||||||
|
// The same columns, said with the table in front of them.
|
||||||
|
//
|
||||||
|
// Needed only by the search, which joins `notes_fts` — and that table carries its
|
||||||
|
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||||
|
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||||
|
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||||
|
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
|
||||||
|
notes.archived_at`
|
||||||
|
|
||||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||||
@@ -199,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
// shared, and a list that dropped it at that moment would make sharing look like
|
// shared, and a list that dropped it at that moment would make sharing look like
|
||||||
// deleting — which is exactly how it read before this existed.
|
// deleting — which is exactly how it read before this existed.
|
||||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
return db.notesWhere(ctx, `
|
return db.allNotes(ctx, userID, false)
|
||||||
WHERE deleted_at IS NULL
|
}
|
||||||
|
|
||||||
|
// ArchivedNotes is the same list, the other way round: what has been put away.
|
||||||
|
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
|
return db.allNotes(ctx, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
|
||||||
|
// Archived notes are left out of the ordinary list rather than mixed in and
|
||||||
|
// greyed: the point of putting something away is not seeing it.
|
||||||
|
state := "notes.archived_at IS NULL"
|
||||||
|
if archived {
|
||||||
|
state = "notes.archived_at IS NOT NULL"
|
||||||
|
}
|
||||||
|
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
|
WHERE `+state+` AND deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
(project_id IS NULL AND created_by = ?)
|
(project_id IS NULL AND created_by = ?)
|
||||||
OR project_id IN (
|
OR project_id IN (
|
||||||
@@ -238,13 +273,89 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
|||||||
if expr == "" {
|
if expr == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
|
// Ordered by how well it matched, not by when it was last touched.
|
||||||
// on it would work and read the whole index to do it.
|
//
|
||||||
return db.notesWhere(ctx, `
|
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||||
WHERE deleted_at IS NULL
|
// useless at twelve hundred: every one of them was imported the same evening,
|
||||||
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
|
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
||||||
ORDER BY updated_at DESC
|
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
||||||
|
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||||
|
// that does not work, because from where the person is sitting it is one.
|
||||||
|
//
|
||||||
|
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||||
|
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||||
|
// carries the same text transliterated, so it must weigh the same as the body
|
||||||
|
// or a Danish match would outrank an exact one.
|
||||||
|
//
|
||||||
|
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||||
|
// subquery throws it away.
|
||||||
|
found, err := db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
|
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||||
|
WHERE notes.deleted_at IS NULL
|
||||||
|
AND notes_fts MATCH ?
|
||||||
|
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||||
LIMIT ?`, expr, limit)
|
LIMIT ?`, expr, limit)
|
||||||
|
if err != nil || len(found) > 0 {
|
||||||
|
return found, err
|
||||||
|
}
|
||||||
|
return db.searchNotesInside(ctx, query, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchNotesInside finds a word that lives *inside* another word.
|
||||||
|
//
|
||||||
|
// An index is built of tokens, and a token is what a tokenizer decided was a
|
||||||
|
// word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||||
|
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
|
||||||
|
// gets nothing. Reported as "søgning har ikke det store held", which from where
|
||||||
|
// the person is sitting is exactly what it is: the note is right there, and the
|
||||||
|
// search says there is nothing.
|
||||||
|
//
|
||||||
|
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
|
||||||
|
// cannot, because it is not a prefix of anything — and the two are joined with AND,
|
||||||
|
// so the half that works is thrown away by the half that cannot.
|
||||||
|
//
|
||||||
|
// This is not an exotic case in a notes app. A decade of notes is full of
|
||||||
|
// commands, identifiers and file names, and every one of them is several words the
|
||||||
|
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
|
||||||
|
//
|
||||||
|
// So: the index answers first, and when it has nothing, the text is read. Every
|
||||||
|
// term has to appear somewhere in the note, anywhere, in any word.
|
||||||
|
//
|
||||||
|
// # What it costs, and when to stop doing it this way
|
||||||
|
//
|
||||||
|
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
|
||||||
|
// hundred notes that is four megabytes and a millisecond or two, and it only runs
|
||||||
|
// when the fast path already came back empty — which is the search that was about
|
||||||
|
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
|
||||||
|
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
|
||||||
|
//
|
||||||
|
// It does not rank. bm25 needs a match to rank, and there is no match here — that
|
||||||
|
// is the whole situation. The rows are the most recently touched first, and the
|
||||||
|
// set is small, because the index already said there was nothing in it.
|
||||||
|
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||||
|
terms := SearchTerms(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
|
||||||
|
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
|
||||||
|
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
|
||||||
|
// answer to "inventory".
|
||||||
|
//
|
||||||
|
// The needle is safe to interpolate into a pattern because `tokenize` splits on
|
||||||
|
// everything that is not a letter or a digit: a term cannot contain % or _, so
|
||||||
|
// there is no wildcard for somebody to smuggle in. It is still a bound
|
||||||
|
// parameter — that is about SQL, and this is about LIKE.
|
||||||
|
where := "WHERE deleted_at IS NULL"
|
||||||
|
args := make([]any, 0, len(terms)+1)
|
||||||
|
for _, term := range terms {
|
||||||
|
where += " AND fold LIKE ?"
|
||||||
|
args = append(args, "%"+FoldDanish(term)+"%")
|
||||||
|
}
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNoteTimes writes the dates a note had somewhere else.
|
// SetNoteTimes writes the dates a note had somewhere else.
|
||||||
@@ -278,6 +389,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNoteArchived puts a note away, or takes it back out.
|
||||||
|
//
|
||||||
|
// Separate from DeleteNote because they mean different things and the difference
|
||||||
|
// is the whole feature: the trash says "this was a mistake, and in thirty days it
|
||||||
|
// is gone", and the archive says "this is finished, and I want to keep it". A
|
||||||
|
// twelve-hundred-note import is mostly the second, and without somewhere to put
|
||||||
|
// them the list is the whole archive forever.
|
||||||
|
//
|
||||||
|
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
|
||||||
|
// sorted by when things were last written should not be reordered by somebody
|
||||||
|
// tidying up.
|
||||||
|
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
|
||||||
|
var at any
|
||||||
|
if archived {
|
||||||
|
at = time.Now().Unix()
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
@@ -295,7 +427,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
|
return db.notesJoin(ctx, noteColumns, where, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notesJoin is notesWhere with the column list spelled out, for the one caller
|
||||||
|
// that joins another table and must therefore say which `id` it means.
|
||||||
|
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -335,10 +473,10 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var deleted sql.NullInt64
|
var deleted, archived sql.NullInt64
|
||||||
|
|
||||||
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
||||||
&n.Pinned, &created, &updated, &deleted); err != nil {
|
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
n.ProjectID = projectID.String
|
n.ProjectID = projectID.String
|
||||||
@@ -349,5 +487,9 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(deleted.Int64, 0)
|
t := time.Unix(deleted.Int64, 0)
|
||||||
n.DeletedAt = &t
|
n.DeletedAt = &t
|
||||||
}
|
}
|
||||||
|
if archived.Valid {
|
||||||
|
t := time.Unix(archived.Int64, 0)
|
||||||
|
n.ArchivedAt = &t
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,3 +333,103 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
|||||||
t.Error("a note title did not match itself")
|
t.Error("a note title did not match itself")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En søgning skal svare med det, der passer bedst, øverst.
|
||||||
|
//
|
||||||
|
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
||||||
|
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
||||||
|
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
||||||
|
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
||||||
|
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
||||||
|
// fra brugerens plads er præcis dét.
|
||||||
|
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
||||||
|
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
||||||
|
for _, body := range []string{
|
||||||
|
"# Tesla Model Y\n\nBilen selv.\n",
|
||||||
|
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
||||||
|
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
||||||
|
} {
|
||||||
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 3 {
|
||||||
|
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
||||||
|
}
|
||||||
|
if found[0].Title != "Tesla Model Y" {
|
||||||
|
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word inside a word.
|
||||||
|
//
|
||||||
|
// Reported as "søgning har ikke det store held": a note holding
|
||||||
|
// `gamerule keepInventory true` could not be found by typing "keep inventory".
|
||||||
|
// The index is built of tokens, and `keepInventory` is one token — so the two
|
||||||
|
// words are nowhere, and the search says there is nothing while the note is right
|
||||||
|
// there.
|
||||||
|
//
|
||||||
|
// A decade of notes is full of these: commands, identifiers, file names. Every one
|
||||||
|
// of them is several words the index thinks is one.
|
||||||
|
func TestAWordInsideAWordIsStillFound(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A second note, so a fallback that simply returns everything would be caught.
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Indkøb\n\nKaffe og filtre",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range []string{
|
||||||
|
"keep inventory", // the two words, which is how somebody looks for it
|
||||||
|
"inventory", // the tail on its own — no prefix query can reach this
|
||||||
|
"keepinventory", // written as one, which the index does hold
|
||||||
|
"KEEP INVENTORY", // and case is not a question anybody is asking
|
||||||
|
"vacuum", // not in either note
|
||||||
|
} {
|
||||||
|
found, err := db.SearchNotes(ctx, q, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", q, err)
|
||||||
|
}
|
||||||
|
want := 1
|
||||||
|
if q == "vacuum" {
|
||||||
|
want = 0
|
||||||
|
}
|
||||||
|
if len(found) != want {
|
||||||
|
t.Errorf("%q found %d notes, want %d", q, len(found), want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want == 1 && found[0].Title != "Minecraft Bedrock" {
|
||||||
|
t.Errorf("%q found %q", q, found[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fast path is still the fast path: a word the index does hold must not be
|
||||||
|
// answered by the scan, or the ranking that was built for twelve hundred notes
|
||||||
|
// is quietly gone.
|
||||||
|
found, err := db.SearchNotes(ctx, "Bedrock", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("an ordinary word found %d notes", len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,11 +140,17 @@ func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) e
|
|||||||
if sub.ID == "" {
|
if sub.ID == "" {
|
||||||
sub.ID = NewID()
|
sub.ID = NewID()
|
||||||
}
|
}
|
||||||
|
// The upsert no longer moves the row between accounts.
|
||||||
|
//
|
||||||
|
// `user_id = excluded.user_id` was in the DO UPDATE, so anybody who learned
|
||||||
|
// another person's endpoint could claim it by subscribing with it: the
|
||||||
|
// victim's device would then receive the claimant's notifications and none of
|
||||||
|
// its own. A re-subscribe from the same person still refreshes the keys, which
|
||||||
|
// is what the upsert is actually for.
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
|
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (endpoint) DO UPDATE SET
|
ON CONFLICT (endpoint) DO UPDATE SET
|
||||||
user_id = excluded.user_id,
|
|
||||||
p256dh = excluded.p256dh,
|
p256dh = excluded.p256dh,
|
||||||
auth = excluded.auth,
|
auth = excluded.auth,
|
||||||
user_agent = excluded.user_agent,
|
user_agent = excluded.user_agent,
|
||||||
@@ -175,7 +181,21 @@ func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushS
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error {
|
// DeletePushSubscription removes one of this person's devices.
|
||||||
|
//
|
||||||
|
// Scoped by user as well as by endpoint. It was endpoint alone, which meant
|
||||||
|
// anybody who learned somebody else's endpoint could silently unsubscribe their
|
||||||
|
// phone — and the endpoint is the only thing about a subscription that ever
|
||||||
|
// leaves the browser.
|
||||||
|
func (db *DB) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, endpoint, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropPushEndpoint removes a subscription the push service has declared dead.
|
||||||
|
// Unexported so the only *request* path to a delete stays the scoped one.
|
||||||
|
func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
||||||
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -186,7 +206,10 @@ func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error
|
|||||||
// silently unsubscribing somebody's phone.
|
// silently unsubscribing somebody's phone.
|
||||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||||
if permanent {
|
if permanent {
|
||||||
return db.DeletePushSubscription(ctx, endpoint)
|
// Not scoped by user, and that is right here: this is the push service
|
||||||
|
// saying the endpoint is gone, not a person asking for something. There is
|
||||||
|
// no caller to check against — the caller is the delivery job.
|
||||||
|
return db.dropPushEndpoint(ctx, endpoint)
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
|
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
|
||||||
@@ -289,6 +312,11 @@ var secretFields = map[string]bool{
|
|||||||
"access_token": true,
|
"access_token": true,
|
||||||
"password": true,
|
"password": true,
|
||||||
"client_secret": true,
|
"client_secret": true,
|
||||||
|
// The AI provider's key. Missed when this list was written, and it is exactly
|
||||||
|
// the same kind of thing as the rest: a live credential, in a settings blob,
|
||||||
|
// in a database anybody with a backup file can read. Existing rows convert
|
||||||
|
// themselves the next time the settings are saved.
|
||||||
|
"api_key": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// seal returns a copy of values with the secret fields sealed. Without a key it
|
// seal returns a copy of values with the secret fields sealed. Without a key it
|
||||||
@@ -363,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
|
||||||
|
// alone and set `scope = excluded.scope` — so writing one scope did not add a
|
||||||
|
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
|
||||||
|
// `calendar` settings each deleted the other two, and the write reported
|
||||||
|
// success every time.
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id, scope) DO UPDATE SET
|
||||||
scope = excluded.scope,
|
|
||||||
values_json = excluded.values_json,
|
values_json = excluded.values_json,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
userID, scope, string(raw), time.Now().Unix())
|
userID, scope, string(raw), time.Now().Unix())
|
||||||
|
|||||||
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderSections writes the order of one project's sections, top to bottom.
|
||||||
|
//
|
||||||
|
// The whole list at once, the same as ReorderProjects and for the same reasons: a
|
||||||
|
// project has a handful of sections, so writing 0, 1, 2 … is one small
|
||||||
|
// transaction that cannot run out of precision, and a list is impossible to get
|
||||||
|
// into the half-ordered state a sequence of individual moves leaves behind when
|
||||||
|
// one of them fails.
|
||||||
|
//
|
||||||
|
// Scoped to the project rather than to a user. A section has no owner of its own —
|
||||||
|
// it belongs to a project, and whether the caller may rearrange it is a question
|
||||||
|
// about that project, which the handler has already asked. Ids belonging to some
|
||||||
|
// other project are skipped by the WHERE clause rather than refused, so a stale
|
||||||
|
// list from a client that has just moved a section elsewhere cannot rearrange a
|
||||||
|
// project the caller was never looking at.
|
||||||
|
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sections SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||||
|
float64(i), now, id, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SectionByName resolves a "/name" from quick add within one project.
|
// SectionByName resolves a "/name" from quick add within one project.
|
||||||
//
|
//
|
||||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ func FoldDanish(s string) string {
|
|||||||
return r.Replace(s)
|
return r.Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ceilings on a search. See MatchExprOver for why they exist; both are far
|
||||||
|
// above anything a person types and far below anything that costs.
|
||||||
|
const (
|
||||||
|
maxSearchInput = 512
|
||||||
|
maxSearchTerms = 16
|
||||||
|
)
|
||||||
|
|
||||||
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
|
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
|
||||||
// expression.
|
// expression.
|
||||||
//
|
//
|
||||||
@@ -51,6 +58,23 @@ func MatchExprOver(query string, columns ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capped, because the expression is built from the input and then run inside
|
||||||
|
// SQLite on one of a handful of pooled connections.
|
||||||
|
//
|
||||||
|
// Every term becomes roughly fifty bytes of FTS5, and every one of them is a
|
||||||
|
// prefix query — which is what makes ⌘K feel like it searches as you type, and
|
||||||
|
// also what makes each one expensive. A quarter of a megabyte pasted into the
|
||||||
|
// box became a two-megabyte MATCH expression and half a minute inside SQLite,
|
||||||
|
// holding a connection the whole time. A few of those at once and the database
|
||||||
|
// is unavailable to everybody on the instance, logins included.
|
||||||
|
//
|
||||||
|
// Sixteen terms is far past anything a person types into a search box and
|
||||||
|
// nowhere near enough to hurt. Silently, because a message about a limit
|
||||||
|
// nobody has reached on purpose is noise.
|
||||||
|
if len(terms) > maxSearchTerms {
|
||||||
|
terms = terms[:maxSearchTerms]
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i, term := range terms {
|
for i, term := range terms {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -66,10 +90,29 @@ func MatchExprOver(query string, columns ...string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTerms is the words a query asks about, capped the same way the MATCH
|
||||||
|
// expression is.
|
||||||
|
//
|
||||||
|
// Exported because two searches need the same list: the FTS expression is built
|
||||||
|
// from it, and so is the substring arm that finds a word living inside another
|
||||||
|
// word. Two tokenizers would be two answers to "what did they type".
|
||||||
|
func SearchTerms(query string) []string {
|
||||||
|
terms := tokenize(query)
|
||||||
|
if len(terms) > maxSearchTerms {
|
||||||
|
terms = terms[:maxSearchTerms]
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
// tokenize splits on everything that is not a letter or a digit. Splitting on
|
// tokenize splits on everything that is not a letter or a digit. Splitting on
|
||||||
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
||||||
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
||||||
func tokenize(query string) []string {
|
func tokenize(query string) []string {
|
||||||
|
// Trimmed before it is split, so a megabyte of text is not walked rune by rune
|
||||||
|
// only to have all but sixteen of the results thrown away.
|
||||||
|
if len(query) > maxSearchInput {
|
||||||
|
query = query[:maxSearchInput]
|
||||||
|
}
|
||||||
fields := strings.FieldsFunc(query, func(r rune) bool {
|
fields := strings.FieldsFunc(query, func(r rune) bool {
|
||||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -124,3 +124,156 @@ func sealedStore(t *testing.T) (*DB, string) {
|
|||||||
}
|
}
|
||||||
return db, user.ID
|
return db, user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three integrations, one person, and none of them may delete the others.
|
||||||
|
//
|
||||||
|
// `user_settings` had `user_id` as its whole primary key and an upsert that
|
||||||
|
// rewrote `scope` on conflict, so every person had exactly one settings row and
|
||||||
|
// the last write won the lot: saving an AI provider deleted the Gmail connection,
|
||||||
|
// and connecting a calendar deleted the AI provider. The write reported success
|
||||||
|
// each time, which is why it was reported as "the AI settings are not saved"
|
||||||
|
// rather than as "connecting the calendar deleted them".
|
||||||
|
//
|
||||||
|
// Written against the store rather than the handlers, because the shape was the
|
||||||
|
// bug: every caller was correct on its own.
|
||||||
|
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
want := map[string]map[string]any{
|
||||||
|
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
|
||||||
|
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
|
||||||
|
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
|
||||||
|
}
|
||||||
|
// In this order on purpose: the AI settings are written first and read last,
|
||||||
|
// so a scope that steals the row is caught by the one written earliest.
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
|
||||||
|
t.Fatalf("save %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
got, err := db.UserSettings(ctx, userID, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
for key, value := range want[scope] {
|
||||||
|
if got[key] != value {
|
||||||
|
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a second write to one scope updates it rather than adding another row —
|
||||||
|
// the upsert still has to upsert.
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rows int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 3 {
|
||||||
|
t.Errorf("the person has %d settings rows, want 3", rows)
|
||||||
|
}
|
||||||
|
back, err := db.UserSettings(ctx, userID, "ai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back["provider"] != "openai" {
|
||||||
|
t.Errorf("the second write did not land: %v", back["provider"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upgrade path, which is the one nobody runs: an existing database with a row
|
||||||
|
// in the old shape, opened by a binary that carries 0025.
|
||||||
|
//
|
||||||
|
// Every other test here starts empty and runs all the migrations in order, which
|
||||||
|
// exercises the migration's SQL but not its *situation*. The situation is a table
|
||||||
|
// that already holds somebody's settings, and the question is whether they come
|
||||||
|
// through it and whether a second scope can be written afterwards.
|
||||||
|
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
user := &User{Email: "[email protected]", Name: "Kristian"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
|
||||||
|
// key, and 0025 not yet applied.
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`PRAGMA foreign_keys = OFF`,
|
||||||
|
`CREATE TABLE us_old (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'general',
|
||||||
|
values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
|
||||||
|
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
|
||||||
|
`DROP TABLE user_settings`,
|
||||||
|
`ALTER TABLE us_old RENAME TO user_settings`,
|
||||||
|
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
|
||||||
|
`PRAGMA foreign_keys = ON`,
|
||||||
|
} {
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
t.Fatalf("rewind (%s): %v", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Gmail connection, written the way the old upsert wrote one.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
|
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And now the new binary opens it.
|
||||||
|
db, err = Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the upgrade refused to open the database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gmail["label"] != "Verdande" {
|
||||||
|
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The thing that was reported: set up the AI provider, and have it still be
|
||||||
|
// there afterwards.
|
||||||
|
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
|
||||||
|
"provider": "anthropic", "model": "claude-sonnet-5",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("saving the AI provider after the upgrade: %v", err)
|
||||||
|
}
|
||||||
|
ai, err := db.UserSettings(ctx, user.ID, "ai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ai["provider"] != "anthropic" {
|
||||||
|
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
|
||||||
|
}
|
||||||
|
again, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again["label"] != "Verdande" {
|
||||||
|
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-1
@@ -166,15 +166,42 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
// labels are short, and their names are typed by the person searching for
|
// labels are short, and their names are typed by the person searching for
|
||||||
// them, so there is no spelling to be generous about. The diacritic
|
// them, so there is no spelling to be generous about. The diacritic
|
||||||
// folding that matters for task text does not earn a second index here.
|
// folding that matters for task text does not earn a second index here.
|
||||||
|
//
|
||||||
|
// The third arm is a word inside a word. An index is built of tokens, and
|
||||||
|
// `SpeedAdmin` is one of them — so "speed admin" asks for two words that are
|
||||||
|
// nowhere. A prefix query gets half of it (`speed*` matches, `admin*` cannot)
|
||||||
|
// and the two are joined with AND, so the half that works is thrown away.
|
||||||
|
//
|
||||||
|
// Notes got the same arm, and for the same reason. Having it in one of the
|
||||||
|
// two would be worse than having it in neither: "it finds it in my notes but
|
||||||
|
// not in my tasks" is a bug report waiting, and the answer to it would be
|
||||||
|
// this line.
|
||||||
|
//
|
||||||
|
// It scans, and it only ever runs when somebody typed something in the box —
|
||||||
|
// Today and Kommende do not set `Search`. Every term has to appear, so the
|
||||||
|
// arms are ANDed with each other while the whole thing is ORed with the two
|
||||||
|
// above.
|
||||||
|
inside := make([]string, 0, maxSearchTerms)
|
||||||
|
insideArgs := make([]any, 0, maxSearchTerms)
|
||||||
|
for _, term := range SearchTerms(f.Search) {
|
||||||
|
inside = append(inside, "t.fold LIKE ?")
|
||||||
|
insideArgs = append(insideArgs, "%"+FoldDanish(term)+"%")
|
||||||
|
}
|
||||||
|
text := ""
|
||||||
|
if len(inside) > 0 {
|
||||||
|
text = " OR (" + strings.Join(inside, " AND ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
where = append(where, `(
|
where = append(where, `(
|
||||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||||
OR t.id IN (
|
OR t.id IN (
|
||||||
SELECT tl.task_id FROM task_labels tl
|
SELECT tl.task_id FROM task_labels tl
|
||||||
JOIN labels l ON l.id = tl.label_id
|
JOIN labels l ON l.id = tl.label_id
|
||||||
WHERE lower(l.name) LIKE ?
|
WHERE lower(l.name) LIKE ?
|
||||||
)
|
)`+text+`
|
||||||
)`)
|
)`)
|
||||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||||
|
args = append(args, insideArgs...)
|
||||||
}
|
}
|
||||||
if f.FilterSQL != "" {
|
if f.FilterSQL != "" {
|
||||||
where = append(where, "("+f.FilterSQL+")")
|
where = append(where, "("+f.FilterSQL+")")
|
||||||
|
|||||||
+19
-1
@@ -129,7 +129,14 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opened on the way out. A row written before the key existed is returned as
|
||||||
|
// itself — see secret.Unseal — so this does not need a migration; a secret
|
||||||
|
// converts the next time it is written.
|
||||||
|
if plain, err := db.unsealValue(secret.String); err == nil {
|
||||||
|
u.TOTPSecret = plain
|
||||||
|
} else {
|
||||||
u.TOTPSecret = secret.String
|
u.TOTPSecret = secret.String
|
||||||
|
}
|
||||||
u.TOTPEnabled = totpEnabled == 1
|
u.TOTPEnabled = totpEnabled == 1
|
||||||
u.IsAdmin = isAdmin == 1
|
u.IsAdmin = isAdmin == 1
|
||||||
// A malformed value is read as "nothing folded" rather than as an error: the
|
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||||
@@ -433,9 +440,20 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||||
|
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||||
|
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||||
|
// generate valid codes for every account on the instance — which turns the
|
||||||
|
// second factor from a defence into a file somebody can take home.
|
||||||
|
//
|
||||||
|
// It was missed when internal/secret was written, along with three other
|
||||||
|
// values; see SECURITY.md.
|
||||||
var value any
|
var value any
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
value = secret
|
sealed, err := db.sealValue(secret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = sealed
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
|||||||
+589
-74
@@ -6,12 +6,12 @@
|
|||||||
<title>verdande — self-hosted tasks, shared</title>
|
<title>verdande — self-hosted tasks, shared</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#111312" />
|
<meta name="theme-color" content="#111312" />
|
||||||
|
|
||||||
<meta property="og:title" content="verdande" />
|
<meta property="og:title" content="verdande" />
|
||||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="screenshots/today.webp" />
|
<meta property="og:image" content="screenshots/today.webp" />
|
||||||
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
||||||
@@ -76,10 +76,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
/* In the bar now rather than floating over the corner. Fixed, it sat
|
||||||
top: 1rem;
|
on top of whatever happened to scroll under it — on a narrow window
|
||||||
right: 1rem;
|
that was the first screenshot's own corner. */
|
||||||
z-index: 10;
|
flex: none;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -141,10 +141,86 @@
|
|||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- the bar that stays -------------------------------------------
|
||||||
|
|
||||||
|
A page this long had its only way out at the very top: somebody who
|
||||||
|
read as far as "What it will not do" had to scroll back past nine
|
||||||
|
screenshots to reach the documentation. The bar is the way out from
|
||||||
|
wherever you are.
|
||||||
|
|
||||||
|
`sticky` and not `fixed`: sticky keeps its space in the flow, so
|
||||||
|
nothing below has to be pushed down by a number that then has to be
|
||||||
|
kept in step with the bar's own height.
|
||||||
|
|
||||||
|
Full width, with the wrap *inside* it. Put the other way round, the
|
||||||
|
background would end where the column ends and the page would scroll
|
||||||
|
past it on both sides. */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--ground);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand .rune {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The three in-page links go first on a narrow screen: they are the ones
|
||||||
|
a phone can reach by scrolling anyway. Documentation and Source cannot
|
||||||
|
be reached any other way, so they stay. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bar-links .inpage {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or the bar covers the heading you just jumped to. */
|
||||||
|
:target {
|
||||||
|
scroll-margin-top: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- hero --- */
|
/* --- hero --- */
|
||||||
|
|
||||||
header {
|
.hero {
|
||||||
padding: 6rem 0 4rem;
|
padding: 5rem 0 3.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.norn {
|
.norn {
|
||||||
margin: 2rem auto 0;
|
margin: 0 auto 1.75rem;
|
||||||
max-width: 34rem;
|
max-width: 34rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -289,44 +365,104 @@
|
|||||||
|
|
||||||
/* --- features --- */
|
/* --- features --- */
|
||||||
|
|
||||||
|
/* A hairline between sections rather than under every heading: the page
|
||||||
|
is one column, and the line says where one subject stops. */
|
||||||
section {
|
section {
|
||||||
padding: 3rem 0;
|
padding: 3.5rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A short accent rule in front of every h2. The sections had nothing to
|
||||||
|
mark them but their own words, and at a glance the page read as one
|
||||||
|
long run of paragraphs. */
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateY(-0.35em);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three groups rather than eighteen cards in a row. Grouped, the list is
|
||||||
|
read as three answers — what it does, what it talks to, what it
|
||||||
|
promises — and any of the three can be skipped. */
|
||||||
|
.group-label {
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 560;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards with their own edges, not cells in one table.
|
||||||
|
The table shared a hairline grid, which meant the last row had to be
|
||||||
|
full or it was a hole in the drawing — and it was a hole, for as long
|
||||||
|
as there were ten of them. A card that stands alone on its row is a
|
||||||
|
card; an empty cell is a mistake. */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
gap: 1px;
|
gap: 0.875rem;
|
||||||
background: var(--line);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
background: var(--ground);
|
background: var(--surface);
|
||||||
padding: 1.5rem;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.125rem 1.25rem 1.25rem;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell h3 {
|
.cell h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.4375rem;
|
||||||
font-size: 1rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell p {
|
.cell p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card that has something to admit rather than something to offer.
|
||||||
|
It is not a footnote and it is not fine print: it is the same size as
|
||||||
|
everything else, and it says the same thing the README says. */
|
||||||
|
.cell .caveat {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- install --- */
|
/* --- install --- */
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@@ -374,6 +510,168 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Four facts under the buttons, in the place where a landing page usually
|
||||||
|
puts logos it has not earned. They are the four questions somebody
|
||||||
|
self-hosting asks before anything else. */
|
||||||
|
ul.facts {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The separator belongs between two facts, not after the last one — so it
|
||||||
|
is drawn by the item that follows it. */
|
||||||
|
ul.facts li + li::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the row wraps, and a separator drawn by the item that follows
|
||||||
|
it lands at the *start* of the new line — a dot with nothing before it.
|
||||||
|
No separators there; the wrap is the separation. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
ul.facts {
|
||||||
|
gap: 0.375rem 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li + li::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two ways to run it, side by side, because they are alternatives rather
|
||||||
|
than steps. Stacked, the second read as what you do after the first. */
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunk rather than raised: inside a card, a block in --surface would be
|
||||||
|
the same colour as the card it sits in. */
|
||||||
|
.panel pre {
|
||||||
|
background: var(--ground);
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Versionsnummeret i sin egen søjle, så øjet kan løbe ned ad dem uden at
|
||||||
|
læse sætningerne. `ch` frem for pixels: søjlen er lige så bred som de
|
||||||
|
tal, der står i den, og den følger med, hvis skriften skifter. */
|
||||||
|
ul.lately {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.lately li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5ch 1fr;
|
||||||
|
gap: 0 1rem;
|
||||||
|
padding: 0.875rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.lately .ver {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
/* Tallet står på linje med den første tekstlinje og ikke over den. */
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
ul.lately li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Risikoen står i kort, ikke i småt.
|
||||||
|
Et forbehold sat med mindre skrift end resten er et forbehold, der er
|
||||||
|
skrevet for at kunne peges på bagefter — og det er den ene ting, denne
|
||||||
|
side ikke skal gøre. Samme størrelse som alt andet. */
|
||||||
|
.risk {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The non-goals were one paragraph of seven sentences, which is the shape
|
||||||
|
nobody reads. Each is its own line now, and they can be counted. */
|
||||||
|
ul.nogo {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.5rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.demo-note {
|
.demo-note {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
@@ -524,27 +822,38 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="wrap bar">
|
||||||
|
<a class="bar-brand" href="#top">
|
||||||
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||||
|
verdande
|
||||||
|
</a>
|
||||||
|
<nav class="bar-links" aria-label="Sections">
|
||||||
|
<a class="inpage" href="#screens">Screens</a>
|
||||||
|
<a class="inpage" href="#features">Features</a>
|
||||||
|
<a class="inpage" href="#running">Running it</a>
|
||||||
|
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
|
<a href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
||||||
<span aria-hidden="true">◐</span>
|
<span aria-hidden="true">◐</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wrap">
|
</nav>
|
||||||
<header>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap" id="top">
|
||||||
|
<header class="hero">
|
||||||
<span class="mark" aria-hidden="true">ᚹ</span>
|
<span class="mark" aria-hidden="true">ᚹ</span>
|
||||||
<h1>verdande</h1>
|
<h1>verdande</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Self-hosted tasks and projects, shared with the people you share the work with.
|
Self-hosted tasks, notes and projects, shared with the people you share the work
|
||||||
One binary, SQLite inside it.
|
with. One binary, SQLite inside it.
|
||||||
</p>
|
</p>
|
||||||
<p class="rune-note">
|
<p class="rune-note">
|
||||||
Built to be deployed as a <strong>Rune</strong> in
|
Built to be deployed as a <strong>Rune</strong> in
|
||||||
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
||||||
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
||||||
</p>
|
</p>
|
||||||
<p class="norn">
|
|
||||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
|
||||||
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
|
||||||
in the middle of. Which is what a to-do list is.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||||
@@ -553,6 +862,14 @@
|
|||||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul class="facts">
|
||||||
|
<li>One Go binary</li>
|
||||||
|
<li>SQLite inside it</li>
|
||||||
|
<li>amd64 and arm64</li>
|
||||||
|
<li>No account anywhere but yours</li>
|
||||||
|
<li>MIT</li>
|
||||||
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
||||||
@@ -584,7 +901,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section id="screens">
|
||||||
<h2>What it looks like</h2>
|
<h2>What it looks like</h2>
|
||||||
<figure class="shot only-dark">
|
<figure class="shot only-dark">
|
||||||
<img
|
<img
|
||||||
@@ -669,11 +986,43 @@
|
|||||||
/>
|
/>
|
||||||
<figcaption>What has been done on the server, across every project.</figcaption>
|
<figcaption>What has been done on the server, across every project.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/delegated.webp"
|
||||||
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-source.webp"
|
||||||
|
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/settings.webp"
|
||||||
|
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="features">
|
||||||
<h2>What it does</h2>
|
<h2>What it does</h2>
|
||||||
|
|
||||||
|
<p class="group-label">Doing the work</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Reads what you type</h3>
|
<h3>Reads what you type</h3>
|
||||||
@@ -683,20 +1032,54 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Shared, properly</h3>
|
<h3>Nothing to remember</h3>
|
||||||
<p>
|
<p>
|
||||||
Owner, editor and viewer roles. People outside your instance join by
|
Start typing anywhere and you are writing a task, with the letter you
|
||||||
invite link. Changes appear live.
|
pressed already in the field. <code>⌘K</code> goes anywhere else.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Search that reads Danish</h3>
|
||||||
|
<p>
|
||||||
|
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
|
||||||
|
consider <code>ø</code> an accented <code>o</code>, so a search that
|
||||||
|
leans on that alone is broken for the people this was built for.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Repeats, and says so in words</h3>
|
||||||
|
<p>
|
||||||
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||||
|
screen. Reminders are absolute or relative, and go out to the devices
|
||||||
|
you have allowed them on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Notes, beside the work</h3>
|
<h3>Notes, beside the work</h3>
|
||||||
<p>
|
<p>
|
||||||
Rich text to write in, Markdown on disk. File a note in a project and
|
Rich text to write in, Markdown on disk — and a source view to see it.
|
||||||
everybody in it can read it; <code>#project</code> and
|
Images are pasted or dragged in. <code>#project</code> and
|
||||||
<code>[[another note]]</code> are links, and a task shows what has been
|
<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
|
written about it. Archive what is done with it; the bin is for mistakes.
|
||||||
shape, so nothing is trapped here.
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Waiting on others</h3>
|
||||||
|
<p>
|
||||||
|
What you have handed to somebody else, on its own page, grouped under
|
||||||
|
the person who has it. The thing a shared list stops being able to tell
|
||||||
|
you the moment it is shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Connected to what you already use</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Shared, properly</h3>
|
||||||
|
<p>
|
||||||
|
Owner, editor and viewer roles. People outside your instance join by
|
||||||
|
invite link. Changes appear live.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -706,6 +1089,27 @@
|
|||||||
here. Not a read-only feed pretending.
|
here. Not a read-only feed pretending.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>A calendar of its own</h3>
|
||||||
|
<p>
|
||||||
|
A month or a week of what is due, and a secret ICS address to subscribe
|
||||||
|
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
|
||||||
|
laid over the top, read-only.
|
||||||
|
</p>
|
||||||
|
<p class="caveat">
|
||||||
|
The Google half is written to Google's documentation and has not yet
|
||||||
|
been run against Google. The feed and CalDAV have.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Your mail, as an inbox</h3>
|
||||||
|
<p>
|
||||||
|
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||||
|
it becomes a task. Forward one to your own address instead and the
|
||||||
|
subject is parsed like anything else you type. One way: unstarring it
|
||||||
|
later does nothing to the task you made.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Claude, connected</h3>
|
<h3>Claude, connected</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -714,35 +1118,39 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Your mail, as an inbox</h3>
|
<h3>AI only if you ask for it</h3>
|
||||||
<p>
|
<p>
|
||||||
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
Off by default, your own key, and your choice of Anthropic, OpenAI,
|
||||||
a task. Forward one to your own address instead and the subject is
|
Google or something local. Nothing leaves the server until you have
|
||||||
parsed like anything else you type. One way: unstarring it later does
|
filled that in, and everything works without it.
|
||||||
nothing to the task you made.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
|
||||||
<h3>Nothing to remember</h3>
|
|
||||||
<p>
|
|
||||||
Start typing anywhere and you are writing a task, with the letter you
|
|
||||||
pressed already in the field. Sign in with a passkey and there is no
|
|
||||||
password to forget either.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Yours, and checkable</p>
|
||||||
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Yours to leave</h3>
|
<h3>More than one way in</h3>
|
||||||
<p>
|
<p>
|
||||||
Import from Todoist, export back to it, or take everything as JSON. The
|
Passkeys, or a password hashed with Argon2id and a second factor with
|
||||||
round trip is tested, not promised.
|
recovery codes. Every device you are signed in on is listed, and any of
|
||||||
|
them can be signed out from here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Keys stay behind</h3>
|
<h3>Keys stay behind</h3>
|
||||||
<p>
|
<p>
|
||||||
Mail tokens and mailbox passwords are encrypted in the database, and the
|
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
|
||||||
key lives in a file beside it — not inside it. So a backup you download,
|
encrypted in the database, and the key lives in a file beside it — not
|
||||||
or one that ends up somewhere it should not, does not open your mail.
|
inside it. So a backup you download, or one that ends up somewhere it
|
||||||
|
should not, does not open your mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Nobody signs themselves up</h3>
|
||||||
|
<p>
|
||||||
|
The first account is the administrator, and after that people arrive by
|
||||||
|
invitation. An address on the internet is not an open door.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -753,50 +1161,157 @@
|
|||||||
instance's record, not the person's.
|
instance's record, not the person's.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Yours to leave</h3>
|
||||||
|
<p>
|
||||||
|
Import from Todoist, export back to it, take everything as JSON, or
|
||||||
|
hand the notes a zip of Markdown and get the same zip back out. The
|
||||||
|
round trip is tested, not promised.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>It counts itself, out loud</h3>
|
||||||
|
<p>
|
||||||
|
Once a day an instance says it exists — a random id, the version, and
|
||||||
|
nothing else. No tasks, no names, no addresses. It is on to begin with,
|
||||||
|
the settings page prints the exact payload, and one switch stops it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="recently">
|
||||||
|
<h2>Lately</h2>
|
||||||
|
<p class="muted">
|
||||||
|
A landing page can say a project is alive; a list of what landed says it
|
||||||
|
better. This is not a changelog — every release has notes, and they are on
|
||||||
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
|
</p>
|
||||||
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.34</span>
|
||||||
|
Sections can be dragged into the order the work actually happens in. The
|
||||||
|
sidebar keeps its head still while the projects scroll.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.33</span>
|
||||||
|
Notes grew an archive, selection of many at once, pictures you can paste
|
||||||
|
or drag in, a source view, and a copy button on code blocks. Search
|
||||||
|
stopped answering by date and started answering by relevance.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.32</span>
|
||||||
|
A calendar of its own as a menu item, with a read-only Google calendar
|
||||||
|
that can be laid over it.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.31</span>
|
||||||
|
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
||||||
|
task — not only Gmail.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.30</span>
|
||||||
|
An installation count that says exactly what it sends, and can be turned
|
||||||
|
off in one click.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="running">
|
||||||
<h2>Running it</h2>
|
<h2>Running it</h2>
|
||||||
|
|
||||||
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
|
<div class="panels">
|
||||||
<p class="muted">
|
<div class="panel">
|
||||||
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
|
<h3>As a Rune in Yggdrasil Panel</h3>
|
||||||
<code>rune/verdande.yaml</code>, create a server from it, set the public address
|
<p>
|
||||||
and your mail server, and start it. No port forwarding — give it a subdomain and
|
This is what it was built for. <strong>Runes → Carve a rune</strong>,
|
||||||
the panel routes it through your Cloudflare Tunnel.
|
upload <code>rune/verdande.yaml</code>, create a server from it, set the
|
||||||
|
public address and your mail server, and start it. No port forwarding —
|
||||||
|
give it a subdomain and the panel routes it through your Cloudflare
|
||||||
|
Tunnel.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<h3 class="sub">Or with Docker, anywhere</h3>
|
<div class="panel">
|
||||||
|
<h3>Or with Docker, anywhere</h3>
|
||||||
|
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
|
||||||
<pre><code>docker run -d --name verdande \
|
<pre><code>docker run -d --name verdande \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v verdande-data:/data \
|
-v verdande-data:/data \
|
||||||
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
||||||
ghcr.io/kristianwind/verdande:latest</code></pre>
|
ghcr.io/kristianwind/verdande:latest</code></pre>
|
||||||
<p class="muted" style="margin-top: 1rem">
|
</div>
|
||||||
Open the address and create the first account. That one is the administrator.
|
</div>
|
||||||
</p>
|
|
||||||
<ul class="plain">
|
<ul class="plain">
|
||||||
<li>No database to provision — SQLite is inside the binary.</li>
|
<li>No database to provision — SQLite is inside the binary.</li>
|
||||||
<li>Distroless image: no shell, no package manager, one static file.</li>
|
<li>Distroless image: no shell, no package manager, one static file.</li>
|
||||||
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
||||||
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
||||||
|
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="limits">
|
||||||
<h2>What it will not do</h2>
|
<h2>What it will not do</h2>
|
||||||
<p class="muted">
|
<ul class="nogo">
|
||||||
No karma or points. No team workspaces. No native apps. No mail integration
|
<li>No karma, streaks or points.</li>
|
||||||
beyond Gmail and the forwarding address. No languages beyond Danish and English.
|
<li>No team workspaces — projects and the people in them are the whole model.</li>
|
||||||
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
|
<li>No native apps. It is a PWA, and it installs on a phone.</li>
|
||||||
|
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
|
||||||
|
<li>No languages beyond Danish and English.</li>
|
||||||
|
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
|
||||||
|
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="risk">
|
||||||
|
<h2>At your own risk</h2>
|
||||||
|
<div class="risk">
|
||||||
|
<p>
|
||||||
|
verdande is <strong>MIT licensed and provided as is, without warranty of
|
||||||
|
any kind</strong>. That sentence is in every MIT licence and it is usually
|
||||||
|
skipped, so here it is in the open: if you run this, you run it at your
|
||||||
|
own risk, and nobody is on the hook if it loses your work.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
It is one person's project, at version 0.x, and 0.x means the shape can
|
||||||
|
still change. <strong>You host it, so the data and the backups are
|
||||||
|
yours.</strong> It writes a backup every night and keeps fourteen — and a
|
||||||
|
backup nobody has ever restored is not yet a backup. Restore one before
|
||||||
|
you need to.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Security is worked on rather than claimed. There have been two full
|
||||||
|
reviews of the code, every finding is written down and closed, and what is
|
||||||
|
protected and what is not is spelled out in
|
||||||
|
<a href="https://github.com/kristianwind/verdande/blob/main/SECURITY.md">SECURITY.md</a>
|
||||||
|
— including the parts that are a deliberate trade-off. <strong>Nobody
|
||||||
|
outside the project has audited it.</strong> The last review found two ways
|
||||||
|
to reach something you should not and closed both; a third review will find
|
||||||
|
more, which is the point of having one.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It is built to sit behind something — a tunnel, a reverse proxy, a VPN —
|
||||||
|
put there by somebody who knows what they exposed. Found a hole? Mail it
|
||||||
|
rather than opening an issue; the address is in SECURITY.md and you get an
|
||||||
|
answer within a week.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
||||||
|
where it stood between somebody arriving and the buttons they came
|
||||||
|
for — and it is a closing thought, not an opening one. -->
|
||||||
|
<p class="norn">
|
||||||
|
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||||
|
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
||||||
|
in the middle of. Which is what a to-do list is.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
MIT licensed ·
|
MIT licensed ·
|
||||||
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
||||||
|
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Built to run as a Rune in
|
Built to run as a Rune in
|
||||||
|
|||||||
+806
-2
@@ -358,6 +358,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
'aria-expanded',
|
'aria-expanded',
|
||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
||||||
|
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
||||||
|
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
||||||
|
// ingenting at male henover den med.
|
||||||
|
const chevron = await sidebar
|
||||||
|
.locator('.folder-head .chevron-button')
|
||||||
|
.evaluate((el) => ({
|
||||||
|
background: getComputedStyle(el).backgroundColor,
|
||||||
|
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
||||||
|
}));
|
||||||
|
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||||
|
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
||||||
|
|
||||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||||
|
|
||||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||||
@@ -962,10 +976,19 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
|||||||
|
|
||||||
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
||||||
await expect(chip).toBeVisible();
|
await expect(chip).toBeVisible();
|
||||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||||
|
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||||
|
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
|
||||||
|
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||||
|
// slipper der, hvor man kan se, man slipper.
|
||||||
|
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||||
|
|
||||||
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
// Landet i timesøjlen med et klokkeslæt: hvor på døgnet man slap, *er* tiden.
|
||||||
|
// Sluppet tyve pixels nede i en søjle, der begynder klokken otte, bliver det
|
||||||
|
// tidligt på dagen — hvad det præcist runder til, er ikke det, der prøves her.
|
||||||
|
await expect(page.locator(`[data-date="${to}"] .tevent`).getByText('skifte dæk')).toBeVisible();
|
||||||
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||||
|
await expect(page.locator(`[data-allday="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -979,6 +1002,222 @@ 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
|
* asserting against a project the admin can already see would pass with the old
|
||||||
* per-project endpoint behind it.
|
* 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: '[email protected]',
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
|
||||||
|
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
|
||||||
|
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
|
||||||
|
// lagt klokken nul ville påstå, den holdt fra midnat til midnat.
|
||||||
|
const band = page.locator(`[data-allday="${day}"]`);
|
||||||
|
const event = cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' });
|
||||||
|
const allDay = band.locator('.event').filter({ hasText: 'Feriedag' });
|
||||||
|
const task = band.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.
|
||||||
|
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||||
|
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||||
|
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
|
||||||
|
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||||
|
// slipper der, hvor man kan se, man slipper.
|
||||||
|
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||||
|
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
|
||||||
|
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
||||||
|
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||||
|
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||||
|
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||||
|
await expect(band.locator('.event')).toHaveCount(1);
|
||||||
|
|
||||||
|
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 ({
|
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
||||||
browser,
|
browser,
|
||||||
page
|
page
|
||||||
@@ -1609,6 +1848,14 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||||
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
|
||||||
|
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
|
||||||
|
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
|
||||||
|
const inner = await sidebar
|
||||||
|
.locator('.scroller')
|
||||||
|
.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||||
|
expect(inner, 'rulleboksen kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
// And the whole page, for the same reason at a different scale.
|
// And the whole page, for the same reason at a different scale.
|
||||||
const page_sideways = await page.evaluate(
|
const page_sideways = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||||
@@ -1622,6 +1869,58 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mærket og de faste visninger bliver stående, når resten ruller.
|
||||||
|
*
|
||||||
|
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
|
||||||
|
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
|
||||||
|
*
|
||||||
|
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
|
||||||
|
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
|
||||||
|
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
|
||||||
|
*/
|
||||||
|
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 420 });
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
const scroller = sidebar.locator('.scroller');
|
||||||
|
await expect(scroller).toBeVisible();
|
||||||
|
|
||||||
|
// Rullebjælken begynder under de faste visninger og ikke bag dem.
|
||||||
|
const geometry = await sidebar.evaluate((el) => {
|
||||||
|
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
|
||||||
|
const scroll = el.querySelector('.scroller');
|
||||||
|
return {
|
||||||
|
views: box('.views'),
|
||||||
|
scroller: box('.scroller'),
|
||||||
|
overflows: scroll.scrollHeight - scroll.clientHeight
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
geometry.scroller.top,
|
||||||
|
'rulleboksen begynder oppe i de faste visninger'
|
||||||
|
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
|
||||||
|
|
||||||
|
// Og hovedet bliver, hvor det er, hele vejen ned.
|
||||||
|
const brand = sidebar.locator('.brand');
|
||||||
|
const before = await brand.boundingBox();
|
||||||
|
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||||
|
await expect
|
||||||
|
.poll(async () => scroller.evaluate((el) => el.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const after = await brand.boundingBox();
|
||||||
|
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A project can have more than one section.
|
* A project can have more than one section.
|
||||||
*
|
*
|
||||||
@@ -1694,6 +1993,76 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sektioner kan trækkes op og ned.
|
||||||
|
*
|
||||||
|
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
|
||||||
|
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
|
||||||
|
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
|
||||||
|
* område på vejen.
|
||||||
|
*
|
||||||
|
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
|
||||||
|
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
|
||||||
|
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
|
||||||
|
*/
|
||||||
|
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', 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('Havehus');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
|
||||||
|
|
||||||
|
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
|
||||||
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||||
|
const field = page.getByLabel('Ny sektion');
|
||||||
|
await field.fill(name);
|
||||||
|
await field.press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = () =>
|
||||||
|
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
|
||||||
|
|
||||||
|
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||||
|
|
||||||
|
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
|
||||||
|
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
|
||||||
|
// under, så positionen er skrevet ud og ikke overladt til midten.
|
||||||
|
await page
|
||||||
|
.locator('.section-head')
|
||||||
|
.filter({ hasText: 'Tag' })
|
||||||
|
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
|
||||||
|
targetPosition: { x: 40, y: 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
|
||||||
|
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
|
||||||
|
'Tag',
|
||||||
|
'Fundament',
|
||||||
|
'Rejsning'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Og den anden vej: sluppet i den nederste halvdel lander den under.
|
||||||
|
await page
|
||||||
|
.locator('.section-head')
|
||||||
|
.filter({ hasText: 'Tag' })
|
||||||
|
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
|
||||||
|
targetPosition: { x: 40, y: 28 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form fields on a touch device are at least 16px.
|
* Form fields on a touch device are at least 16px.
|
||||||
*
|
*
|
||||||
@@ -2499,3 +2868,438 @@ test('et billede kan indsættes og trækkes ind i en note', async ({ page }) =>
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan oprettes inde fra et projekt og hører til det med det samme', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByLabel('Nyt projekt').click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Tagprojekt');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tagprojekt' })).toBeVisible();
|
||||||
|
|
||||||
|
// Ruden findes, også før der er en eneste note — ellers er der ingen knap at
|
||||||
|
// trykke på det sted, hvor man opdager at der skal skrives noget ned.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(ed).toBeVisible();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Tagsten og lægter');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
|
||||||
|
// nogen har valgt det bagefter.
|
||||||
|
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
||||||
|
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
|
||||||
|
expect(shared).toContain('Tagprojekt');
|
||||||
|
|
||||||
|
// Og den står på projektets egen side.
|
||||||
|
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
|
||||||
|
// Titlen, ikke uddraget: begge indeholder de samme ord, fordi titlen *er*
|
||||||
|
// notens første linje.
|
||||||
|
await expect(page.locator('.notes strong', { hasText: 'Tagsten og lægter' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await page.goto('/');
|
||||||
|
await sidebar.getByLabel('Nyt projekt').click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
|
||||||
|
|
||||||
|
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: '# Løs note\n\nSkal flyttes ved at trække.\n' })
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/noter?note=' + id);
|
||||||
|
await page.evaluate((noteId) => (window.__noteId = noteId), id);
|
||||||
|
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
|
||||||
|
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
|
||||||
|
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
|
||||||
|
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('application/x-verdande-note', window.__noteId);
|
||||||
|
const target = [...document.querySelectorAll('nav a')].find((a) =>
|
||||||
|
a.textContent.includes('Trækmål')
|
||||||
|
);
|
||||||
|
for (const type of ['dragover', 'drop']) {
|
||||||
|
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Det, der tæller: noten ligger i projektet bagefter.
|
||||||
|
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();
|
||||||
|
}, id);
|
||||||
|
expect(body.project_id).toBeTruthy();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
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: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
});
|
||||||
|
await page.goto('/noter?note=' + id);
|
||||||
|
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
const copy = ed.locator('pre .copy');
|
||||||
|
await expect(copy).toBeVisible();
|
||||||
|
await copy.click();
|
||||||
|
await expect(copy).toHaveText('Kopieret');
|
||||||
|
|
||||||
|
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
|
||||||
|
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(clip).toBe('docker pull verdande');
|
||||||
|
|
||||||
|
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
|
||||||
|
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
|
||||||
|
// fælde, den her prøve findes for.
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.type(' ');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
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('docker pull verdande');
|
||||||
|
expect(body).not.toContain('Kopiér');
|
||||||
|
expect(body).not.toContain('Kopieret');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
|
||||||
|
await page.evaluate(async (t) => {
|
||||||
|
await fetch('/api/v1/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
|
||||||
|
});
|
||||||
|
}, title);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
const rows = page.locator('.notes button.row');
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
|
||||||
|
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
|
||||||
|
// hører med i markeringen — ellers ville det andet klik markere én og glemme
|
||||||
|
// den, man stod på.
|
||||||
|
await rows.filter({ hasText: 'Alfa' }).click();
|
||||||
|
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||||
|
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
|
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||||
|
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Vis arkivet').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
|
||||||
|
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
|
||||||
|
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
|
||||||
|
await rows.filter({ hasText: 'Alfa' }).hover();
|
||||||
|
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByLabel('Vis arkivet').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
|
||||||
|
*
|
||||||
|
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
|
||||||
|
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
|
||||||
|
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
|
||||||
|
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
|
||||||
|
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
|
||||||
|
* bagefter. Klikket kan ikke engang lande.
|
||||||
|
*
|
||||||
|
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
|
||||||
|
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
|
||||||
|
* kunne låse de tolv hundrede andre ude.
|
||||||
|
*/
|
||||||
|
test('en note uden krop må ikke låse ruden', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/noter');
|
||||||
|
for (const title of ['Alfa', 'Beta']) {
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type(title);
|
||||||
|
await expect(ed.locator('h1')).toHaveText(title);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Brødtekst i ' + title);
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.route('**/api/v1/notes/*', async (route) => {
|
||||||
|
if (route.request().method() !== 'GET') return route.continue();
|
||||||
|
const res = await route.fetch();
|
||||||
|
const note = await res.json();
|
||||||
|
delete note.body;
|
||||||
|
await route.fulfill({ response: res, json: note });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
|
||||||
|
|
||||||
|
await page.unroute('**/api/v1/notes/*');
|
||||||
|
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
|
||||||
|
'ruden er låst: den næste note åbner ikke'
|
||||||
|
).toHaveText('Beta', { timeout: 5000 });
|
||||||
|
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ugen er et døgn, ikke syv punktopstillinger.
|
||||||
|
*
|
||||||
|
* En uge, hvor hver dag er en liste, svarer på "hvad skal der ske torsdag" og ikke
|
||||||
|
* på "hvornår" — og det andet er det, man åbner en uge for. To møder klokken ti er
|
||||||
|
* en konflikt, man skal kunne se, ikke læse sig til.
|
||||||
|
*
|
||||||
|
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
|
||||||
|
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
|
||||||
|
* er en prøve, der aldrig kører.
|
||||||
|
*/
|
||||||
|
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
// Mandag i den uge, siden åbner på.
|
||||||
|
const monday = (n) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
d.getDate()
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const at = (n, from, to, id, summary) => ({
|
||||||
|
id,
|
||||||
|
calendar_id: 'c1',
|
||||||
|
summary,
|
||||||
|
starts_at: `${monday(n)}T${from}:00+02:00`,
|
||||||
|
ends_at: `${monday(n)}T${to}:00+02:00`,
|
||||||
|
start_day: monday(n),
|
||||||
|
end_day: monday(n),
|
||||||
|
all_day: false,
|
||||||
|
calendar_name: 'Privat'
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/calendar/events*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
events: [
|
||||||
|
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
|
||||||
|
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
|
||||||
|
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
|
||||||
|
{
|
||||||
|
id: 'e4',
|
||||||
|
calendar_id: 'c1',
|
||||||
|
summary: 'Aalborg',
|
||||||
|
start_day: monday(1),
|
||||||
|
end_day: monday(1),
|
||||||
|
all_day: true,
|
||||||
|
calendar_name: 'Privat'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/kalender');
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
await expect(page.locator('.weekgrid')).toBeVisible();
|
||||||
|
|
||||||
|
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
|
||||||
|
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
|
||||||
|
const widths = await page
|
||||||
|
.locator('.weekgrid .daycol')
|
||||||
|
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||||
|
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
|
||||||
|
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
|
||||||
|
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
|
||||||
|
const morning = await box('Morgenmøde');
|
||||||
|
const evening = await box('Sent på dagen');
|
||||||
|
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
|
||||||
|
|
||||||
|
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
|
||||||
|
// den konflikt, visningen findes for at vise.
|
||||||
|
const overlapping = await box('Oveni det første');
|
||||||
|
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
|
||||||
|
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
|
||||||
|
|
||||||
|
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
|
||||||
|
// ville påstå, den holdt fra midnat til midnat.
|
||||||
|
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
|
||||||
|
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
|
||||||
|
|
||||||
|
// Og måneden er stadig lige bred hele vejen rundt.
|
||||||
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
|
const monthWidths = await page
|
||||||
|
.locator('.grid .day')
|
||||||
|
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||||
|
expect(
|
||||||
|
Math.max(...monthWidths) - Math.min(...monthWidths),
|
||||||
|
'månedens dage er ikke lige brede'
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tid på en opgave, sat i ruden og justeret i kalenderen.
|
||||||
|
*
|
||||||
|
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
|
||||||
|
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
|
||||||
|
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
|
||||||
|
* det samme gjorde et træk i kalenderen.
|
||||||
|
*/
|
||||||
|
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
|
||||||
|
// åbner på længere nede.
|
||||||
|
await box.fill('ringe til tandlægen i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
|
||||||
|
|
||||||
|
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
|
||||||
|
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
|
||||||
|
await page.getByText('ringe til tandlægen').click();
|
||||||
|
const time = page.getByLabel('Klokkeslæt');
|
||||||
|
await expect(time).toHaveValue('');
|
||||||
|
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
|
||||||
|
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
|
||||||
|
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
|
||||||
|
// browseren taber.
|
||||||
|
await time.fill('08:30');
|
||||||
|
await time.blur();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
|
||||||
|
await page.reload();
|
||||||
|
await page.getByText('ringe til tandlægen').click();
|
||||||
|
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
|
||||||
|
|
||||||
|
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||||
|
const date = page.locator('#due');
|
||||||
|
const day = await date.inputValue();
|
||||||
|
await date.fill(day);
|
||||||
|
await date.blur();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByText('ringe til tandlægen').click();
|
||||||
|
await expect(
|
||||||
|
page.getByLabel('Klokkeslæt'),
|
||||||
|
'et gem af datoen ryddede klokkeslættet'
|
||||||
|
).toHaveValue('08:30');
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
|
||||||
|
await page.goto('/upcoming');
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
|
||||||
|
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||||
|
await expect(placed).toContainText('08:30');
|
||||||
|
|
||||||
|
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
|
||||||
|
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
|
||||||
|
// forveksles.
|
||||||
|
//
|
||||||
|
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
|
||||||
|
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
|
||||||
|
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
|
||||||
|
// der prøves her.
|
||||||
|
const band = page
|
||||||
|
.locator('.weekgrid > .allday')
|
||||||
|
.filter({ hasNot: page.locator('.chip, .event') })
|
||||||
|
.first();
|
||||||
|
const today = await band.getAttribute('data-allday');
|
||||||
|
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
|
||||||
|
// over fire hundrede pixels i et travlt gitter giver det browseren for få
|
||||||
|
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
|
||||||
|
// vejen, og det er også det, der skal prøves.
|
||||||
|
const fromBox = await placed.boundingBox();
|
||||||
|
const toBox = await band.boundingBox();
|
||||||
|
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(
|
||||||
|
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
|
||||||
|
'opgaven kom ikke op i båndet'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
|
||||||
|
'opgaven står stadig på et klokkeslæt'
|
||||||
|
).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
+25
-1
@@ -61,6 +61,10 @@ const MESSAGES = {
|
|||||||
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||||
// this when it did not.
|
// this when it did not.
|
||||||
mailbox_failed: 'error.mailboxRefused',
|
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'
|
last_admin: 'error.lastAdmin'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +88,9 @@ export function humanMessage(err) {
|
|||||||
if (err.code === 'gmail_failed' && err.message) {
|
if (err.code === 'gmail_failed' && err.message) {
|
||||||
return t('error.gmailSaid', { what: 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];
|
const key = MESSAGES[err.code];
|
||||||
if (key) return t(key);
|
if (key) return t(key);
|
||||||
@@ -196,7 +203,7 @@ export const api = {
|
|||||||
changePassword: (current_password, new_password) =>
|
changePassword: (current_password, new_password) =>
|
||||||
post('/auth/password/change', { current_password, new_password }),
|
post('/auth/password/change', { current_password, new_password }),
|
||||||
|
|
||||||
totpSetup: () => post('/auth/totp/setup'),
|
totpSetup: (password) => post('/auth/totp/setup', { password }),
|
||||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||||
listSessions: () => get('/auth/sessions'),
|
listSessions: () => get('/auth/sessions'),
|
||||||
@@ -276,6 +283,10 @@ export const api = {
|
|||||||
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
||||||
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
||||||
deleteSection: (id) => del(`/sections/${id}`),
|
deleteSection: (id) => del(`/sections/${id}`),
|
||||||
|
// The whole list, the same as projects and groups: a project has a handful of
|
||||||
|
// sections, and an order that cannot land half-applied is worth more than the
|
||||||
|
// bytes a single move would save.
|
||||||
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||||
@@ -338,6 +349,7 @@ export const api = {
|
|||||||
|
|
||||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||||
|
noteBulk: (body) => post('/notes/bulk', body),
|
||||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||||
|
|
||||||
@@ -354,6 +366,8 @@ export const api = {
|
|||||||
pushKey: () => get('/push/key'),
|
pushKey: () => get('/push/key'),
|
||||||
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
||||||
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
||||||
|
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
|
||||||
|
testPush: (title, body) => post('/push/test', { title, body }),
|
||||||
|
|
||||||
// --- integrations
|
// --- integrations
|
||||||
getMailAddress: () => get('/mail-address'),
|
getMailAddress: () => get('/mail-address'),
|
||||||
@@ -365,6 +379,16 @@ export const api = {
|
|||||||
authorizeGmail: () => post('/gmail/authorize'),
|
authorizeGmail: () => post('/gmail/authorize'),
|
||||||
syncGmail: () => post('/gmail/sync'),
|
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
|
// Notes. Listing and searching are one endpoint because they answer the same
|
||||||
// question with different words.
|
// question with different words.
|
||||||
notes: (params = {}) => {
|
notes: (params = {}) => {
|
||||||
|
|||||||
@@ -49,7 +49,19 @@
|
|||||||
* flight swallows the click that would page. A week that straddles the
|
* flight swallows the click that would page. A week that straddles the
|
||||||
* 31st and the 1st has both days in the same row.
|
* 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();
|
} = $props();
|
||||||
|
|
||||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||||
@@ -137,6 +149,66 @@
|
|||||||
(!projectId || t.project_id === projectId)
|
(!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(
|
const monthName = $derived(
|
||||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||||
);
|
);
|
||||||
@@ -200,6 +272,15 @@
|
|||||||
over = iso(date);
|
over = iso(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sluppet på en dag, uden at sige noget om klokkeslættet — så beholder opgaven
|
||||||
|
* det, den har. Måneden er den her: en rude er en dag, ikke et døgn, og der er
|
||||||
|
* ingen tid at læse ud af hvor man slap.
|
||||||
|
*
|
||||||
|
* Det var ikke sådan før. Trækket sendte kun datoen, og serveren læser en dato
|
||||||
|
* uden en tid som "ryd tiden" — så en opgave klokken 14 mistede sit
|
||||||
|
* klokkeslæt ved at blive flyttet til dagen efter, uden at nogen bad om det.
|
||||||
|
*/
|
||||||
async function onDrop(event, date) {
|
async function onDrop(event, date) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = dragged(event, TASK);
|
const id = dragged(event, TASK);
|
||||||
@@ -207,6 +288,184 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
await app.reschedule(id, iso(date));
|
await app.reschedule(id, iso(date));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sluppet et sted på døgnet: dér, man slap, er klokkeslættet.
|
||||||
|
*
|
||||||
|
* Målt mod søjlens egen kasse frem for mod siden, så det bliver rigtigt, uanset
|
||||||
|
* hvor gitteret sidder, og uanset hvor langt der er rullet.
|
||||||
|
*
|
||||||
|
* Rundet til kvarter. Minuttet under markøren er ikke en oplysning, nogen har —
|
||||||
|
* pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
|
||||||
|
* det blev valgt, hvilket er værre end at være omtrentligt.
|
||||||
|
*/
|
||||||
|
async function onDropAt(event, date) {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = dragged(event, TASK);
|
||||||
|
over = null;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
const share = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1);
|
||||||
|
const minutes = hours.from * 60 + share * hours.minutes;
|
||||||
|
const snapped = Math.min(Math.round(minutes / 15) * 15, 24 * 60 - 15);
|
||||||
|
await app.reschedule(id, iso(date), clock(snapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sluppet i båndet foroven: opgaven har en dag og ingen tid.
|
||||||
|
*
|
||||||
|
* Den tomme streng er ikke det samme som ingenting her — den siger "ryd", hvor
|
||||||
|
* `undefined` siger "behold". Det er hele grunden til, at de to er skilt ad i
|
||||||
|
* `reschedule`.
|
||||||
|
*/
|
||||||
|
async function onDropAllDay(event, date) {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = dragged(event, TASK);
|
||||||
|
over = null;
|
||||||
|
if (!id) return;
|
||||||
|
await app.reschedule(id, iso(date), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ugen som et døgn ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// En uge, hvor hver dag er en punktopstilling, svarer på "hvad skal der ske
|
||||||
|
// torsdag" og ikke på "hvornår" — og det andet er det, man åbner en uge for.
|
||||||
|
// To møder klokken ti er en konflikt, man skal kunne *se*, ikke læse sig til.
|
||||||
|
//
|
||||||
|
// Måneden bliver som den er. En dag i en månedsrude er halvanden centimeter høj;
|
||||||
|
// et døgn tegnet i den er ikke en visning, det er en streg.
|
||||||
|
|
||||||
|
/** Minutter siden midnat, læst ud af strengen. */
|
||||||
|
const minutesAt = (stamp) => Number(stamp.slice(11, 13)) * 60 + Number(stamp.slice(14, 16));
|
||||||
|
|
||||||
|
const clock = (minutes) =>
|
||||||
|
`${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Det, der har et klokkeslæt på denne dag, med start og slut i minutter.
|
||||||
|
*
|
||||||
|
* Begivenheder læses som tekst, af samme grund som `timeOn` gør det: Google
|
||||||
|
* skriver begivenhedens egen forskydning ind i stemplet, og at give det til
|
||||||
|
* browseren spørger *denne maskine*, hvad klokken er. Opgaver læses med `Date`,
|
||||||
|
* fordi det er sådan TaskRow gør det ét skærmbillede væk — en opgaves tid er
|
||||||
|
* kontoens egen, og de to steder skal vise det samme tal.
|
||||||
|
*
|
||||||
|
* En begivenhed hen over midnat klippes til dagen. Uden det ville et møde fra
|
||||||
|
* fredag aften til lørdag morgen blive tegnet fra 20:00 til 09:00, altså baglæns.
|
||||||
|
*/
|
||||||
|
function timedOn(date) {
|
||||||
|
const day = iso(date);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.all_day || !event.starts_at) continue;
|
||||||
|
if (event.start_day > day || event.end_day < day) continue;
|
||||||
|
const from = event.start_day === day ? minutesAt(event.starts_at) : 0;
|
||||||
|
let to = 24 * 60;
|
||||||
|
if (event.ends_at && event.end_day === day) to = minutesAt(event.ends_at);
|
||||||
|
else if (!event.ends_at) to = from + 60;
|
||||||
|
// Et kvarters gulv: et nulminuts møde er stadig noget, der skal kunne ses
|
||||||
|
// og klikkes på.
|
||||||
|
out.push({ kind: 'event', ref: event, from, to: Math.max(to, from + 15) });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of tasksOn(date)) {
|
||||||
|
if (!task.due_datetime) continue;
|
||||||
|
const at = new Date(task.due_datetime);
|
||||||
|
const from = at.getHours() * 60 + at.getMinutes();
|
||||||
|
out.push({ kind: 'task', ref: task, from, to: from + 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Det uden klokkeslæt: heldagsbegivenheder og opgaver, der kun har en dato. */
|
||||||
|
function untimedOn(date) {
|
||||||
|
return [
|
||||||
|
...eventsOn(date)
|
||||||
|
.filter((e) => e.all_day || !e.starts_at)
|
||||||
|
.map((e) => ({ kind: 'event', ref: e })),
|
||||||
|
...tasksOn(date)
|
||||||
|
.filter((t) => !t.due_datetime)
|
||||||
|
.map((t) => ({ kind: 'task', ref: t }))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To ting på samme tid står ved siden af hinanden, ikke oven på hinanden.
|
||||||
|
*
|
||||||
|
* Sammenhængende klynger frem for parvise sammenligninger: overlapper A med B og
|
||||||
|
* B med C, skal alle tre dele bredden — også når A og C ikke rører hinanden.
|
||||||
|
* Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på hver sin
|
||||||
|
* halvdel af B.
|
||||||
|
*/
|
||||||
|
function packed(items) {
|
||||||
|
const out = [];
|
||||||
|
let cluster = [];
|
||||||
|
let end = -1;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const lanes = [];
|
||||||
|
for (const item of cluster) {
|
||||||
|
let lane = lanes.findIndex((free) => free <= item.from);
|
||||||
|
if (lane === -1) {
|
||||||
|
lane = lanes.length;
|
||||||
|
lanes.push(0);
|
||||||
|
}
|
||||||
|
lanes[lane] = item.to;
|
||||||
|
item.lane = lane;
|
||||||
|
}
|
||||||
|
for (const item of cluster) item.lanes = lanes.length;
|
||||||
|
out.push(...cluster);
|
||||||
|
cluster = [];
|
||||||
|
end = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (cluster.length && item.from >= end) flush();
|
||||||
|
cluster.push(item);
|
||||||
|
end = Math.max(end, item.to);
|
||||||
|
}
|
||||||
|
if (cluster.length) flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilke timer der tegnes.
|
||||||
|
*
|
||||||
|
* Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
|
||||||
|
* gitteret noget man ruller i frem for noget man ser. Arbejdsdagen er gulvet, og
|
||||||
|
* ugens egne begivenheder skubber kanterne ud, når de ligger uden for den — en
|
||||||
|
* vagt klokken fem om morgenen kommer med, uden at alle andre uger betaler for
|
||||||
|
* den med tomme rækker.
|
||||||
|
*/
|
||||||
|
let hours = $derived.by(() => {
|
||||||
|
let from = 8;
|
||||||
|
let to = 18;
|
||||||
|
if (span === 'week') {
|
||||||
|
for (const date of grid) {
|
||||||
|
for (const item of timedOn(date)) {
|
||||||
|
from = Math.min(from, Math.floor(item.from / 60));
|
||||||
|
to = Math.max(to, Math.ceil(item.to / 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from = Math.max(0, from);
|
||||||
|
to = Math.min(24, Math.max(to, from + 1));
|
||||||
|
return { from, to, count: to - from, minutes: (to - from) * 60 };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Hvor på døgnet noget står, som procent af de timer, der tegnes. */
|
||||||
|
function place(item) {
|
||||||
|
const top = Math.max(0, ((item.from - hours.from * 60) / hours.minutes) * 100);
|
||||||
|
const height = ((item.to - item.from) / hours.minutes) * 100;
|
||||||
|
const width = 100 / item.lanes;
|
||||||
|
return (
|
||||||
|
`top:${top}%;height:${Math.min(height, 100 - top)}%;` +
|
||||||
|
`left:${item.lane * width}%;width:${width}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="calendar" class:week={span === 'week'}>
|
<div class="calendar" class:week={span === 'week'}>
|
||||||
@@ -224,6 +483,143 @@
|
|||||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Én brik, tegnet ét sted.
|
||||||
|
|
||||||
|
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
|
||||||
|
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
|
||||||
|
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
|
||||||
|
det er kun placeringen, der står to steder.
|
||||||
|
|
||||||
|
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
|
||||||
|
klokkeslættet, når det ikke allerede står i brikken selv.
|
||||||
|
-->
|
||||||
|
{#snippet eventChip(event, date, kind, extra = '', at = '')}
|
||||||
|
{@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={kind}
|
||||||
|
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)}` : '', extra]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';')}
|
||||||
|
>
|
||||||
|
{#if at}<span class="at">{at}</span>{:else if timeOn(event, date)}<span class="at"
|
||||||
|
>{timeOn(event, date)}</span
|
||||||
|
>{/if}
|
||||||
|
{event.summary}
|
||||||
|
</svelte:element>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet taskChip(task, kind, extra = '', at = '')}
|
||||||
|
<!-- Draggable as well as clickable: moving something to another day is the
|
||||||
|
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||||
|
of movement before it calls a press a drag, so the click survives. -->
|
||||||
|
<button
|
||||||
|
class={kind}
|
||||||
|
data-priority={task.priority}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||||
|
ondragend={() => (over = null)}
|
||||||
|
onclick={() => onselect?.(task)}
|
||||||
|
style={extra}
|
||||||
|
>
|
||||||
|
{#if at}<span class="at">{at}</span>{/if}
|
||||||
|
{task.content}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if span === 'week'}
|
||||||
|
<!-- Ugen som et døgn, ikke som syv punktopstillinger.
|
||||||
|
Rullet vandret på en smal skærm frem for stablet til én søjle: der er
|
||||||
|
ét sæt afleveringsmål her, og et andet sæt til telefonen ville være
|
||||||
|
endnu et sted, den samme regel kunne være næsten rigtig. -->
|
||||||
|
<div class="weekwrap">
|
||||||
|
<div class="weekgrid" style="--hours: {hours.count}">
|
||||||
|
<div class="corner"></div>
|
||||||
|
{#each grid as date (date.toISOString())}
|
||||||
|
<div class="head" class:today={iso(date) === todayISO}>
|
||||||
|
<span class="wd">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
|
||||||
|
<span class="num">{date.getDate()}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
|
||||||
|
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
|
||||||
|
påstå, den holdt fra midnat til midnat. -->
|
||||||
|
<div class="gutter">{t('cal.allDay')}</div>
|
||||||
|
{#each grid as date (date.toISOString())}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="allday"
|
||||||
|
data-allday={iso(date)}
|
||||||
|
class:over={over === iso(date)}
|
||||||
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
|
ondragleave={() => (over = null)}
|
||||||
|
ondrop={(e) => onDropAllDay(e, date)}
|
||||||
|
>
|
||||||
|
{#each untimedOn(date) as item (item.kind + item.ref.id)}
|
||||||
|
{#if item.kind === 'event'}
|
||||||
|
{@render eventChip(item.ref, date, 'event')}
|
||||||
|
{:else}
|
||||||
|
{@render taskChip(item.ref, 'chip')}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="gutter times">
|
||||||
|
{#each Array(hours.count) as _, i}
|
||||||
|
<span class="hour">{clock((hours.from + i) * 60)}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#each grid as date (date.toISOString())}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- `data-date` er dagens navn udadtil. Månedens ruder har altid haft
|
||||||
|
det, og ugens søjler skal have det af samme grund: det er sådan
|
||||||
|
noget uden for komponenten peger på en dag — og det er det, en
|
||||||
|
prøve slipper en opgave på. Heldagsbåndet får sit eget navn, så
|
||||||
|
de to ikke bliver til det samme svar på ét spørgsmål. -->
|
||||||
|
<div
|
||||||
|
class="daycol"
|
||||||
|
data-date={iso(date)}
|
||||||
|
class:today={iso(date) === todayISO}
|
||||||
|
class:over={over === iso(date)}
|
||||||
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
|
ondragleave={() => (over = null)}
|
||||||
|
ondrop={(e) => onDropAt(e, date)}
|
||||||
|
>
|
||||||
|
{#each Array(hours.count) as _, i}
|
||||||
|
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
|
||||||
|
{/each}
|
||||||
|
{#each packed(timedOn(date)) as item (item.kind + item.ref.id)}
|
||||||
|
{#if item.kind === 'event'}
|
||||||
|
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||||
|
{:else}
|
||||||
|
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="weekdays" aria-hidden="true">
|
<div class="weekdays" aria-hidden="true">
|
||||||
{#each WEEKDAYS as day}
|
{#each WEEKDAYS as day}
|
||||||
<span>{day}</span>
|
<span>{day}</span>
|
||||||
@@ -232,7 +628,14 @@
|
|||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each grid as date (date.toISOString())}
|
{#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. -->
|
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -254,29 +657,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="number">{date.getDate()}</span>
|
<span class="number">{date.getDate()}</span>
|
||||||
|
|
||||||
{#each tasks.slice(0, chipLimit) as task (task.id)}
|
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||||
<!-- Draggable as well as clickable: within a month, moving something
|
{@render eventChip(event, date, 'event')}
|
||||||
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
|
|
||||||
drag, so the click survives. -->
|
|
||||||
<button
|
|
||||||
class="chip"
|
|
||||||
data-priority={task.priority}
|
|
||||||
draggable="true"
|
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
|
||||||
ondragend={() => (over = null)}
|
|
||||||
onclick={() => onselect?.(task)}
|
|
||||||
>
|
|
||||||
{task.content}
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if tasks.length > chipLimit}
|
{#each tasks as task (task.id)}
|
||||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
{@render taskChip(task, 'chip')}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hidden > 0}
|
||||||
|
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -340,9 +735,19 @@
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `minmax(0, 1fr)` og ikke `1fr`.
|
||||||
|
*
|
||||||
|
* `1fr` er `minmax(auto, 1fr)`, og `auto` som minimum betyder, at en søjle ikke
|
||||||
|
* kan blive smallere end sit længste ord. Én opgave, der hedder "Gårdbutikken på
|
||||||
|
* Møllerup Gods — opfølgning på mail", gjorde onsdag dobbelt så bred som mandag
|
||||||
|
* og klemte resten af ugen sammen om den. Syv dage er syv lige store dage; det
|
||||||
|
* er hele idéen med et gitter.
|
||||||
|
*
|
||||||
|
* Nul som minimum lader søjlen blive så smal, den skal — og så er det brikkens
|
||||||
|
* opgave at klippe sin tekst af, hvilket den allerede gør. */
|
||||||
.weekdays {
|
.weekdays {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
margin-bottom: var(--s2);
|
margin-bottom: var(--s2);
|
||||||
}
|
}
|
||||||
@@ -357,7 +762,7 @@
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -365,6 +770,168 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- ugen som et døgn ---------------------------------------------------
|
||||||
|
*
|
||||||
|
* Et gitter med en tidsrende og syv dage, og tre rækker: dagenes navne, båndet
|
||||||
|
* med det, der ikke har et klokkeslæt, og selve døgnet.
|
||||||
|
*
|
||||||
|
* Rullet vandret på en smal skærm frem for stablet til én søjle. Syv timeglas
|
||||||
|
* ved siden af hinanden kan ikke blive smalle nok til en telefon, og en uge
|
||||||
|
* stablet til en liste er præcis det, den her visning findes for at holde op
|
||||||
|
* med at være. */
|
||||||
|
.weekwrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3.25rem repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 640px;
|
||||||
|
background: var(--line);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner,
|
||||||
|
.head,
|
||||||
|
.gutter,
|
||||||
|
.allday,
|
||||||
|
.daycol {
|
||||||
|
background: var(--ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: var(--s2) var(--s1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head .wd {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head .num {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head.today .num {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--s1) var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allday {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s1);
|
||||||
|
min-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rækken er lige så høj som de timer, den viser. `--hours` sættes på gitteret,
|
||||||
|
fordi tallet kommer fra ugens egne begivenheder og ikke fra stilarket. */
|
||||||
|
.times {
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: stretch;
|
||||||
|
grid-template-rows: repeat(var(--hours), 1fr);
|
||||||
|
height: calc(var(--hours) * 3rem);
|
||||||
|
padding: 0 var(--s2) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Klokkeslættet står ved sin egen streg og ikke midt i timen under den. */
|
||||||
|
.times .hour {
|
||||||
|
transform: translateY(-0.55em);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Undtagen det første, som ellers kravler op i heldagsbåndet: der er ingen streg
|
||||||
|
over den første time — der er gitterets kant, og et tal, der ligger oven på
|
||||||
|
den, ser ud til at høre til rækken ovenover. */
|
||||||
|
.times .hour:first-child {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycol {
|
||||||
|
position: relative;
|
||||||
|
height: calc(var(--hours) * 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Den første streg er gitterets egen kant. */
|
||||||
|
.hourline:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allday.over,
|
||||||
|
.daycol.over {
|
||||||
|
background: var(--accent-sunken, var(--surface-raised));
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
|
||||||
|
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
|
||||||
|
luft, der gør to kasser til to kasser. */
|
||||||
|
.tevent {
|
||||||
|
position: absolute;
|
||||||
|
margin-right: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1px var(--s1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid var(--event-colour, var(--line-strong));
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tevent.task {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-left-color: var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tevent.task[data-priority='1'] {
|
||||||
|
border-left-color: var(--p1);
|
||||||
|
}
|
||||||
|
.tevent.task[data-priority='2'] {
|
||||||
|
border-left-color: var(--p2);
|
||||||
|
}
|
||||||
|
.tevent.task[data-priority='3'] {
|
||||||
|
border-left-color: var(--p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tevent .at {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-right: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
.day {
|
.day {
|
||||||
background: var(--ground);
|
background: var(--ground);
|
||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
@@ -451,6 +1018,58 @@
|
|||||||
background: var(--surface);
|
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 {
|
.more {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -482,6 +1101,29 @@
|
|||||||
background: var(--p3);
|
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 {
|
.more {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
@@ -536,6 +1178,27 @@
|
|||||||
background: var(--surface-raised);
|
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 {
|
.calendar.week .more {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,20 +35,53 @@
|
|||||||
let loadedId = $state(null);
|
let loadedId = $state(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!editor || !note || note.id === loadedId) return;
|
if (!editor || !note || note.id === loadedId) return;
|
||||||
// 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
|
// Sat *før* tegningen, ikke efter.
|
||||||
// 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
|
// Kaster noget herunder, er noten stadig den, der er forsøgt indlæst — og
|
||||||
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
// uden det her ville effekten prøve igen ved hver eneste ændring bagefter og
|
||||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
// kaste hver gang.
|
||||||
loadedId = note.id;
|
loadedId = note.id;
|
||||||
active = {};
|
active = {};
|
||||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
// 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,
|
// 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.
|
// uden at nogen har bedt om det.
|
||||||
asSource = false;
|
asSource = false;
|
||||||
|
|
||||||
|
// Én note, der ikke kan tegnes, må ikke tage ruden med sig.
|
||||||
|
//
|
||||||
|
// Det var `note.body.trim()` uden værn, og `body` er en streng fra serveren
|
||||||
|
// hver eneste gang — indtil den ikke er. Så kaster den her inde i et
|
||||||
|
// `$effect`, og en effekt, der kaster, tager komponentens opdateringer med
|
||||||
|
// sig: den forrige note bliver stående, og *ingen* note kan åbnes bagefter.
|
||||||
|
// Klikket kan ikke engang lande. Meldt ind som "jeg kan ikke skifte mellem
|
||||||
|
// noter, uanset hvilken jeg trykker på", hvilket er præcis, hvad det ser ud
|
||||||
|
// som fra den anden side.
|
||||||
|
//
|
||||||
|
// Fejlen står i konsollen frem for i en toast: den hører til den, der kan
|
||||||
|
// rette den, og en note, der viser sin egen tekst uden formatering, siger
|
||||||
|
// allerede til den, der læser den, at noget er galt.
|
||||||
|
//
|
||||||
|
// `textContent` og ikke `innerHTML` i faldbakken — teksten er ikke tegnet, og
|
||||||
|
// at lægge den ind som markup ville være at køre det, tegningen lige nægtede.
|
||||||
|
// Gemningen er ikke i fare: den skriver `draft`, som kommer fra svaret og
|
||||||
|
// ikke fra det, der står i editoren, indtil nogen taster i den.
|
||||||
|
const body = note.body ?? '';
|
||||||
|
try {
|
||||||
|
// 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 = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
|
||||||
colourCode();
|
colourCode();
|
||||||
|
} catch (e) {
|
||||||
|
editor.textContent = body;
|
||||||
|
console.error('verdande: noten kunne ikke tegnes', note.id, e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
@@ -121,12 +154,71 @@
|
|||||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||||
* man læser den.
|
* man læser den.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* En kopiér-knap på hver kodeblok.
|
||||||
|
*
|
||||||
|
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
|
||||||
|
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
|
||||||
|
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
|
||||||
|
* den findes for øjet og musen, ikke for dokumentet.
|
||||||
|
*
|
||||||
|
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
|
||||||
|
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
|
||||||
|
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
|
||||||
|
*/
|
||||||
|
function addCopyButtons() {
|
||||||
|
if (!editor) return;
|
||||||
|
for (const pre of editor.querySelectorAll('pre')) {
|
||||||
|
if (pre.querySelector('.copy')) continue;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'copy';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.contentEditable = 'false';
|
||||||
|
btn.textContent = t('notes.copyCode');
|
||||||
|
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const text = codeTextOf(pre);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||||
|
btn.textContent = t('notes.copied');
|
||||||
|
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
|
||||||
|
} catch {
|
||||||
|
app.toast(t('notes.copyFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pre.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||||
|
*
|
||||||
|
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
||||||
|
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
||||||
|
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
||||||
|
*/
|
||||||
|
function codeTextOf(pre) {
|
||||||
|
const from = pre.querySelector('code') ?? pre;
|
||||||
|
return [...from.childNodes]
|
||||||
|
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||||
|
.map((n) => n.textContent ?? '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function colourCode() {
|
function colourCode() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const inside = currentBlock();
|
const inside = currentBlock();
|
||||||
for (const pre of editor.querySelectorAll('pre')) {
|
for (const pre of editor.querySelectorAll('pre')) {
|
||||||
if (pre === inside || pre.contains(inside)) continue;
|
if (pre === inside || pre.contains(inside)) continue;
|
||||||
const code = pre.textContent ?? '';
|
// Blokkens tekst uden det, vi selv har lagt oven på den.
|
||||||
|
//
|
||||||
|
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
|
||||||
|
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||||
|
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||||
|
// spiste den kode, den var sat der for at kopiere.
|
||||||
|
const code = codeTextOf(pre);
|
||||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||||
if (lang) pre.setAttribute('data-lang', lang);
|
if (lang) pre.setAttribute('data-lang', lang);
|
||||||
const painted = highlight(code, lang);
|
const painted = highlight(code, lang);
|
||||||
@@ -135,6 +227,7 @@
|
|||||||
const target = pre.querySelector('code') ?? pre;
|
const target = pre.querySelector('code') ?? pre;
|
||||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||||
}
|
}
|
||||||
|
addCopyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changed() {
|
function changed() {
|
||||||
@@ -814,8 +907,30 @@
|
|||||||
*
|
*
|
||||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||||
* blive i den. */
|
* blive i den. */
|
||||||
max-width: 46rem;
|
/* Bredden vokser med ruden, men ikke i det uendelige.
|
||||||
|
*
|
||||||
|
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
|
||||||
|
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
|
||||||
|
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
|
||||||
|
* bredde, blev klemt sammen uden grund.
|
||||||
|
*
|
||||||
|
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
||||||
|
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
||||||
|
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
||||||
|
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
|
||||||
|
* der afgør det.
|
||||||
|
*
|
||||||
|
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
|
||||||
|
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
|
||||||
|
* håndflade tom rude til højre for sig og svævede midt i arket. Med
|
||||||
|
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
|
||||||
|
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
|
||||||
|
*
|
||||||
|
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
|
||||||
|
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
|
||||||
|
* polstringen. Uden det ville det her give en vandret rullebjælke. */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
@@ -915,6 +1030,40 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kopiér-knappen, øverst til højre i blokken.
|
||||||
|
*
|
||||||
|
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
|
||||||
|
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
|
||||||
|
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
|
||||||
|
* opmærksomhed fra koden.
|
||||||
|
*
|
||||||
|
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
|
||||||
|
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
|
||||||
|
.page :global(pre) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(pre .copy) {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
color: rgb(255 255 255 / 0.55);
|
||||||
|
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(pre:hover .copy),
|
||||||
|
.page :global(pre .copy:hover),
|
||||||
|
.page :global(pre .copy:focus-visible) {
|
||||||
|
background: rgb(255 255 255 / 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
/* 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
|
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,
|
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||||
|
|||||||
@@ -221,6 +221,13 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
padding: var(--s3) var(--s2);
|
padding: var(--s3) var(--s2);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
/* Luft under stregen.
|
||||||
|
*
|
||||||
|
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
|
||||||
|
* sad den første sektion klods op ad den — så den læste som en kant om
|
||||||
|
* sektionen frem for som en afslutning på feltet over. En streg skal have
|
||||||
|
* plads på begge sider for at kunne ses som en streg. */
|
||||||
|
margin-bottom: var(--s4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plus {
|
.plus {
|
||||||
|
|||||||
@@ -145,7 +145,16 @@
|
|||||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
// 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
|
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||||
// eksisterende en anden.
|
// eksisterende en anden.
|
||||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
{ 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)
|
].filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -354,6 +363,15 @@
|
|||||||
overProject = project.id;
|
overProject = project.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Et slip på et projekt, uanset hvad der bliver sluppet.
|
||||||
|
*
|
||||||
|
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
|
||||||
|
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
|
||||||
|
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
|
||||||
|
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
|
||||||
|
* virke, helt indtil man slap.
|
||||||
|
*/
|
||||||
async function onTaskDrop(event, project) {
|
async function onTaskDrop(event, project) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
overProject = null;
|
overProject = null;
|
||||||
@@ -362,9 +380,12 @@
|
|||||||
// The same target takes both. A note dropped on a project is filed there,
|
// 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
|
// which is also how it gets shared — one gesture, one meaning, and the
|
||||||
// sidebar already reads as "put this here".
|
// sidebar already reads as "put this here".
|
||||||
const noteID = dragged(event, NOTE);
|
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
|
||||||
if (noteID) {
|
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
|
||||||
await app.moveNoteToProject(noteID, project.id);
|
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
|
||||||
|
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
|
||||||
|
if (noteIDs.length) {
|
||||||
|
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +509,26 @@
|
|||||||
/>
|
/>
|
||||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
</svg>
|
</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}
|
{:else}
|
||||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -540,14 +581,17 @@
|
|||||||
ondragstart={(e) => sortable && onDragStart(e, project)}
|
ondragstart={(e) => sortable && onDragStart(e, project)}
|
||||||
ondragend={clearDrag}
|
ondragend={clearDrag}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (carries(e, TASK)) onTaskDragOver(e, project);
|
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
|
||||||
else if (sortable) onDragOver(e, project);
|
else if (sortable) onDragOver(e, project);
|
||||||
}}
|
}}
|
||||||
ondragleave={() => {
|
ondragleave={() => {
|
||||||
overId = null;
|
overId = null;
|
||||||
overProject = null;
|
overProject = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
ondrop={(e) =>
|
||||||
|
carries(e, TASK) || carries(e, NOTE)
|
||||||
|
? onTaskDrop(e, project)
|
||||||
|
: sortable && onDrop(e, project)}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
{projectName(project)}
|
{projectName(project)}
|
||||||
@@ -595,6 +639,17 @@
|
|||||||
</h2>
|
</h2>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Alt herunder ruller; mærket og de faste visninger gør ikke.
|
||||||
|
Hele sidebjælken rullede før, så runen og I dag forsvandt op over kanten,
|
||||||
|
så snart der var projekter nok — og de er dem, man er på vej hen til
|
||||||
|
oftest. En rulleboks indeni frem for `position: sticky` udenom, fordi et
|
||||||
|
klæbende hoved efterlader rullebjælken i fuld højde ved siden af sig: den
|
||||||
|
skal begynde under hovedet, ikke bag det.
|
||||||
|
|
||||||
|
Snippet-erklæringerne bliver liggende udenfor. De tegner ikke noget selv,
|
||||||
|
men de kaldes oppe fra .views, og en snippet erklæret herinde hører kun
|
||||||
|
til herinde. -->
|
||||||
|
<div class="scroller">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<!-- The heading is also the drop target for "no group": without it, a project
|
<!-- The heading is also the drop target for "no group": without it, a project
|
||||||
dragged into a group could only be got out again by emptying the group,
|
dragged into a group could only be got out again by emptying the group,
|
||||||
@@ -891,6 +946,7 @@
|
|||||||
<span class="signout">{t('nav.signOut')}</span>
|
<span class="signout">{t('nav.signOut')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||||
A drag handle with no keyboard equivalent is a setting some people simply
|
A drag handle with no keyboard equivalent is a setting some people simply
|
||||||
cannot reach. -->
|
cannot reach. -->
|
||||||
@@ -926,11 +982,31 @@
|
|||||||
padding: var(--s4) var(--s3);
|
padding: var(--s4) var(--s3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||||
|
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||||
|
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||||
|
overflow: hidden;
|
||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `min-height: 0`, ellers nægter et flex-element at blive lavere end sit
|
||||||
|
indhold, og boksen ville vokse ned gennem bunden af skærmen i stedet for at
|
||||||
|
rulle — samme fælde som `min-width: 0` på kontonavnet længere nede.
|
||||||
|
|
||||||
|
Ikke `overflow-x: hidden` her: `overflow-y: auto` gør den anden akse til
|
||||||
|
`auto` af sig selv, og en vandret rullebjælke under menuen er en fejl i en
|
||||||
|
række, ikke i boksen. At klippe den ville skjule fejlen; prøven måler begge
|
||||||
|
bokse. */
|
||||||
|
.scroller {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||||
the border; this only widens what the pointer hits.
|
the border; this only widens what the pointer hits.
|
||||||
|
|
||||||
@@ -1169,6 +1245,11 @@
|
|||||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||||
stays put while the group is folded, so a folded group still shows the way
|
stays put while the group is folded, so a folded group still shows the way
|
||||||
back open. */
|
back open. */
|
||||||
|
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
||||||
|
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
||||||
|
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
||||||
|
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
||||||
|
skifte, uden noget at male henover den med. */
|
||||||
.chevron-button {
|
.chevron-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(var(--s2) - 5px);
|
left: calc(var(--s2) - 5px);
|
||||||
@@ -1179,7 +1260,13 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
background: var(--surface);
|
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
||||||
|
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
||||||
|
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
||||||
|
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
||||||
|
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
||||||
|
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-head:hover .chevron-button,
|
.folder-head:hover .chevron-button,
|
||||||
@@ -1188,9 +1275,18 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
||||||
|
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
||||||
|
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
||||||
|
pilen kom frem. */
|
||||||
|
.folder-head:hover .group-dot,
|
||||||
|
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
||||||
|
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.chevron-button:hover {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fold a,
|
.fold a,
|
||||||
|
|||||||
@@ -59,7 +59,17 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
||||||
// an error for that reads as though the key was rejected.
|
// an error for that reads as though the key was rejected.
|
||||||
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
if (e?.name === 'NotAllowedError' || e?.message === 'cancelled') {
|
||||||
|
// nothing to say
|
||||||
|
} else if (e?.code === 'unauthorized') {
|
||||||
|
// Not "wrong email or password". There is no email here and no
|
||||||
|
// password — the device signed, and the signature was refused. The
|
||||||
|
// generic 401 text sent somebody looking through their account for a
|
||||||
|
// typo that was never there; what had actually happened was that the
|
||||||
|
// site had moved from http to https, and a passkey is bound to the
|
||||||
|
// origin it was made on, scheme included.
|
||||||
|
error = t('passkey.notAccepted');
|
||||||
|
} else {
|
||||||
error = humanMessage(e);
|
error = humanMessage(e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* necessarily the last one sent.
|
* necessarily the last one sent.
|
||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, projectName } from '$lib/stores.svelte.js';
|
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { REPEATS } from '$lib/when.js';
|
import { REPEATS } from '$lib/when.js';
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
let description = $state('');
|
let description = $state('');
|
||||||
let priority = $state(4);
|
let priority = $state(4);
|
||||||
let dueDate = $state('');
|
let dueDate = $state('');
|
||||||
|
let dueTime = $state('');
|
||||||
let labels = $state('');
|
let labels = $state('');
|
||||||
let projectId = $state('');
|
let projectId = $state('');
|
||||||
let recurrence = $state('');
|
let recurrence = $state('');
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
description = task.description ?? '';
|
description = task.description ?? '';
|
||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
dueDate = task.due_date ?? '';
|
dueDate = task.due_date ?? '';
|
||||||
|
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||||
labels = (task.labels ?? []).join(', ');
|
labels = (task.labels ?? []).join(', ');
|
||||||
projectId = task.project_id;
|
projectId = task.project_id;
|
||||||
recurrence = task.recurrence_rule ?? '';
|
recurrence = task.recurrence_rule ?? '';
|
||||||
@@ -407,14 +409,34 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Dato og klokkeslæt gemmes sammen, altid.
|
||||||
|
|
||||||
|
Serveren læser de to felter som ét: kommer datoen uden en tid, *ryddes*
|
||||||
|
tiden. Så et gem af datoen alene tog klokkeslættet med sig — man rettede
|
||||||
|
dagen og mistede timen, uden at noget sagde det.
|
||||||
|
|
||||||
|
Tiden er tom, indtil nogen sætter den. En opgave uden klokkeslæt er det
|
||||||
|
almindelige, og et felt, der åbner på 00:00, er et felt, der påstår, at
|
||||||
|
noget skal ske ved midnat.
|
||||||
|
-->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="due">{t('detail.due')}</label>
|
<label for="due">{t('detail.due')}</label>
|
||||||
|
<div class="due">
|
||||||
<input
|
<input
|
||||||
id="due"
|
id="due"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={dueDate}
|
bind:value={dueDate}
|
||||||
onchange={() => save({ due_date: dueDate })}
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label={t('detail.dueTime')}
|
||||||
|
bind:value={dueTime}
|
||||||
|
disabled={!dueDate}
|
||||||
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -692,6 +714,27 @@
|
|||||||
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
|
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dato og tid på én linje: de er ét svar på ét spørgsmål, og tiden er den
|
||||||
|
smalle halvdel af det. */
|
||||||
|
.due {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due input[type='date'] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due input[type='time'] {
|
||||||
|
flex: none;
|
||||||
|
width: 7.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const TASK = 'application/x-verdande-task';
|
|||||||
export const PROJECT = 'application/x-verdande-project';
|
export const PROJECT = 'application/x-verdande-project';
|
||||||
export const GROUP = 'application/x-verdande-group';
|
export const GROUP = 'application/x-verdande-group';
|
||||||
export const NOTE = 'application/x-verdande-note';
|
export const NOTE = 'application/x-verdande-note';
|
||||||
|
export const SECTION = 'application/x-verdande-section';
|
||||||
|
|
||||||
/** Starts a drag carrying one id of the given kind. */
|
/** Starts a drag carrying one id of the given kind. */
|
||||||
export function startDrag(event, type, id) {
|
export function startDrag(event, type, id) {
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ export const da = {
|
|||||||
'beacon.byVersion': 'Efter version (30 dage):',
|
'beacon.byVersion': 'Efter version (30 dage):',
|
||||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
'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.',
|
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||||
|
'notes.newHere': 'Ny note',
|
||||||
|
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
||||||
|
'notes.clearSearch': 'Ryd søgningen',
|
||||||
|
'notes.found': '{count} noter',
|
||||||
|
'notes.picked': '{count} markeret',
|
||||||
|
'notes.archive': 'Arkivér',
|
||||||
|
'notes.unarchive': 'Tag frem igen',
|
||||||
|
'notes.clearPick': 'Ryd markering',
|
||||||
|
'notes.showArchive': 'Vis arkivet',
|
||||||
|
'notes.deleteMany': 'Slet {count} noter?',
|
||||||
|
'notes.archiveOne': 'Arkivér noten',
|
||||||
|
'notes.emptyArchive': 'Arkivet er tomt.',
|
||||||
|
'notes.copyCode': 'Kopiér',
|
||||||
|
'notes.copied': 'Kopieret',
|
||||||
|
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.showSource': 'Vis som kode',
|
'notes.showSource': 'Vis som kode',
|
||||||
'notes.source': 'Notens kildetekst',
|
'notes.source': 'Notens kildetekst',
|
||||||
@@ -80,6 +95,7 @@ export const da = {
|
|||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
'nav.inbox': 'Indbakke',
|
'nav.inbox': 'Indbakke',
|
||||||
|
'nav.calendar': 'Kalender',
|
||||||
'nav.settings': 'Indstillinger',
|
'nav.settings': 'Indstillinger',
|
||||||
'nav.signOut': 'Log ud',
|
'nav.signOut': 'Log ud',
|
||||||
'nav.main': 'Hovedmenu',
|
'nav.main': 'Hovedmenu',
|
||||||
@@ -226,6 +242,13 @@ export const da = {
|
|||||||
'push.turnOff': 'Slå fra',
|
'push.turnOff': 'Slå fra',
|
||||||
'push.turnOn': 'Slå til på denne enhed',
|
'push.turnOn': 'Slå til på denne enhed',
|
||||||
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
||||||
|
'push.test': 'Send en prøve',
|
||||||
|
'push.testing': 'Sender …',
|
||||||
|
'push.testTitle': 'Prøve fra verdande',
|
||||||
|
'push.testBody': 'Kom den frem, virker notifikationer på denne enhed.',
|
||||||
|
'push.testSent': 'Sendt til {n} enhed(er). Kom den ikke frem, er det browseren eller styresystemet, der holder den tilbage — se om verdande må sende notifikationer.',
|
||||||
|
'push.testNone': 'Ingen enheder er tilmeldt endnu. Slå notifikationer til her på enheden først.',
|
||||||
|
'push.testFailed': '{service} afviste den: {reason}',
|
||||||
'push.recent': 'Seneste',
|
'push.recent': 'Seneste',
|
||||||
'push.none': 'Ingenting endnu.',
|
'push.none': 'Ingenting endnu.',
|
||||||
'push.open': 'Åbn',
|
'push.open': 'Åbn',
|
||||||
@@ -535,6 +558,7 @@ export const da = {
|
|||||||
'detail.p1': 'P1 — haster',
|
'detail.p1': 'P1 — haster',
|
||||||
'detail.none': 'Ingen',
|
'detail.none': 'Ingen',
|
||||||
'detail.due': 'Forfalder',
|
'detail.due': 'Forfalder',
|
||||||
|
'detail.dueTime': 'Klokkeslæt',
|
||||||
'detail.labels': 'Etiketter',
|
'detail.labels': 'Etiketter',
|
||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
'detail.project': 'Projekt',
|
'detail.project': 'Projekt',
|
||||||
@@ -593,7 +617,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.',
|
'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.oauthClient': 'OAuth-klient hos Google',
|
||||||
'int.registerHint':
|
'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.redirectURI': 'Godkendt redirect-URI',
|
||||||
'int.clientID': 'Klient-id',
|
'int.clientID': 'Klient-id',
|
||||||
'int.clientSecret': 'Klienthemmelighed',
|
'int.clientSecret': 'Klienthemmelighed',
|
||||||
@@ -633,6 +657,32 @@ export const da = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
'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.gmailFetchedOne': '{n} opgave hentet.',
|
||||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||||
@@ -647,6 +697,18 @@ export const da = {
|
|||||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||||
'int.gmailOther': 'Gmail svarede: {what}',
|
'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 ----------------------------------------------------------------------
|
// --- a project ----------------------------------------------------------------------
|
||||||
'project.name': 'Projektets navn',
|
'project.name': 'Projektets navn',
|
||||||
'project.clickToRename': 'Klik for at omdøbe',
|
'project.clickToRename': 'Klik for at omdøbe',
|
||||||
@@ -760,6 +822,8 @@ export const da = {
|
|||||||
'passkey.hint':
|
'passkey.hint':
|
||||||
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
|
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
|
||||||
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
|
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
|
||||||
|
'passkey.notAccepted':
|
||||||
|
'Nøglen blev ikke godtaget. Sker det for en nøgle, der virkede før, er adressen typisk skiftet — en passkey hører til præcis den adresse, den blev lavet på, skema og alt.',
|
||||||
'passkey.none': 'Ingen nøgler endnu.',
|
'passkey.none': 'Ingen nøgler endnu.',
|
||||||
'passkey.name': 'Navn på nøglen',
|
'passkey.name': 'Navn på nøglen',
|
||||||
'passkey.namePlaceholder': 'fx min bærbare',
|
'passkey.namePlaceholder': 'fx min bærbare',
|
||||||
@@ -789,6 +853,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.',
|
'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.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.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||||
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ export const en = {
|
|||||||
'beacon.byVersion': 'By version (30 days):',
|
'beacon.byVersion': 'By version (30 days):',
|
||||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
'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.',
|
'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.newHere': 'New note',
|
||||||
|
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
|
||||||
|
'notes.clearSearch': 'Clear the search',
|
||||||
|
'notes.found': '{count} notes',
|
||||||
|
'notes.picked': '{count} selected',
|
||||||
|
'notes.archive': 'Archive',
|
||||||
|
'notes.unarchive': 'Bring back',
|
||||||
|
'notes.clearPick': 'Clear selection',
|
||||||
|
'notes.showArchive': 'Show the archive',
|
||||||
|
'notes.deleteMany': 'Delete {count} notes?',
|
||||||
|
'notes.archiveOne': 'Archive this note',
|
||||||
|
'notes.emptyArchive': 'The archive is empty.',
|
||||||
|
'notes.copyCode': 'Copy',
|
||||||
|
'notes.copied': 'Copied',
|
||||||
|
'notes.copyFailed': 'Could not copy',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.showSource': 'Show as code',
|
'notes.showSource': 'Show as code',
|
||||||
'notes.source': 'Note source',
|
'notes.source': 'Note source',
|
||||||
@@ -76,6 +91,7 @@ export const en = {
|
|||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
'nav.inbox': 'Inbox',
|
'nav.inbox': 'Inbox',
|
||||||
|
'nav.calendar': 'Calendar',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.signOut': 'Sign out',
|
'nav.signOut': 'Sign out',
|
||||||
'nav.main': 'Main menu',
|
'nav.main': 'Main menu',
|
||||||
@@ -222,6 +238,13 @@ export const en = {
|
|||||||
'push.turnOff': 'Turn off',
|
'push.turnOff': 'Turn off',
|
||||||
'push.turnOn': 'Turn on for this device',
|
'push.turnOn': 'Turn on for this device',
|
||||||
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
||||||
|
'push.test': 'Send a test',
|
||||||
|
'push.testing': 'Sending …',
|
||||||
|
'push.testTitle': 'Test from verdande',
|
||||||
|
'push.testBody': 'If this arrived, notifications work on this device.',
|
||||||
|
'push.testSent': 'Sent to {n} device(s). If nothing arrived, the browser or the operating system is holding it back — check that verdande is allowed to send notifications.',
|
||||||
|
'push.testNone': 'No devices are registered yet. Turn notifications on here first.',
|
||||||
|
'push.testFailed': '{service} refused it: {reason}',
|
||||||
'push.recent': 'Recent',
|
'push.recent': 'Recent',
|
||||||
'push.none': 'Nothing yet.',
|
'push.none': 'Nothing yet.',
|
||||||
'push.open': 'Open',
|
'push.open': 'Open',
|
||||||
@@ -531,6 +554,7 @@ export const en = {
|
|||||||
'detail.p1': 'P1 — urgent',
|
'detail.p1': 'P1 — urgent',
|
||||||
'detail.none': 'None',
|
'detail.none': 'None',
|
||||||
'detail.due': 'Due',
|
'detail.due': 'Due',
|
||||||
|
'detail.dueTime': 'Time',
|
||||||
'detail.labels': 'Labels',
|
'detail.labels': 'Labels',
|
||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
'detail.project': 'Project',
|
'detail.project': 'Project',
|
||||||
@@ -589,7 +613,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.',
|
'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.oauthClient': 'OAuth client at Google',
|
||||||
'int.registerHint':
|
'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.redirectURI': 'Authorised redirect URI',
|
||||||
'int.clientID': 'Client ID',
|
'int.clientID': 'Client ID',
|
||||||
'int.clientSecret': 'Client secret',
|
'int.clientSecret': 'Client secret',
|
||||||
@@ -629,6 +653,31 @@ export const en = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'The username is your email. The password is an API token — not your real password.',
|
'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.gmailFetchedOne': '{n} task fetched.',
|
||||||
'int.gmailFetchedMany': '{n} tasks fetched.',
|
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||||
'int.gmailNothingNew': 'No new messages.',
|
'int.gmailNothingNew': 'No new messages.',
|
||||||
@@ -642,6 +691,17 @@ export const en = {
|
|||||||
'int.gmailDenied': 'Access was refused at Google.',
|
'int.gmailDenied': 'Access was refused at Google.',
|
||||||
'int.gmailOther': 'Gmail replied: {what}',
|
'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 ----------------------------------------------------------------------
|
// --- a project ----------------------------------------------------------------------
|
||||||
'project.name': 'The project’s name',
|
'project.name': 'The project’s name',
|
||||||
'project.clickToRename': 'Click to rename',
|
'project.clickToRename': 'Click to rename',
|
||||||
@@ -755,6 +815,8 @@ export const en = {
|
|||||||
'passkey.hint':
|
'passkey.hint':
|
||||||
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
|
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
|
||||||
'passkey.unsupported': 'This browser does not support passkeys.',
|
'passkey.unsupported': 'This browser does not support passkeys.',
|
||||||
|
'passkey.notAccepted':
|
||||||
|
'That key was not accepted. If it worked before, the address has usually changed — a passkey belongs to the exact address it was made on, scheme and all.',
|
||||||
'passkey.none': 'No keys yet.',
|
'passkey.none': 'No keys yet.',
|
||||||
'passkey.name': 'Name for the key',
|
'passkey.name': 'Name for the key',
|
||||||
'passkey.namePlaceholder': 'e.g. my laptop',
|
'passkey.namePlaceholder': 'e.g. my laptop',
|
||||||
@@ -784,6 +846,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.',
|
'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.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.mailboxRefused': 'The mailbox could not be read.',
|
||||||
'error.mailboxSaid': 'The mailbox answered: {what}',
|
'error.mailboxSaid': 'The mailbox answered: {what}',
|
||||||
|
|||||||
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
|
|||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
emit('```' + lang);
|
emit('```' + lang);
|
||||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
||||||
|
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
||||||
|
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
||||||
|
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
||||||
|
const code = [...child.childNodes]
|
||||||
|
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||||
|
.map((n) => n.textContent ?? '')
|
||||||
|
.join('');
|
||||||
|
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||||
emit(l);
|
emit(l);
|
||||||
}
|
}
|
||||||
emit('```');
|
emit('```');
|
||||||
|
|||||||
@@ -372,10 +372,35 @@ class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
|
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
|
||||||
async reschedule(id, date) {
|
/**
|
||||||
|
* Flytter en opgave til en dag — og afgør, hvad der sker med klokkeslættet.
|
||||||
|
*
|
||||||
|
* Serveren læser `due_date` og `due_time` sammen: kommer datoen uden en tid,
|
||||||
|
* *ryddes* tiden. Det er den rigtige regel for et felt, man skriver i, og den
|
||||||
|
* forkerte for et træk i en kalender — en opgave, der stod klokken 14, mistede
|
||||||
|
* sit klokkeslæt ved at blive trukket til dagen efter, uden at nogen bad om
|
||||||
|
* det, og uden at noget sagde det.
|
||||||
|
*
|
||||||
|
* Så tiden siges nu højt, tre måder:
|
||||||
|
*
|
||||||
|
* time === undefined behold den, opgaven har
|
||||||
|
* time === '' ryd den — det er heldagsbåndet
|
||||||
|
* time === 'HH:MM' sæt den — det er et sted på døgnet
|
||||||
|
*
|
||||||
|
* `undefined` og `''` er med vilje to forskellige ting her. De ligner hinanden
|
||||||
|
* i JavaScript, og det er præcis derfor de skal skilles ad ét sted frem for at
|
||||||
|
* blive forvekslet fire steder.
|
||||||
|
*/
|
||||||
|
async reschedule(id, date, time) {
|
||||||
const previous = this.get(id);
|
const previous = this.get(id);
|
||||||
if (!previous || previous.due_date === date) return;
|
if (!previous) return;
|
||||||
await this.update(id, { due_date: date });
|
|
||||||
|
const clock =
|
||||||
|
time === undefined ? (previous.due_datetime ? clockOf(previous.due_datetime) : '') : time;
|
||||||
|
if (previous.due_date === date && (previous.due_datetime ? clockOf(previous.due_datetime) : '') === clock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.update(id, { due_date: date, due_time: clock });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id) {
|
async remove(id) {
|
||||||
@@ -729,6 +754,20 @@ class AppState {
|
|||||||
* Only while it still carries a name the server gave it. Rename it and that is your
|
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||||
* name for it, in every language — the same rule as any other project.
|
* name for it, in every language — the same rule as any other project.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Klokkeslættet på en opgave, som "HH:MM" i den maskine, der ser på det.
|
||||||
|
*
|
||||||
|
* `Date` og ikke en strengudskæring: `due_datetime` kommer som UTC med et Z på,
|
||||||
|
* og de fjorten på et stempel er ikke klokken fjorten for den, der læser det.
|
||||||
|
* TaskRow viser tiden på nøjagtig samme måde, og de to steder skal vise det
|
||||||
|
* samme tal.
|
||||||
|
*/
|
||||||
|
export function clockOf(stamp) {
|
||||||
|
const at = new Date(stamp);
|
||||||
|
if (Number.isNaN(at.getTime())) return '';
|
||||||
|
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||||
|
|
||||||
export function projectName(project) {
|
export function projectName(project) {
|
||||||
@@ -835,6 +874,43 @@ function read() {
|
|||||||
|
|
||||||
export const upcomingView = new UpcomingView();
|
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.
|
* Whether the lists show what has already been finished.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -437,7 +437,6 @@
|
|||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
padding-right: var(--s3);
|
padding-right: var(--s3);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@
|
|||||||
let recoveryCodes = $state([]);
|
let recoveryCodes = $state([]);
|
||||||
let remaining = $state(null);
|
let remaining = $state(null);
|
||||||
let disablePassword = $state('');
|
let disablePassword = $state('');
|
||||||
|
let enablePassword = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (app.user?.totp_enabled) {
|
if (app.user?.totp_enabled) {
|
||||||
@@ -150,13 +151,21 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function beginTOTP() {
|
async function beginTOTP(event) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
totpErrors = {};
|
||||||
try {
|
try {
|
||||||
const r = await api.totpSetup();
|
// Adgangskoden, ligesom når man slår den fra. At slå den til er mindst
|
||||||
|
// lige så indgribende som at slå den fra — det afgør, hvilken enhed der
|
||||||
|
// fra nu af må sige ja — og en dør, der åbnes lettere end den lukkes, er
|
||||||
|
// ikke en dør.
|
||||||
|
const r = await api.totpSetup(enablePassword);
|
||||||
|
enablePassword = '';
|
||||||
totpSecret = r.secret;
|
totpSecret = r.secret;
|
||||||
totpURI = r.uri;
|
totpURI = r.uri;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
totpErrors = e.fields ?? {};
|
||||||
|
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +484,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="row">
|
<form onsubmit={beginTOTP}>
|
||||||
<button class="primary" onclick={beginTOTP}>{t('account.totpOn')}</button>
|
<div class="field">
|
||||||
|
<label for="enable-pw">{t('account.password')}</label>
|
||||||
|
<input
|
||||||
|
id="enable-pw"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
bind:value={enablePassword}
|
||||||
|
/>
|
||||||
|
{#if totpErrors.password}<p class="error">{totpErrors.password}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" type="submit">{t('account.totpOn')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
/** Gmail, the calendar feed, and the mail-to-task address. */
|
/** Gmail, Google Calendar, the calendar feed, and the mail-to-task address. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { t, plural } from '$lib/i18n.svelte.js';
|
import { t, plural, tag } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
// --- Gmail ------------------------------------------------------------------
|
// --- Gmail ------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -136,6 +136,120 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Google Calendar -----------------------------------------------------------
|
||||||
|
|
||||||
|
// Its own panel rather than a second mode inside the Gmail one. They sign in
|
||||||
|
// through the same registration and are otherwise nothing alike: one turns mail
|
||||||
|
// into work you own, the other shows a copy of somebody else's calendar and
|
||||||
|
// never writes to it.
|
||||||
|
let calendar = $state(null);
|
||||||
|
let savingCalendars = $state(false);
|
||||||
|
let calendarsSaved = $state(false);
|
||||||
|
let syncingCalendar = $state(false);
|
||||||
|
/** The ids ticked in the form, which is not what is stored until Save. */
|
||||||
|
let shownCalendars = $state([]);
|
||||||
|
|
||||||
|
const CALENDAR_CALLBACK = {
|
||||||
|
connected: { tone: 'ok', text: t('int.calendarConnected') },
|
||||||
|
state: { tone: 'bad', text: t('int.calendarState') },
|
||||||
|
expired: { tone: 'bad', text: t('int.calendarExpired') },
|
||||||
|
invalid: { tone: 'bad', text: t('int.calendarInvalid') },
|
||||||
|
failed: { tone: 'bad', text: t('int.calendarFailed') },
|
||||||
|
norefresh: { tone: 'bad', text: t('int.calendarNoRefresh') },
|
||||||
|
access_denied: { tone: 'bad', text: t('int.calendarDenied') },
|
||||||
|
// The one worth naming. An OAuth app registered as Internal accepts only
|
||||||
|
// accounts inside the organisation, and Google's own screen does not say
|
||||||
|
// that in words anybody could act on.
|
||||||
|
org_internal: { tone: 'bad', text: t('int.calendarOrgInternal') }
|
||||||
|
};
|
||||||
|
|
||||||
|
let calendarCallback = $derived.by(() => {
|
||||||
|
const value = $page.url.searchParams.get('calendar');
|
||||||
|
if (!value) return null;
|
||||||
|
return CALENDAR_CALLBACK[value] ?? { tone: 'bad', text: t('int.calendarOther', { what: value }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCalendar() {
|
||||||
|
try {
|
||||||
|
calendar = await api.getCalendar();
|
||||||
|
shownCalendars = calendar.calendars.filter((c) => c.shown).map((c) => c.id);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectCalendar() {
|
||||||
|
try {
|
||||||
|
const { url } = await api.authorizeCalendar();
|
||||||
|
location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCalendars(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
savingCalendars = true;
|
||||||
|
calendarsSaved = false;
|
||||||
|
try {
|
||||||
|
await api.setCalendars(shownCalendars);
|
||||||
|
calendarsSaved = true;
|
||||||
|
// And fetch, without being asked again.
|
||||||
|
//
|
||||||
|
// Ticking a calendar is somebody saying "show me this one". Saving the
|
||||||
|
// choice and leaving the grid empty until the sweep comes round a quarter
|
||||||
|
// of an hour later is a calendar that says a fortnight is clear — and the
|
||||||
|
// person is on this page, watching, with the button under their hand.
|
||||||
|
// Also what refreshes the panel: syncCalendarNow reloads it when it is done.
|
||||||
|
if (shownCalendars.length) {
|
||||||
|
await syncCalendarNow();
|
||||||
|
} else {
|
||||||
|
await loadCalendar();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
savingCalendars = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCalendarNow() {
|
||||||
|
syncingCalendar = true;
|
||||||
|
try {
|
||||||
|
const result = await api.syncCalendar();
|
||||||
|
app.toast(
|
||||||
|
result?.events
|
||||||
|
? plural(result.events, 'int.calendarFetchedOne', 'int.calendarFetchedMany')
|
||||||
|
: t('int.calendarNothingNew')
|
||||||
|
);
|
||||||
|
await loadCalendar();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
syncingCalendar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectCalendar() {
|
||||||
|
if (!confirm(t('int.disconnectCalendarQuestion'))) return;
|
||||||
|
try {
|
||||||
|
await api.disconnectCalendar();
|
||||||
|
await loadCalendar();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCalendar(id) {
|
||||||
|
shownCalendars = shownCalendars.includes(id)
|
||||||
|
? shownCalendars.filter((c) => c !== id)
|
||||||
|
: [...shownCalendars, id];
|
||||||
|
}
|
||||||
|
|
||||||
// --- calendar feed -------------------------------------------------------------
|
// --- calendar feed -------------------------------------------------------------
|
||||||
|
|
||||||
let feedURL = $state('');
|
let feedURL = $state('');
|
||||||
@@ -355,6 +469,87 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>{t('int.calendar')}</h2>
|
||||||
|
<p class="hint">{t('int.calendarHint')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if calendarCallback}
|
||||||
|
<p class="callback" data-tone={calendarCallback.tone}>{calendarCallback.text}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if calendar === null}
|
||||||
|
<p class="empty">…</p>
|
||||||
|
{:else if !calendar.connected}
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" onclick={connectCalendar} disabled={!calendar.has_client}>
|
||||||
|
{t('int.connectCalendar')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The registration is the Gmail panel's — one client, two features — so
|
||||||
|
this only spells out the second redirect URI, which has to be added to
|
||||||
|
it. It is the single most likely thing to be wrong, and the error Google
|
||||||
|
gives for it names neither value. -->
|
||||||
|
{#if app.user?.is_admin}
|
||||||
|
<div class="field">
|
||||||
|
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||||
|
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
||||||
|
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if !calendar.has_client}
|
||||||
|
<p class="hint">{t('int.noClient')}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="hint">
|
||||||
|
{t('int.calendarConnectedAs')}
|
||||||
|
<strong>{calendar.account || t('int.unknownAccount')}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onsubmit={saveCalendars}>
|
||||||
|
{#if calendar.calendars.length}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t('int.whichCalendars')}</legend>
|
||||||
|
{#each calendar.calendars as c (c.id)}
|
||||||
|
<label class="pick">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shownCalendars.includes(c.id)}
|
||||||
|
onchange={() => toggleCalendar(c.id)}
|
||||||
|
/>
|
||||||
|
<!-- Google's own colour for the calendar, which is what the grid
|
||||||
|
draws its events in. Told apart by the colour the person
|
||||||
|
already knows it by, not by one of verdande's ten names. -->
|
||||||
|
<span class="swatch" style:background={c.colour || 'var(--line-strong)'}></span>
|
||||||
|
{c.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">{t('int.noCalendars')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
{calendar.last_sync_at
|
||||||
|
? t('int.calendarLastSync', {
|
||||||
|
when: new Date(calendar.last_sync_at).toLocaleString(tag())
|
||||||
|
})
|
||||||
|
: t('int.calendarNeverSynced')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" type="submit" disabled={savingCalendars}>{t('int.save')}</button>
|
||||||
|
{#if calendarsSaved}<span class="saved">{t('int.saved')}</span>{/if}
|
||||||
|
<button class="secondary" onclick={syncCalendarNow} disabled={syncingCalendar}>
|
||||||
|
{syncingCalendar ? t('int.fetching') : t('int.fetchNow')}
|
||||||
|
</button>
|
||||||
|
<button class="danger" onclick={disconnectCalendar}>{t('int.disconnect')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.feed')}</h2>
|
<h2>{t('int.feed')}</h2>
|
||||||
@@ -529,6 +724,38 @@
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--s3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A solid mark beside a label that carries the meaning, so it only has to be
|
||||||
|
tellable from its neighbours and visible on both near-black and white — the
|
||||||
|
same job the project colours' dots do. */
|
||||||
|
.swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let testing = $state(false);
|
||||||
|
let testResult = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Én notifikation, hele vejen gennem den rigtige rute, og et svar på hvad der
|
||||||
|
* skete med den.
|
||||||
|
*
|
||||||
|
* Den siger ikke "sendt" og lader det være. Kommer den frem, ved man det, fordi
|
||||||
|
* den ligger på skærmen; kommer den ikke, står der her, hvem der afviste den og
|
||||||
|
* med hvilke ord — og hvis ingen afviste den, står der, at det så er browseren
|
||||||
|
* eller styresystemet, der holder den tilbage. Det er de tre steder, den kan
|
||||||
|
* blive væk, og nu peger svaret på ét af dem.
|
||||||
|
*/
|
||||||
|
async function testPush() {
|
||||||
|
testing = true;
|
||||||
|
testResult = null;
|
||||||
|
try {
|
||||||
|
const r = await api.testPush(t('push.testTitle'), t('push.testBody'));
|
||||||
|
if (!r.subscriptions) {
|
||||||
|
testResult = { bad: true, text: t('push.testNone') };
|
||||||
|
} else if (r.failed?.length) {
|
||||||
|
testResult = {
|
||||||
|
bad: true,
|
||||||
|
text: r.failed
|
||||||
|
.map((f) => t('push.testFailed', { service: f.service, reason: f.reason }))
|
||||||
|
.join(' ')
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
testResult = { bad: false, text: t('push.testSent', { n: r.sent }) };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
testResult = { bad: true, text: humanMessage(e) };
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function disablePush() {
|
async function disablePush() {
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
@@ -165,7 +202,18 @@
|
|||||||
<p class="hint">{t('push.on')}</p>
|
<p class="hint">{t('push.on')}</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
||||||
|
<!-- "Til" er ikke det samme som "virker".
|
||||||
|
Ruden sagde til, så snart der var et abonnement, og en push, tjenesten
|
||||||
|
afviste, gik i en logline på Debug, som ingen ser. Tre forskellige
|
||||||
|
problemer — browseren tilmeldte sig aldrig, tjenesten afviste den,
|
||||||
|
der er ikke noget forfaldent endnu — så ens ud fra den her side. -->
|
||||||
|
<button class="secondary" onclick={testPush} disabled={busy || testing}>
|
||||||
|
{testing ? t('push.testing') : t('push.test')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if testResult}
|
||||||
|
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" onclick={enablePush} disabled={busy}>
|
<button class="primary" onclick={enablePush} disabled={busy}>
|
||||||
@@ -261,6 +309,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.what {
|
.what {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Kalender: Google's events and verdande's own dated tasks in one grid.
|
||||||
|
*
|
||||||
|
* The same component Kommende uses, given a second list to draw. Not a second
|
||||||
|
* grid, and the reason is the part that is easy to get subtly wrong: a drop
|
||||||
|
* target has to decide from `dataTransfer.types` alone whether it accepts a
|
||||||
|
* drag, and a second copy of that is a second place for it to be almost right.
|
||||||
|
* A task dragged here reschedules exactly as it does everywhere else, because
|
||||||
|
* it is the same code doing it.
|
||||||
|
*
|
||||||
|
* A month and a week, and no list. The list is Kommende's answer — seven days
|
||||||
|
* of work, in order — and this view is the other question: what does the day
|
||||||
|
* already have in it.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app, calendarView } from '$lib/stores.svelte.js';
|
||||||
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
|
let events = $state([]);
|
||||||
|
/** Null until the first answer, so nothing flashes before it is known. */
|
||||||
|
let window_ = $state(null);
|
||||||
|
let connected = $state(null);
|
||||||
|
/** Whether any calendar in the connected account is ticked. */
|
||||||
|
let anyShown = $state(false);
|
||||||
|
/** The dates the grid is showing, so the notice below can compare against them. */
|
||||||
|
let showing = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.getCalendar()
|
||||||
|
.then((status) => {
|
||||||
|
connected = status.connected;
|
||||||
|
anyShown = (status.calendars ?? []).some((c) => c.shown);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A calendar that could not be asked about is not a reason to withhold
|
||||||
|
// the grid: the tasks in it are verdande's own and do not depend on
|
||||||
|
// Google being reachable.
|
||||||
|
connected = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the dates a grid is showing, whichever grid it is.
|
||||||
|
*
|
||||||
|
* Both halves at once, because they answer the same question about the same
|
||||||
|
* cells. The limit on the tasks is raised well above the default for the reason
|
||||||
|
* Kommende raises it: a busy month across every project is more than two hundred
|
||||||
|
* rows, and a silently truncated grid is a calendar that quietly lies about a
|
||||||
|
* day being clear.
|
||||||
|
*/
|
||||||
|
function loadRange({ from, to }) {
|
||||||
|
showing = { from, to };
|
||||||
|
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
|
||||||
|
api
|
||||||
|
.calendarEvents(from, to)
|
||||||
|
.then((data) => {
|
||||||
|
events = data.events;
|
||||||
|
window_ = { from: data.from, to: data.to };
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// The grid still draws, with the tasks in it. Saying so once beats a
|
||||||
|
// view that renders half of itself and explains nothing.
|
||||||
|
events = [];
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the grid has been paged past the window verdande keeps a copy of.
|
||||||
|
//
|
||||||
|
// Said out loud rather than left as an empty month. A calendar showing no events
|
||||||
|
// looks exactly like a calendar with no events in it, and the difference between
|
||||||
|
// "nothing is booked" and "I have not looked that far" is the whole reason
|
||||||
|
// somebody opened this.
|
||||||
|
let beyond = $derived(
|
||||||
|
connected &&
|
||||||
|
window_ &&
|
||||||
|
showing &&
|
||||||
|
(showing.from < window_.from || showing.to > window_.to)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="view">
|
||||||
|
<header>
|
||||||
|
<h1>{t('cal.title')}</h1>
|
||||||
|
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||||
|
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||||
|
<button
|
||||||
|
class:active={calendarView.mode === value}
|
||||||
|
onclick={() => calendarView.set(value)}
|
||||||
|
aria-pressed={calendarView.mode === value}>{label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if connected === false}
|
||||||
|
<!-- Shown once, and quietly. The grid underneath is already useful without a
|
||||||
|
Google account in it — it is verdande's own calendar, which is what this
|
||||||
|
view was before anything was laid over it. -->
|
||||||
|
<p class="hint">
|
||||||
|
{t('cal.notConnected')}
|
||||||
|
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Connected, and nothing ticked. Worth its own sentence rather than an empty
|
||||||
|
grid: "no calendar is chosen" and "nothing is booked" look identical, and
|
||||||
|
only one of them is something to do about. -->
|
||||||
|
{#if connected && !anyShown}
|
||||||
|
<p class="hint">
|
||||||
|
{t('cal.noneChosen')}
|
||||||
|
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if beyond}
|
||||||
|
<p class="hint">{t('cal.beyondWindow', { from: window_.from, to: window_.to })}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if calendarView.mode === 'week'}
|
||||||
|
<CalendarView span="week" {events} onrange={loadRange} />
|
||||||
|
{:else}
|
||||||
|
<CalendarView {events} onrange={loadRange} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A month grid needs the width a reading column deliberately withholds — the
|
||||||
|
same allowance Kommende and the project page make for theirs. */
|
||||||
|
.view {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--s6) var(--s4) var(--s8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wraps, for the same reason the project header does: a title and a switcher
|
||||||
|
are more than a phone's width, and the switcher belongs underneath rather
|
||||||
|
than squeezing the heading. */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
flex: 1 1 8ch;
|
||||||
|
min-width: 8ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--line);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views button {
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background: var(--ground);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
transition:
|
||||||
|
background var(--fast) var(--ease),
|
||||||
|
color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.views button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.views button.active {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 var(--s4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,7 +53,16 @@
|
|||||||
|
|
||||||
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
||||||
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
||||||
let ordered = $derived(
|
let ordered = $derived.by(() => {
|
||||||
|
// En søgning har allerede en rækkefølge, og den er bedre end nogen af de
|
||||||
|
// tre herunder: serveren har sorteret efter hvor godt hver note matchede.
|
||||||
|
// Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en
|
||||||
|
// søgning i tolv hundrede noter brugbar.
|
||||||
|
if (query.trim()) return notes;
|
||||||
|
return sortedByChoice();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedByChoice = () =>
|
||||||
[...notes].sort((a, b) => {
|
[...notes].sort((a, b) => {
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
if (order === 'title') {
|
if (order === 'title') {
|
||||||
@@ -61,10 +70,106 @@
|
|||||||
}
|
}
|
||||||
const field = order === 'created' ? 'created_at' : 'updated_at';
|
const field = order === 'created' ? 'created_at' : 'updated_at';
|
||||||
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
||||||
})
|
});
|
||||||
);
|
|
||||||
let selected = $state(null);
|
let selected = $state(null);
|
||||||
|
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||||
|
let selectedId = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flere ad gangen.
|
||||||
|
*
|
||||||
|
* Et sæt frem for et flag på noten, fordi markeringen hører til skærmen og ikke
|
||||||
|
* til noten: den forsvinder, når man går et andet sted hen, og den skal ikke
|
||||||
|
* gemmes nogen steder.
|
||||||
|
*
|
||||||
|
* `anchor` er den, et skift-klik måler fra. Uden den ville et interval skulle
|
||||||
|
* gættes ud fra "den valgte", og den flytter sig, hver gang man klikker.
|
||||||
|
*/
|
||||||
|
let picked = $state(new Set());
|
||||||
|
let anchor = $state(null);
|
||||||
|
let showArchive = $state(false);
|
||||||
|
|
||||||
|
function rowClick(event, note) {
|
||||||
|
const many = event.metaKey || event.ctrlKey;
|
||||||
|
const range = event.shiftKey;
|
||||||
|
|
||||||
|
if (!many && !range) {
|
||||||
|
picked = new Set();
|
||||||
|
anchor = note.id;
|
||||||
|
open(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(picked);
|
||||||
|
if (range && anchor) {
|
||||||
|
// Fra ankeret til her, i den rækkefølge listen faktisk står i — ikke i
|
||||||
|
// notens egen, som er en anden, når der er sorteret eller søgt.
|
||||||
|
const ids = ordered.map((n) => n.id);
|
||||||
|
const from = ids.indexOf(anchor);
|
||||||
|
const to = ids.indexOf(note.id);
|
||||||
|
if (from !== -1 && to !== -1) {
|
||||||
|
for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Den, der lige er åbnet, hører med i markeringen: ellers ville et
|
||||||
|
// ⌘-klik nummer to markere to noter og glemme den, man stod på.
|
||||||
|
if (!next.size && selectedId) next.add(selectedId);
|
||||||
|
next.has(note.id) ? next.delete(note.id) : next.add(note.id);
|
||||||
|
anchor = note.id;
|
||||||
|
}
|
||||||
|
picked = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arkivér, tag frem igen, eller slet — for det, der er markeret. */
|
||||||
|
async function bulk(change) {
|
||||||
|
const ids = [...picked];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (change.delete && !confirm(t('notes.deleteMany', { count: ids.length }))) return;
|
||||||
|
try {
|
||||||
|
await api.noteBulk({ ids, ...change });
|
||||||
|
picked = new Set();
|
||||||
|
if (ids.includes(selectedId)) open(null);
|
||||||
|
await load(query);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Én note lagt væk, uden at markere den først. */
|
||||||
|
async function archiveOne(note) {
|
||||||
|
try {
|
||||||
|
await api.noteBulk({ ids: [note.id], archived: !note.archived_at });
|
||||||
|
if (selectedId === note.id) open(null);
|
||||||
|
await load(query);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Det, der står i feltet, og det, der er søgt efter.
|
||||||
|
*
|
||||||
|
* To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert
|
||||||
|
* tastetryk,
|
||||||
|
* så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi
|
||||||
|
* det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger
|
||||||
|
* gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom
|
||||||
|
* tilbage — og svarene kunne lande i den forkerte rækkefølge.
|
||||||
|
*/
|
||||||
|
let draftQuery = $state('');
|
||||||
|
let typing = $state(false);
|
||||||
|
let searchTimer;
|
||||||
|
|
||||||
|
function search(next) {
|
||||||
|
draftQuery = next;
|
||||||
|
typing = next.trim() !== '' && next !== query;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
query = draftQuery;
|
||||||
|
typing = false;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
let status = $state('loading');
|
let status = $state('loading');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
@@ -74,6 +179,7 @@
|
|||||||
let timer;
|
let timer;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
showArchive;
|
||||||
load(query);
|
load(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +187,7 @@
|
|||||||
// opens it rather than dropping the person on a list to find it again.
|
// opens it rather than dropping the person on a list to find it again.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
let asked = $derived($page.url.searchParams.get('note'));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!asked || selected?.id === asked) return;
|
if (!asked || selectedId === asked) return;
|
||||||
const found = notes.find((n) => n.id === asked);
|
const found = notes.find((n) => n.id === asked);
|
||||||
if (found) open(found);
|
if (found) open(found);
|
||||||
else api.note(asked).then(open).catch(() => {});
|
else api.note(asked).then(open).catch(() => {});
|
||||||
@@ -89,19 +195,60 @@
|
|||||||
|
|
||||||
async function load(q) {
|
async function load(q) {
|
||||||
try {
|
try {
|
||||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||||
|
notes = (await api.notes(params)).notes ?? [];
|
||||||
status = 'ready';
|
status = 'ready';
|
||||||
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = 'failed';
|
status = 'failed';
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(note) {
|
/**
|
||||||
|
* Åbner en note — og henter den hel først, hvis listen kun har uddraget.
|
||||||
|
*
|
||||||
|
* Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter
|
||||||
|
* ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville
|
||||||
|
* gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag
|
||||||
|
* af ingenting andet end at være blevet kigget på.
|
||||||
|
*
|
||||||
|
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
||||||
|
* at vente; teksten kommer, når den er der.
|
||||||
|
*/
|
||||||
|
async function open(note) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
if (!note) {
|
||||||
|
selectedId = null;
|
||||||
|
selected = null;
|
||||||
|
draft = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rækken fremhæves med det samme; editoren får først noten, når den er hel.
|
||||||
|
//
|
||||||
|
// De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id*
|
||||||
|
// skifter — og hvis den fik uddraget først og hele noten bagefter, ville
|
||||||
|
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
||||||
|
// sidde med en tom note, der lige havde haft en titel.
|
||||||
|
selectedId = note.id;
|
||||||
|
if (note.body) {
|
||||||
selected = note;
|
selected = note;
|
||||||
draft = note?.body ?? '';
|
draft = note.body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const full = await api.note(note.id);
|
||||||
|
// Kun hvis den stadig er den, der er valgt: skifter man note, mens
|
||||||
|
// hentningen er undervejs, må den forrige ikke lande oven i den nye.
|
||||||
|
if (selectedId !== note.id) return;
|
||||||
|
selected = full;
|
||||||
|
draft = full.body ?? '';
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +298,14 @@
|
|||||||
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hvilken dato rækken viser: den, der sorteres efter. */
|
||||||
|
const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
|
||||||
|
|
||||||
|
/** Begge datoer, til den, der holder musen stille. */
|
||||||
|
const stamp = (note) =>
|
||||||
|
`${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` +
|
||||||
|
`${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`;
|
||||||
|
|
||||||
function projectName(id) {
|
function projectName(id) {
|
||||||
return app.projects.find((p) => p.id === id)?.name ?? '';
|
return app.projects.find((p) => p.id === id)?.name ?? '';
|
||||||
}
|
}
|
||||||
@@ -161,7 +316,11 @@
|
|||||||
|
|
||||||
function onDragStart(event, note) {
|
function onDragStart(event, note) {
|
||||||
dragging = note.id;
|
dragging = note.id;
|
||||||
startDrag(event, NOTE, note.id);
|
// Trækker man en note, der er markeret, følger hele markeringen med. Trækker
|
||||||
|
// man en, der ikke er, er det den ene — ellers ville et træk i en tilfældig
|
||||||
|
// række flytte halvtreds noter, man havde glemt var markeret.
|
||||||
|
const ids = picked.has(note.id) ? [...picked] : [note.id];
|
||||||
|
startDrag(event, NOTE, ids.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -194,7 +353,10 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const saved = await api.updateNote(id, { body });
|
const saved = await api.updateNote(id, { body });
|
||||||
notes = notes.map((n) => (n.id === id ? saved : n));
|
// Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers
|
||||||
|
// står linjen tom, indtil siden hentes igen.
|
||||||
|
const forList = { ...saved, preview: saved.body ?? '' };
|
||||||
|
notes = notes.map((n) => (n.id === id ? forList : n));
|
||||||
// Only if it is still the one on screen: switching notes mid-save must not
|
// Only if it is still the one on screen: switching notes mid-save must not
|
||||||
// drag the previous one's title back onto this one.
|
// drag the previous one's title back onto this one.
|
||||||
if (selected?.id === id) selected = saved;
|
if (selected?.id === id) selected = saved;
|
||||||
@@ -295,22 +457,63 @@
|
|||||||
<option value={o.key}>{t(o.label)}</option>
|
<option value={o.key}>{t(o.label)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
class="archive-toggle"
|
||||||
|
class:on={showArchive}
|
||||||
|
onclick={() => { showArchive = !showArchive; picked = new Set(); }}
|
||||||
|
aria-pressed={showArchive}
|
||||||
|
aria-label={t('notes.showArchive')}
|
||||||
|
title={t('notes.showArchive')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
|
||||||
|
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
|
||||||
|
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
|
||||||
|
programmet, der ikke fulgte temaet, og det kunne ses. -->
|
||||||
|
<div class="searchbox" class:busy={typing}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M20 20l-3.5-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
class="search"
|
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
value={draftQuery}
|
||||||
|
oninput={(e) => search(e.currentTarget.value)}
|
||||||
placeholder={t('notes.search')}
|
placeholder={t('notes.search')}
|
||||||
aria-label={t('notes.search')}
|
aria-label={t('notes.search')}
|
||||||
/>
|
/>
|
||||||
|
{#if draftQuery}
|
||||||
|
<button class="clear" onclick={() => search('')} aria-label={t('notes.clearSearch')}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if picked.size}
|
||||||
|
<!-- Kun mens der er markeret noget. En bjælke, der altid står der, er en
|
||||||
|
bjælke, der tager plads fra listen hver dag for at gøre gavn sjældent. -->
|
||||||
|
<div class="picked-bar">
|
||||||
|
<span>{t('notes.picked', { count: picked.size })}</span>
|
||||||
|
<button onclick={() => bulk({ archived: !showArchive })}>
|
||||||
|
{showArchive ? t('notes.unarchive') : t('notes.archive')}
|
||||||
|
</button>
|
||||||
|
<button class="danger" onclick={() => bulk({ delete: true })}>{t('notes.delete')}</button>
|
||||||
|
<button onclick={() => (picked = new Set())}>{t('notes.clearPick')}</button>
|
||||||
|
</div>
|
||||||
|
{:else if query && status === 'ready'}
|
||||||
|
<p class="found">{t('notes.found', { count: notes.length })}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if status === 'loading'}
|
{#if status === 'loading'}
|
||||||
<p class="hint">{t('history.loading')}</p>
|
<p class="hint">{t('history.loading')}</p>
|
||||||
{:else if !notes.length}
|
{:else if !notes.length}
|
||||||
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
<p class="hint">
|
||||||
|
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each ordered as note (note.id)}
|
{#each ordered as note (note.id)}
|
||||||
@@ -318,8 +521,9 @@
|
|||||||
<div class="rowline">
|
<div class="rowline">
|
||||||
<button
|
<button
|
||||||
class="row"
|
class="row"
|
||||||
class:on={selected?.id === note.id}
|
class:on={selectedId === note.id}
|
||||||
onclick={() => open(note)}
|
class:picked={picked.has(note.id)}
|
||||||
|
onclick={(e) => rowClick(e, note)}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onDragStart(e, note)}
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
ondragend={() => (dragging = null)}
|
ondragend={() => (dragging = null)}
|
||||||
@@ -329,13 +533,36 @@
|
|||||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
linjer pr. note frem for tre, og datoen er dét, man skanner
|
||||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
efter, når man leder efter noget, man skrev i tirsdags. -->
|
||||||
<span class="under">
|
<span class="under">
|
||||||
<span class="when">{when(note.updated_at)}</span>
|
<!-- Datoen, der svarer til den rækkefølge, listen står i.
|
||||||
<span class="preview">{plain(note.body)}</span>
|
Den viste altid `updated_at`, og for tolv hundrede
|
||||||
|
importerede noter er det i aftes — så en note fra 2017
|
||||||
|
stod som "i går", og den dato, man sorterede efter, var
|
||||||
|
ingen steder at se. -->
|
||||||
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
|
<span class="preview">{plain(note.preview ?? note.body)}</span>
|
||||||
</span>
|
</span>
|
||||||
{#if note.project_id}
|
{#if note.project_id}
|
||||||
<span class="filed">{projectName(note.project_id)}</span>
|
<span class="filed">{projectName(note.project_id)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||||
|
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||||
|
der er tre hundrede bred — og de bruges sjældent, mens
|
||||||
|
titlen og uddraget læses hele tiden. -->
|
||||||
|
<div class="rowactions">
|
||||||
|
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||||
|
til flere; det her er til den ene, man står med — og i
|
||||||
|
arkivet er det vejen tilbage. -->
|
||||||
|
<button
|
||||||
|
class="archive-one"
|
||||||
|
onclick={() => archiveOne(note)}
|
||||||
|
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="star"
|
class="star"
|
||||||
class:on={note.pinned}
|
class:on={note.pinned}
|
||||||
@@ -347,6 +574,7 @@
|
|||||||
{note.pinned ? '★' : '☆'}
|
{note.pinned ? '★' : '☆'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -474,21 +702,164 @@
|
|||||||
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
||||||
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
||||||
udgave, og den ser ens ud hele tiden. */
|
udgave, og den ser ens ud hele tiden. */
|
||||||
.search {
|
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
|
||||||
width: 100%;
|
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
|
||||||
appearance: none;
|
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
|
||||||
-webkit-appearance: none;
|
farve skrevet ind: den gamle var #8b918d uanset tema. */
|
||||||
padding-left: 30px;
|
.searchbox {
|
||||||
border-radius: 999px;
|
display: flex;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
|
align-items: center;
|
||||||
background-repeat: no-repeat;
|
gap: var(--s2);
|
||||||
background-position: 9px center;
|
padding: var(--s2) var(--s3);
|
||||||
background-size: 15px 15px;
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
|
.searchbox:focus-within {
|
||||||
.search::-webkit-search-decoration,
|
border-color: var(--line-strong);
|
||||||
.search::-webkit-search-cancel-button {
|
}
|
||||||
|
|
||||||
|
/* Mens det tastede endnu ikke er søgt efter. Et hint om at der kommer noget,
|
||||||
|
frem for en liste, der ser forkert ud i en kvart sekund. */
|
||||||
|
.searchbox.busy {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
flex: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear:hover {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bjælken over listen, mens der er markeret noget. Den erstatter antallet af
|
||||||
|
fundne frem for at lægge sig oven i det: kun ét af de to er relevant ad
|
||||||
|
gangen. */
|
||||||
|
.picked-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--s2) var(--s1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En markeret række.
|
||||||
|
*
|
||||||
|
* En kant og en tone, ikke en tone alene: `--accent-sunken` er meget lys i lyst
|
||||||
|
* tema, og tre markerede rækker var næsten ikke til at skelne fra tre, der ikke
|
||||||
|
* var — hvilket er en dårlig ting at være i tvivl om, lige inden man trykker
|
||||||
|
* Slet. Kanten kan ses uanset tema og uanset hvor lys tonen er. */
|
||||||
|
.row.picked {
|
||||||
|
background: var(--accent-sunken);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vises som stjernen: usynlig, indtil rækken er under musen, så listen er
|
||||||
|
rolig at skanne. */
|
||||||
|
.archive-one {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-one svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowline:hover .archive-one,
|
||||||
|
.archive-one:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-one:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle.on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safaris egen ryd-knap ville sidde oven i vores egen. */
|
||||||
|
.searchbox input::-webkit-search-decoration,
|
||||||
|
.searchbox input::-webkit-search-cancel-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,15 +871,45 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rækker uden for skærmen koster ingenting.
|
||||||
|
*
|
||||||
|
* Listen tegner alle noterne. Med ti var det ligegyldigt; med tolv hundrede tog
|
||||||
|
* første tegning to sekunder, og browseren gik i knæ — for hver eneste række
|
||||||
|
* blev der lavet layout og malet, også de tusind, ingen kan se.
|
||||||
|
*
|
||||||
|
* `content-visibility: auto` lader browseren springe netop det over for det,
|
||||||
|
* der er uden for billedet, og tage det, når man ruller derhen. Ingen JavaScript,
|
||||||
|
* ingen rullematematik, ingen liste, der kan komme ud af trit med sig selv —
|
||||||
|
* hvilket er hele grunden til at prøve det her før en vinduesvisning.
|
||||||
|
*
|
||||||
|
* `contain-intrinsic-size` er gættet på, hvor høj en uset række er. Uden det
|
||||||
|
* ville rullebjælken tro, at listen er tom, og hoppe, mens man ruller. Tallet er
|
||||||
|
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||||
|
li {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
.rowline {
|
.rowline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
||||||
|
hinanden er toogfyrre pixels taget fra en liste på tre hundrede — og de er
|
||||||
|
det, man rører en gang imellem, mens titlen er det, man læser hele tiden. */
|
||||||
|
.rowactions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.star {
|
.star {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 24px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
@@ -134,6 +134,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En ny note, der allerede hører til dette projekt.
|
||||||
|
*
|
||||||
|
* Noten oprettes med `project_id` sat, hvilket er præcis det, deling betyder
|
||||||
|
* her: en note i et projekt kan læses af alle, der kan læse projektet. Så det
|
||||||
|
* er én handling og ikke to — man skulle før oprette noten under Noter og
|
||||||
|
* bagefter finde projektet frem i "Del i".
|
||||||
|
*
|
||||||
|
* Tom, og så videre til den. Der er ikke noget at spørge om: titlen er notens
|
||||||
|
* første linje, så et navnefelt ville være et felt, der lover noget, programmet
|
||||||
|
* ikke holder.
|
||||||
|
*/
|
||||||
|
async function createNote() {
|
||||||
|
try {
|
||||||
|
const note = await api.createNote({ body: '', project_id: id });
|
||||||
|
goto(`/noter?note=${note.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
||||||
let isOwner = $derived(project?.role === 'owner');
|
let isOwner = $derived(project?.role === 'owner');
|
||||||
|
|
||||||
@@ -365,6 +386,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- moving a section ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The order of the sections is the order of the plan — "Planlægning" stands
|
||||||
|
// before "I gang" because that is when the work happens — and until this it
|
||||||
|
// could only be changed by deleting a section and making it again, which drops
|
||||||
|
// its tasks into the unsectioned area on the way past.
|
||||||
|
//
|
||||||
|
// The whole heading is the handle, not a grip beside it. A section heading
|
||||||
|
// holds nothing that is itself dragged, so there is no gesture to steal — which
|
||||||
|
// is the reason the sidebar's rows needed one, and the reason these do not.
|
||||||
|
|
||||||
|
let draggingSection = $state(null);
|
||||||
|
/** The heading whose gap is lit up, and which side of it. */
|
||||||
|
let overSectionEdge = $state(null);
|
||||||
|
let overSectionBelow = $state(false);
|
||||||
|
|
||||||
|
function clearSectionDrag() {
|
||||||
|
draggingSection = null;
|
||||||
|
overSectionEdge = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragStart(event, section) {
|
||||||
|
draggingSection = section.id;
|
||||||
|
startDrag(event, SECTION, section.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeadDragOver(event, section) {
|
||||||
|
if (!canEdit || !carries(event, SECTION) || draggingSection === section.id) return;
|
||||||
|
accept(event);
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
overSectionEdge = section.id;
|
||||||
|
overSectionBelow = event.clientY > box.top + box.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic, and the whole list at once.
|
||||||
|
*
|
||||||
|
* The array is what the page renders from, so moving the entry moves the
|
||||||
|
* section; `sort_order` is set alongside it only so a later read of this list
|
||||||
|
* agrees with what is on screen. If the write fails the previous array goes
|
||||||
|
* back — a half-moved section is worse than one that did not move.
|
||||||
|
*/
|
||||||
|
async function onHeadDrop(event, target) {
|
||||||
|
if (!carries(event, SECTION)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const id = dragged(event, SECTION) || draggingSection;
|
||||||
|
const below = overSectionBelow;
|
||||||
|
clearSectionDrag();
|
||||||
|
if (!id || id === target.id || !canEdit) return;
|
||||||
|
|
||||||
|
const moved = sections.find((s) => s.id === id);
|
||||||
|
if (!moved) return;
|
||||||
|
|
||||||
|
const without = sections.filter((s) => s.id !== id);
|
||||||
|
const at = without.findIndex((s) => s.id === target.id);
|
||||||
|
if (at < 0) return;
|
||||||
|
|
||||||
|
const ordered = [...without];
|
||||||
|
ordered.splice(below ? at + 1 : at, 0, moved);
|
||||||
|
|
||||||
|
const previous = sections;
|
||||||
|
sections = ordered.map((s, i) => ({ ...s, sort_order: i }));
|
||||||
|
try {
|
||||||
|
await api.reorderSections(
|
||||||
|
project.id,
|
||||||
|
ordered.map((s) => s.id)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
sections = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeSection(section) {
|
async function removeSection(section) {
|
||||||
const count = open.filter((t) => t.section_id === section.id).length;
|
const count = open.filter((t) => t.section_id === section.id).length;
|
||||||
const warning = count
|
const warning = count
|
||||||
@@ -718,7 +813,22 @@
|
|||||||
ondragleave={() => (overSection = null)}
|
ondragleave={() => (overSection = null)}
|
||||||
ondrop={(e) => onSectionDrop(e, section.id)}
|
ondrop={(e) => onSectionDrop(e, section.id)}
|
||||||
>
|
>
|
||||||
<div class="section-head">
|
<!-- Draggable, except while it is being renamed: a draggable element
|
||||||
|
will not let the pointer select text in the field it contains.
|
||||||
|
The sidebar's groups learned this first. -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="section-head"
|
||||||
|
class:dragging={draggingSection === section.id}
|
||||||
|
class:drop-above={overSectionEdge === section.id && !overSectionBelow}
|
||||||
|
class:drop-below={overSectionEdge === section.id && overSectionBelow}
|
||||||
|
draggable={canEdit && renamingSection !== section.id}
|
||||||
|
ondragstart={(e) => onSectionDragStart(e, section)}
|
||||||
|
ondragend={clearSectionDrag}
|
||||||
|
ondragover={(e) => onHeadDragOver(e, section)}
|
||||||
|
ondragleave={() => (overSectionEdge = null)}
|
||||||
|
ondrop={(e) => onHeadDrop(e, section)}
|
||||||
|
>
|
||||||
{#if renamingSection === section.id}
|
{#if renamingSection === section.id}
|
||||||
<form onsubmit={(e) => renameSection(e, section)}>
|
<form onsubmit={(e) => renameSection(e, section)}>
|
||||||
<input
|
<input
|
||||||
@@ -826,15 +936,30 @@
|
|||||||
<!-- What has been written about this project, which is the payoff for a #tag
|
<!-- What has been written about this project, which is the payoff for a #tag
|
||||||
in a note being the same tag a task carries: nobody files anything here,
|
in a note being the same tag a task carries: nobody files anything here,
|
||||||
it turns up because somebody mentioned the project while writing. -->
|
it turns up because somebody mentioned the project while writing. -->
|
||||||
{#if notes.length}
|
{#if notes.length || canEdit}
|
||||||
<section class="notes">
|
<section class="notes">
|
||||||
|
<div class="noteshead">
|
||||||
<h2>{t('notes.title')}</h2>
|
<h2>{t('notes.title')}</h2>
|
||||||
|
{#if canEdit}
|
||||||
|
<!-- En note skrevet herfra hører til projektet med det samme.
|
||||||
|
At ligge i et projekt *er* måden en note deles på, så det er
|
||||||
|
den samme handling som at oprette den og dele den — og det er
|
||||||
|
her, man står, når man opdager at der skal skrives noget ned
|
||||||
|
om netop dette projekt. -->
|
||||||
|
<button class="newnote" onclick={createNote}>{t('notes.newHere')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !notes.length}
|
||||||
|
<p class="hint">{t('notes.noneInProject')}</p>
|
||||||
|
{/if}
|
||||||
<ul>
|
<ul>
|
||||||
{#each notes as note (note.id)}
|
{#each notes as note (note.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href="/noter?note={note.id}">
|
<a href="/noter?note={note.id}">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
<span>{note.body.slice(0, 90)}</span>
|
<!-- `preview` når listen bærer det, `body` når noten kom
|
||||||
|
fra et sted, der sender den hel. -->
|
||||||
|
<span>{(note.preview ?? note.body ?? '').slice(0, 90)}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -863,6 +988,25 @@
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noteshead {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.newnote {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.newnote:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.notes h2 {
|
.notes h2 {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1386,12 +1530,61 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: var(--s2) var(--s3);
|
|
||||||
margin-bottom: var(--s1);
|
margin-bottom: var(--s1);
|
||||||
/* Pulled out to the padding of the rows below, so the band lines up with the
|
/* The same inset as the rows, and the same width.
|
||||||
text it heads rather than with the rows' own inset. */
|
*
|
||||||
margin-left: calc(var(--s2) * -1);
|
* It used to be pulled out by `--s2` and padded by `--s3`, which is the same
|
||||||
margin-right: calc(var(--s2) * -1);
|
* idea done with two different numbers: the band ended up eight pixels wider
|
||||||
|
* than the list on each side, and its text four pixels *outside* the column
|
||||||
|
* the rows keep. On the left that was invisible. On the right it was Omdøb
|
||||||
|
* and Slet standing four pixels past everything above and below them, which
|
||||||
|
* is exactly far enough to look like a mistake and not far enough to see why.
|
||||||
|
*
|
||||||
|
* A row is `.row` with `padding: var(--s2)` inside a box with none, so the
|
||||||
|
* column the eye follows starts one `--s2` in. The band matches that by
|
||||||
|
* having the same padding and no margin at all — no arithmetic, and nothing
|
||||||
|
* to keep in step if the rows are ever re-inset.
|
||||||
|
*/
|
||||||
|
padding: var(--s2);
|
||||||
|
/* For the drop line, which is drawn inside the band's own box. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grabbable, and only where it can actually be grabbed: the rename field is
|
||||||
|
not draggable, and a heading somebody may only read is not either. */
|
||||||
|
.section-head[draggable='true'] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line in the gap rather than a box around the heading. The gap is where it
|
||||||
|
will land, and a lit heading says "into this one" — which is what a task
|
||||||
|
dragged onto a section means, and this is not that. */
|
||||||
|
.section-head::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.drop-above::after {
|
||||||
|
top: -1px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.drop-below::after {
|
||||||
|
bottom: -1px;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head h2 {
|
.section-head h2 {
|
||||||
|
|||||||
Reference in New Issue
Block a user