Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd6dca852 | ||
|
|
ae673086bc | ||
|
|
e29fd04abd | ||
|
|
be581eb8ae | ||
|
|
9d34b57166 | ||
|
|
4b3abc44fb | ||
|
|
b87c9ea6e2 | ||
|
|
ce1d27f8ca | ||
|
|
cab4e703f5 |
+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.
|
||||||
@@ -2079,6 +2079,53 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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 } }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, "viewer@example.dk", "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": "viewer@example.dk", "role": "viewer",
|
||||||
|
}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
|
||||||
|
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(), "kristian@example.dk")
|
||||||
|
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)
|
||||||
@@ -1358,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(), "kristian@example.dk")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -106,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}
|
||||||
@@ -128,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) {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ 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
|
// Second factors are counted against the *account*, not the address. An
|
||||||
// address bucket is defeated by anybody with more than one address, which is
|
// address bucket is defeated by anybody with more than one address, which is
|
||||||
@@ -85,6 +86,11 @@ 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
|
// 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
|
// their own code gets it right on the first or second try; ten in an hour is
|
||||||
@@ -122,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
|
||||||
@@ -281,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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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:kristian@example.dk"}
|
||||||
|
}
|
||||||
+62
-1
@@ -289,12 +289,73 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
|||||||
//
|
//
|
||||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||||
// subquery throws it away.
|
// subquery throws it away.
|
||||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
found, err := db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||||
WHERE notes.deleted_at IS NULL
|
WHERE notes.deleted_at IS NULL
|
||||||
AND notes_fts MATCH ?
|
AND notes_fts MATCH ?
|
||||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
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.
|
||||||
|
|||||||
@@ -369,3 +369,67 @@ func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
|||||||
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ 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.
|
||||||
|
|||||||
@@ -186,3 +186,94 @@ func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
|||||||
t.Errorf("the second write did not land: %v", back["provider"])
|
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: "kristian@example.dk", 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+")")
|
||||||
|
|||||||
+131
@@ -588,6 +588,65 @@
|
|||||||
color: var(--ink-muted);
|
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
|
/* 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. */
|
nobody reads. Each is its own line now, and they can be counted. */
|
||||||
ul.nogo {
|
ul.nogo {
|
||||||
@@ -1121,6 +1180,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<section id="running">
|
||||||
<h2>Running it</h2>
|
<h2>Running it</h2>
|
||||||
|
|
||||||
@@ -1168,6 +1264,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
||||||
|
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>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
<!-- 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
|
where it stood between somebody arriving and the buttons they came
|
||||||
|
|||||||
+275
-10
@@ -976,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([]);
|
||||||
});
|
});
|
||||||
@@ -1131,10 +1140,14 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
||||||
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
||||||
|
|
||||||
// Both kinds are in the same cell, which is the whole point of the view.
|
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
|
||||||
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
|
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
|
||||||
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
|
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
|
||||||
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
|
// 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(event).toBeVisible();
|
||||||
await expect(allDay).toBeVisible();
|
await expect(allDay).toBeVisible();
|
||||||
await expect(task).toBeVisible();
|
await expect(task).toBeVisible();
|
||||||
@@ -1159,11 +1172,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
||||||
|
|
||||||
// The task still reschedules by drag, in a grid that now has events in it.
|
// The task still reschedules by drag, in a grid that now has events in it.
|
||||||
await task.dragTo(page.locator(`[data-date="${next}"]`));
|
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||||
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
|
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||||
await expect(cell.getByText('vande blomster')).toBeHidden();
|
// 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.
|
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||||
await expect(cell.locator('.event')).toHaveCount(2);
|
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||||
|
await expect(band.locator('.event')).toHaveCount(1);
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -3038,3 +3058,248 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -366,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'),
|
||||||
|
|||||||
@@ -272,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);
|
||||||
@@ -279,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'}>
|
||||||
@@ -296,95 +483,195 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div class="weekdays" aria-hidden="true">
|
<!--
|
||||||
{#each WEEKDAYS as day}
|
Én brik, tegnet ét sted.
|
||||||
<span>{day}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
|
||||||
{#each grid as date (date.toISOString())}
|
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
|
||||||
{@const dayEvents = eventsOn(date)}
|
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
|
||||||
<!-- Events first, then whatever room is left goes to tasks. An event is
|
det er kun placeringen, der står to steder.
|
||||||
fixed to a time somebody else chose; a task is only dated, and if
|
|
||||||
something in a full cell has to fall behind "+3 mere", it should be
|
|
||||||
the one that can be moved. -->
|
|
||||||
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
|
|
||||||
{@const hidden =
|
|
||||||
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
|
|
||||||
<!-- A week has no outside: every day in it is the week you asked for. -->
|
|
||||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="day"
|
|
||||||
data-date={iso(date)}
|
|
||||||
class:outside
|
|
||||||
class:today={iso(date) === todayISO}
|
|
||||||
class:over={over === iso(date)}
|
|
||||||
ondragover={(e) => onDragOver(e, date)}
|
|
||||||
ondragleave={() => (over = null)}
|
|
||||||
ondrop={(e) => onDrop(e, date)}
|
|
||||||
>
|
|
||||||
<!-- The weekday strip above the grid is enough on a wide screen. A week
|
|
||||||
stacked into rows on a phone hides it, and a column of bare numbers
|
|
||||||
is not a week — so the cell carries its own name there. -->
|
|
||||||
{#if span === 'week'}
|
|
||||||
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="number">{date.getDate()}</span>
|
|
||||||
|
|
||||||
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
|
||||||
{@const link = linkOf(event)}
|
klokkeslættet, når det ikke allerede står i brikken selv.
|
||||||
{@const label = t('cal.eventCannotMove', {
|
-->
|
||||||
name: event.summary,
|
{#snippet eventChip(event, date, kind, extra = '', at = '')}
|
||||||
calendar: event.calendar_name || t('cal.title')
|
{@const link = linkOf(event)}
|
||||||
})}
|
{@const label = t('cal.eventCannotMove', {
|
||||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
name: event.summary,
|
||||||
browser dragging an anchor of its own accord, and the title says
|
calendar: event.calendar_name || t('cal.title')
|
||||||
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
|
<!-- Not draggable, and it says so twice: the attribute stops the browser
|
||||||
the page and then will not land reads as a bug rather than as a
|
dragging an anchor of its own accord, and the title says why to anybody
|
||||||
rule. It is also not a button: there is nothing here to complete,
|
who tries. The drop targets would refuse it anyway — they only accept the
|
||||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
task MIME type — but a chip that lifts off the page and then will not land
|
||||||
<svelte:element
|
reads as a bug rather than as a rule. It is also not a button: there is
|
||||||
this={link ? 'a' : 'span'}
|
nothing here to complete, and a checkbox next to a meeting is an offer
|
||||||
class="event"
|
verdande cannot keep. -->
|
||||||
class:allday={event.all_day}
|
<svelte:element
|
||||||
draggable="false"
|
this={link ? 'a' : 'span'}
|
||||||
href={link || undefined}
|
class={kind}
|
||||||
target={link ? '_blank' : undefined}
|
class:allday={event.all_day}
|
||||||
rel={link ? 'noreferrer noopener' : undefined}
|
draggable="false"
|
||||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
href={link || undefined}
|
||||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
target={link ? '_blank' : undefined}
|
||||||
>
|
rel={link ? 'noreferrer noopener' : undefined}
|
||||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||||
{event.summary}
|
style={[swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : '', extra]
|
||||||
</svelte:element>
|
.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}
|
{/each}
|
||||||
|
|
||||||
{#each tasks as task (task.id)}
|
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
|
||||||
<!-- Draggable as well as clickable: within a month, moving something
|
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
|
||||||
to another day is the whole reason to be looking at a month. The
|
påstå, den holdt fra midnat til midnat. -->
|
||||||
browser needs a few pixels of movement before it calls a press a
|
<div class="gutter">{t('cal.allDay')}</div>
|
||||||
drag, so the click survives. -->
|
{#each grid as date (date.toISOString())}
|
||||||
<button
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
class="chip"
|
<div
|
||||||
data-priority={task.priority}
|
class="allday"
|
||||||
draggable="true"
|
data-allday={iso(date)}
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
class:over={over === iso(date)}
|
||||||
ondragend={() => (over = null)}
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
onclick={() => onselect?.(task)}
|
ondragleave={() => (over = null)}
|
||||||
|
ondrop={(e) => onDropAllDay(e, date)}
|
||||||
>
|
>
|
||||||
{task.content}
|
{#each untimedOn(date) as item (item.kind + item.ref.id)}
|
||||||
</button>
|
{#if item.kind === 'event'}
|
||||||
|
{@render eventChip(item.ref, date, 'event')}
|
||||||
|
{:else}
|
||||||
|
{@render taskChip(item.ref, 'chip')}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if hidden > 0}
|
<div class="gutter times">
|
||||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
{#each Array(hours.count) as _, i}
|
||||||
{/if}
|
<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>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<div class="weekdays" aria-hidden="true">
|
||||||
|
{#each WEEKDAYS as day}
|
||||||
|
<span>{day}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
{#each grid as date (date.toISOString())}
|
||||||
|
{@const dayEvents = eventsOn(date)}
|
||||||
|
<!-- Events first, then whatever room is left goes to tasks. An event is
|
||||||
|
fixed to a time somebody else chose; a task is only dated, and if
|
||||||
|
something in a full cell has to fall behind "+3 mere", it should be
|
||||||
|
the one that can be moved. -->
|
||||||
|
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
|
||||||
|
{@const hidden =
|
||||||
|
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
|
||||||
|
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||||
|
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="day"
|
||||||
|
data-date={iso(date)}
|
||||||
|
class:outside
|
||||||
|
class:today={iso(date) === todayISO}
|
||||||
|
class:over={over === iso(date)}
|
||||||
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
|
ondragleave={() => (over = null)}
|
||||||
|
ondrop={(e) => onDrop(e, date)}
|
||||||
|
>
|
||||||
|
<!-- The weekday strip above the grid is enough on a wide screen. A week
|
||||||
|
stacked into rows on a phone hides it, and a column of bare numbers
|
||||||
|
is not a week — so the cell carries its own name there. -->
|
||||||
|
{#if span === 'week'}
|
||||||
|
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="number">{date.getDate()}</span>
|
||||||
|
|
||||||
|
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||||
|
{@render eventChip(event, date, 'event')}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each tasks as task (task.id)}
|
||||||
|
{@render taskChip(task, 'chip')}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hidden > 0}
|
||||||
|
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -448,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);
|
||||||
}
|
}
|
||||||
@@ -465,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);
|
||||||
@@ -473,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
colourCode();
|
|
||||||
|
// É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();
|
||||||
|
} catch (e) {
|
||||||
|
editor.textContent = body;
|
||||||
|
console.error('verdande: noten kunne ikke tegnes', note.id, e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
|
|||||||
@@ -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>
|
||||||
<input
|
<div class="due">
|
||||||
id="due"
|
<input
|
||||||
type="date"
|
id="due"
|
||||||
bind:value={dueDate}
|
type="date"
|
||||||
onchange={() => save({ due_date: dueDate })}
|
bind:value={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;
|
||||||
|
|||||||
@@ -242,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',
|
||||||
@@ -551,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',
|
||||||
@@ -814,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',
|
||||||
|
|||||||
@@ -238,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',
|
||||||
@@ -547,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',
|
||||||
@@ -807,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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user