Compare commits
+437
@@ -0,0 +1,437 @@
|
|||||||
|
# Notes, as verdande builds them
|
||||||
|
|
||||||
|
A description of one working notes system: the data model, the syntax, the search,
|
||||||
|
the rendering contract, the import and export formats, and — more useful than any
|
||||||
|
of it — the decisions that look arbitrary until you know what they cost.
|
||||||
|
|
||||||
|
It is written to be lifted into another program. Nothing here depends on Go,
|
||||||
|
SQLite or Svelte except where it says so; where an answer was forced by the tool,
|
||||||
|
that is called out so you can pick a different one on purpose rather than by
|
||||||
|
accident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read this part first: what to ask before you build
|
||||||
|
|
||||||
|
**This document is not a drop-in. It describes a notes system that lives inside a
|
||||||
|
task app with projects, roles, a trash and a search box, and about a third of its
|
||||||
|
design is inherited from that.** Lifting it into a program with different
|
||||||
|
surroundings without deciding which parts to keep produces something that works
|
||||||
|
for a week and then fights you.
|
||||||
|
|
||||||
|
**If you are an assistant working from this file: ask these questions and get
|
||||||
|
answers before writing code.** Do not guess a default. Each one changes what you
|
||||||
|
build, not just how you build it.
|
||||||
|
|
||||||
|
### About the surroundings
|
||||||
|
|
||||||
|
1. **Does the host app already have a place to file things** — projects, folders,
|
||||||
|
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
|
||||||
|
filed them under the projects that already existed. If your app has nothing,
|
||||||
|
you need an answer for where a note lives, and "a folder tree" is the expensive
|
||||||
|
one.
|
||||||
|
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
|
||||||
|
through the whole model below. Single-user changes the schema and removes about
|
||||||
|
half the permission surface.
|
||||||
|
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
|
||||||
|
it, deleting is either permanent or you are building a trash.
|
||||||
|
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
|
||||||
|
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
|
||||||
|
ranking section below changes accordingly.
|
||||||
|
|
||||||
|
### About the notes themselves
|
||||||
|
|
||||||
|
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
|
||||||
|
That decision is defended below; it is also the one that most constrains
|
||||||
|
everything else.
|
||||||
|
6. **Is the first line the title, or is there a title field?** Both work. Deriving
|
||||||
|
it means one less thing to keep in step and one less thing a person can leave
|
||||||
|
blank, and it forces the rule "the first line is a heading" on the editor.
|
||||||
|
7. **What links to what?** `#project`, `[[note]]` and task references exist here
|
||||||
|
because the app has projects and tasks. Yours may have none of them, or others.
|
||||||
|
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
|
||||||
|
authorisation path and an XSS surface. Skipping them is a legitimate answer.
|
||||||
|
|
||||||
|
### About scale and language
|
||||||
|
|
||||||
|
9. **How many notes, realistically?** Several choices below flip at around ten
|
||||||
|
thousand: the list payload, the search fallback, rendering the whole list.
|
||||||
|
10. **Which languages?** The folding described under *Search* is Danish. Every
|
||||||
|
language has its own version of that problem and the generic answer (strip
|
||||||
|
diacritics) is wrong for several of them.
|
||||||
|
11. **Where does the text end up besides the screen?** Search indexes, AI
|
||||||
|
features, exports and backups each read it. Plaintext makes all of them cheap
|
||||||
|
and makes a stolen backup expensive — see *What is still open*.
|
||||||
|
|
||||||
|
### About integration, specifically
|
||||||
|
|
||||||
|
12. **Who owns the note's identity — you or the other system?** If notes will sync
|
||||||
|
with something else, ids and timestamps are the whole problem, and the
|
||||||
|
`updated_at` rule below becomes the first thing you have to change.
|
||||||
|
13. **Is this a read of somebody else's notes, or the store of record?** A copy
|
||||||
|
needs conflict rules; a store of record needs export.
|
||||||
|
14. **What happens to a note whose project, task or link target is deleted?**
|
||||||
|
Answered here as "the link goes dead and the note is untouched". If your host
|
||||||
|
app cascades instead, say so before the first delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape of a note
|
||||||
|
|
||||||
|
```
|
||||||
|
id an opaque string, unique
|
||||||
|
project_id nullable — where it is filed, or loose
|
||||||
|
title derived, never set by a caller
|
||||||
|
body Markdown, the whole note
|
||||||
|
created_by nullable — survives the account being deleted
|
||||||
|
pinned favourite; the list puts these first
|
||||||
|
archived_at nullable timestamp — put away, not thrown away
|
||||||
|
deleted_at nullable timestamp — the trash
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown in a column
|
||||||
|
|
||||||
|
Not rich text, and not a format of our own.
|
||||||
|
|
||||||
|
Export was a requirement before a line was written, and **if the stored form is
|
||||||
|
the exported form there is no conversion left to lose anything in.** A note stays
|
||||||
|
readable by any editor on the day the program is gone. It is also what makes
|
||||||
|
search and anything an AI does over notes cheap: both read text.
|
||||||
|
|
||||||
|
The cost is real and you should know it before choosing: everything the editor can
|
||||||
|
do has to survive a round trip through Markdown, and anything Markdown cannot say,
|
||||||
|
the editor must not offer. There is no place to hide a feature.
|
||||||
|
|
||||||
|
### The title is the first line
|
||||||
|
|
||||||
|
Always, and re-derived on every save — not "when it is empty".
|
||||||
|
|
||||||
|
Derived once and then left alone, a title goes stale the moment somebody rewrites
|
||||||
|
the opening of a note, and the list ends up calling a note by a name that is
|
||||||
|
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
|
||||||
|
caller can set it, so every caller gets the same answer.
|
||||||
|
|
||||||
|
This forces one editor rule: a new note opens on its title, and the first line is
|
||||||
|
styled as a heading. Otherwise a note whose first line is a sentence is a note
|
||||||
|
named by its first sentence.
|
||||||
|
|
||||||
|
### Archive and trash are two different bits
|
||||||
|
|
||||||
|
The trash says *this was a mistake, and in thirty days it is gone*. The archive
|
||||||
|
says *this is finished, and I do not want to read past it.*
|
||||||
|
|
||||||
|
A twelve-hundred-note import is exactly the case where most notes are worth
|
||||||
|
keeping and almost none are worth looking at. Without an archive, the list is the
|
||||||
|
whole archive forever, and finding this week's note means reading past a decade.
|
||||||
|
|
||||||
|
Both are nullable timestamps rather than booleans: *when was this put away* answers
|
||||||
|
questions a flag cannot, and it costs the same byte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filing: no hierarchy of its own
|
||||||
|
|
||||||
|
A note belongs to a project, or to nobody.
|
||||||
|
|
||||||
|
The reasoning is worth repeating because it is easy to talk yourself out of:
|
||||||
|
projects already exist, are already shared, already have roles, already have a
|
||||||
|
trash. **A second hierarchy to file things in is one too many**, and a note filed
|
||||||
|
under a project inherits every one of those answers instead of needing its own.
|
||||||
|
|
||||||
|
- A note with a project: everybody who can read the project can read the note; an
|
||||||
|
editor can change it.
|
||||||
|
- A note without one: the author's, and nobody else's.
|
||||||
|
|
||||||
|
Sharing a note *is* filing it in a project. There is no access list of its own —
|
||||||
|
a second way to give somebody access is a second place to get it wrong, and the
|
||||||
|
two would disagree the day one of them changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Links: three kinds, parsed out of the text
|
||||||
|
|
||||||
|
The body is the truth. Links are an index over it, rebuilt on every save.
|
||||||
|
|
||||||
|
| Syntax | Means | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
|
||||||
|
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
|
||||||
|
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
|
||||||
|
|
||||||
|
Patterns as used here:
|
||||||
|
|
||||||
|
```
|
||||||
|
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
|
||||||
|
note \[\[([^\]\n]{1,200})\]\]
|
||||||
|
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
|
||||||
|
```
|
||||||
|
|
||||||
|
Three decisions inside that table:
|
||||||
|
|
||||||
|
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
|
||||||
|
second kind of tag that happens to look alike. That is the whole of "tag a note
|
||||||
|
and see it in the project".
|
||||||
|
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
|
||||||
|
and the set is small; a diff is a second description of the same fact, and it
|
||||||
|
can disagree with the text. That is the one thing this must never do.
|
||||||
|
- **The target is not a foreign key.** A link to something deleted is a fact about
|
||||||
|
the note. It should read as a dead link rather than vanish, and it must not
|
||||||
|
block the delete.
|
||||||
|
|
||||||
|
The index exists so the question can be asked **backwards**: *what has been written
|
||||||
|
about this task?* Answering that by searching every note's prose would be too slow
|
||||||
|
to leave a panel open on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
Three layers, in the order they answer.
|
||||||
|
|
||||||
|
### 1. Full-text, ranked
|
||||||
|
|
||||||
|
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
|
||||||
|
|
||||||
|
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
|
||||||
|
is what the note is *about*; the same word once in a long body is a mention.
|
||||||
|
|
||||||
|
This matters more than it sounds. Ordering by "last touched" is invisible with ten
|
||||||
|
notes and useless with twelve hundred imported the same evening: searching "Tesla"
|
||||||
|
answered with a note about nginx first and the note actually called "Tesla Model Y"
|
||||||
|
second — which reads as a search that does not work, because from where the person
|
||||||
|
is sitting it is one.
|
||||||
|
|
||||||
|
### 2. Folding, for the language you are in
|
||||||
|
|
||||||
|
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
|
||||||
|
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
|
||||||
|
typing "gron", which is how people type when the keyboard is not theirs.
|
||||||
|
|
||||||
|
The answer here is a generated column carrying a transliterated copy:
|
||||||
|
|
||||||
|
```
|
||||||
|
ø,Ø → o æ,Æ → ae å,Å → aa
|
||||||
|
```
|
||||||
|
|
||||||
|
indexed alongside the body and **weighted the same as the body** — otherwise a
|
||||||
|
folded match outranks an exact one.
|
||||||
|
|
||||||
|
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
|
||||||
|
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
|
||||||
|
> naive lowercasing entirely. Get the mapping from somebody who types the
|
||||||
|
> language.
|
||||||
|
|
||||||
|
### 3. A word inside a word
|
||||||
|
|
||||||
|
An index is built of tokens, and a token is whatever the tokenizer decided was a
|
||||||
|
word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||||
|
`keepinventory` — so a search for "keep inventory" asks for two words that are
|
||||||
|
nowhere and gets nothing, while the note sits right there.
|
||||||
|
|
||||||
|
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
|
||||||
|
are ANDed, so the half that works is thrown away by the half that cannot.
|
||||||
|
|
||||||
|
**This is not exotic in a notes app.** A decade of notes is full of commands,
|
||||||
|
identifiers and file names, and every one of them is several words the index
|
||||||
|
thinks is one.
|
||||||
|
|
||||||
|
So: the index answers first; when it has nothing, the text is read, and every term
|
||||||
|
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
|
||||||
|
column covers title, body and language folding at once.
|
||||||
|
|
||||||
|
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
|
||||||
|
notes that is a few megabytes and a millisecond or two, and it only runs when the
|
||||||
|
fast path already came back empty — which is exactly the search that was about to
|
||||||
|
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
|
||||||
|
answer then is a trigram index, not a bigger scan.
|
||||||
|
|
||||||
|
It does not rank. There is no match to rank — that is the whole situation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The list, and why the body is empty in it
|
||||||
|
|
||||||
|
A list showing a title and ninety characters of preview must not fetch whole
|
||||||
|
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
|
||||||
|
see.
|
||||||
|
|
||||||
|
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
|
||||||
|
|
||||||
|
A field called `body` holding a *piece* of the body is a trap. The editor opens
|
||||||
|
it, the autosave pause writes it back, and the note is now cut down to its own
|
||||||
|
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
|
||||||
|
its own field and leave `body` empty; the client sees the empty body and fetches
|
||||||
|
the whole note.
|
||||||
|
|
||||||
|
That costs one more separation, and it is not optional:
|
||||||
|
|
||||||
|
> **Which row is selected and which note is loaded are two different things.**
|
||||||
|
|
||||||
|
The editor loads its text when the note's id changes. Hand it the excerpt first
|
||||||
|
and the whole note second and the id is the same both times — so the second one
|
||||||
|
never renders, and somebody sits looking at a note that had a title a moment ago.
|
||||||
|
|
||||||
|
Highlight the row immediately from the list; give the editor the note only when it
|
||||||
|
is whole.
|
||||||
|
|
||||||
|
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
|
||||||
|
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
|
||||||
|
virtual list, no scroll maths, and no list that can fall out of step with itself.
|
||||||
|
Try that before building windowing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The editor contract
|
||||||
|
|
||||||
|
Rich text on top, Markdown underneath, and a source view that shows the file.
|
||||||
|
|
||||||
|
The supported subset — deliberately small, because everything in it has to survive
|
||||||
|
the round trip:
|
||||||
|
|
||||||
|
| Block | Markdown |
|
||||||
|
|---|---|
|
||||||
|
| Title | `# ` |
|
||||||
|
| Heading | `## ` |
|
||||||
|
| Subheading | `### ` |
|
||||||
|
| Quote | `> ` |
|
||||||
|
| Code block | ```` ``` ```` with an optional language |
|
||||||
|
| Bullet and numbered lists | `- `, `1. `, nested |
|
||||||
|
|
||||||
|
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
|
||||||
|
and images as ``.
|
||||||
|
|
||||||
|
Five rules that were each learned the hard way:
|
||||||
|
|
||||||
|
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
|
||||||
|
lines into a paragraph. Here it must not: the person typed a line and expects
|
||||||
|
to get it back, and an editor that silently reflows what somebody wrote is an
|
||||||
|
editor they stop trusting.
|
||||||
|
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
|
||||||
|
the caret back at the start, which mid-sentence is unusable.
|
||||||
|
3. **One definition of what a code block contains.** A copy button inside the
|
||||||
|
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
|
||||||
|
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
|
||||||
|
the code and the next save put it on disk. Have one function that says what a
|
||||||
|
block holds, used by the highlighter and the Markdown serialiser both.
|
||||||
|
4. **A note that cannot be rendered must not take the pane with it.** One note
|
||||||
|
arriving in an unexpected shape threw inside the editor's reactive effect, and
|
||||||
|
from then on *no* note would open — the previous one just stayed on screen. Put
|
||||||
|
a guard around the render, fall back to showing the raw text, and let the rest
|
||||||
|
of the pane keep working.
|
||||||
|
5. **Escaping between tags and escaping inside an attribute are different jobs.**
|
||||||
|
An escaper written for text between two tags handles `&`, `<`, `>` — and not
|
||||||
|
quotes, because between tags they are harmless. Inside an attribute they close
|
||||||
|
it. `` is the whole exploit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
Images pasted or dragged into a note, stored content-addressed by hash, referenced
|
||||||
|
from the body as ordinary Markdown images.
|
||||||
|
|
||||||
|
The security shape, which is the part worth copying:
|
||||||
|
|
||||||
|
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
|
||||||
|
are the one place a user supplies content another user opens. An uploaded SVG or
|
||||||
|
HTML file rendered inline runs its own script on your origin with the session
|
||||||
|
cookie attached.
|
||||||
|
- **One exception, as a named allowlist:** a short list of raster image types
|
||||||
|
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
|
||||||
|
A note carries its pictures in the text, and served as octet-stream every one of
|
||||||
|
them is a broken image.
|
||||||
|
- **The list is named one type at a time, never matched on an `image/` prefix.**
|
||||||
|
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
|
||||||
|
script.
|
||||||
|
- **The stored path is checked against the files directory again at read time.** A
|
||||||
|
traversal that somehow got into the database must not become one that reads
|
||||||
|
`/etc/passwd`.
|
||||||
|
- **Reaching an attachment follows the note, which follows its project.** Same
|
||||||
|
chain as everything else. Do not let a client hand you the parent id and take it
|
||||||
|
on trust.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import and export
|
||||||
|
|
||||||
|
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
|
||||||
|
front matter block.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
created: 2019-04-02T10:15:00Z
|
||||||
|
modified: 2024-11-30T21:04:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Møde med Anders
|
||||||
|
|
||||||
|
Han vil gerne have kaffe hver uge.
|
||||||
|
```
|
||||||
|
|
||||||
|
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
|
||||||
|
all accepted on the way in. Obsidian and Bear write this block; so should you.
|
||||||
|
- Images travel in the zip beside the notes, and the links in the text are
|
||||||
|
relative — **a note and its pictures must end up in the same archive**, which
|
||||||
|
matters the moment an export is split into parts.
|
||||||
|
- File names collide: two notes called "Møde" are two notes, and a zip with one
|
||||||
|
entry twice is a zip that loses one.
|
||||||
|
|
||||||
|
Limits that a real import taught, all of them cheap to add and expensive to omit:
|
||||||
|
|
||||||
|
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
|
||||||
|
64 MB of zip expanded to 66 GB is a normal shape for an attack.
|
||||||
|
- A per-file limit as well as a whole-archive budget: one enormous photograph and
|
||||||
|
ten thousand small ones are different attacks with the same ending.
|
||||||
|
- The entry name is never used as a path. Store by hash; the name is only a key
|
||||||
|
for rewriting links.
|
||||||
|
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
|
||||||
|
dropped silently by a filter looking for `..` — one out of 1141, found only by
|
||||||
|
counting at both ends.
|
||||||
|
|
||||||
|
### Timestamps on the way in
|
||||||
|
|
||||||
|
An importer needs to write `created_at` and `updated_at` from the file, and the
|
||||||
|
normal save path must refuse to let it.
|
||||||
|
|
||||||
|
`updated_at` means *when did this program last write this note*. Let any caller set
|
||||||
|
it and the column means whatever the last writer felt like. Give the importer its
|
||||||
|
own narrow entry point instead, and say in the comment why it exists.
|
||||||
|
|
||||||
|
Without those dates, a move of twelve hundred notes is a pile that was all written
|
||||||
|
the same evening — and the order they were written in is half of what an archive
|
||||||
|
is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is still open here
|
||||||
|
|
||||||
|
Written down because a specification that only lists what works is a specification
|
||||||
|
that lies by omission.
|
||||||
|
|
||||||
|
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
|
||||||
|
they are in every backup anybody can download. Encrypting them is bigger than it
|
||||||
|
sounds: the body is in the full-text index and the folded column is generated
|
||||||
|
from it, so both are a second plaintext copy in the same database. Encrypt and
|
||||||
|
both have to go, and search has to be rewritten to decrypt in application code —
|
||||||
|
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
|
||||||
|
point the answer is a keyed index (an HMAC per word).
|
||||||
|
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
|
||||||
|
- **There is no sync.** Export and the API are the way out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A short list of the traps, for the person in a hurry
|
||||||
|
|
||||||
|
1. A `body` field holding an excerpt will be saved over the real one.
|
||||||
|
2. Selected row and loaded note are two different pieces of state.
|
||||||
|
3. The title must be re-derived on every save or it goes stale.
|
||||||
|
4. Diacritic stripping is not folding, and folding is language-specific.
|
||||||
|
5. A tokenizer cannot see a word inside a word; decide what you do about it.
|
||||||
|
6. An attachment served inline is a script on your origin unless you allowlist by
|
||||||
|
exact type and send `nosniff`.
|
||||||
|
7. Escaping for text and escaping for attributes are different functions.
|
||||||
|
8. One render failure must not be allowed to freeze the whole pane.
|
||||||
|
9. An unpacked-size limit is not optional on an archive import.
|
||||||
|
10. Let the importer set timestamps; let nobody else.
|
||||||
@@ -104,15 +104,21 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||||
|
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||||
|
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||||
|
| Notes: archive, grouped list, images, source view | Done, tested |
|
||||||
| Update notice for administrators | Done, tested |
|
| Update notice for administrators | Done, tested |
|
||||||
| Personal API tokens | Done, tested |
|
| Personal API tokens | Done, tested |
|
||||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||||
API calls are implemented against their specifications and have only ever been
|
implemented against their specifications and have only ever been exercised there,
|
||||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
not against live services. Gmail is no longer in that list — it runs in production,
|
||||||
|
including the flagging and de-duplication that took a day to get right. Calendar
|
||||||
|
subscriptions over ICS and webcal are proven; the Google half of the calendar is
|
||||||
|
not. Everything else has an interface: opening a task shows
|
||||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||||
templates.
|
templates.
|
||||||
|
|||||||
+23
-3
@@ -39,8 +39,28 @@ reply within a week.
|
|||||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||||
page, rather than allowing `unsafe-inline`.
|
page, rather than allowing `unsafe-inline`.
|
||||||
|
|
||||||
|
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||||||
|
only the body: a note's title *is* its first line, so a readable title beside a
|
||||||
|
sealed body would leave the beginning of every note in the clear.
|
||||||
|
|
||||||
|
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||||||
|
generated `fold` column held a transliterated copy of it — sealing the body while
|
||||||
|
leaving those would have been theatre, since a stolen file could be read straight
|
||||||
|
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||||||
|
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||||||
|
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||||||
|
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||||||
|
projects and tasks a note points at, though not what it says.
|
||||||
|
|
||||||
## What it does not do
|
## What it does not do
|
||||||
|
|
||||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
else is limited** — an authenticated caller can ask as often as they like, and a
|
||||||
|
signed-in account that wanted to be a nuisance can be one.
|
||||||
|
|
||||||
|
There is no audit log of reads. Who *changed* something is recorded per project;
|
||||||
|
who looked at it is not.
|
||||||
|
|
||||||
|
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||||||
|
VPN — by somebody who knows what they exposed.
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ func run() error {
|
|||||||
}
|
}
|
||||||
db.UseSecrets(box)
|
db.UseSecrets(box)
|
||||||
|
|
||||||
|
// Noter, der stadig ligger åbne, lukkes nu.
|
||||||
|
//
|
||||||
|
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
||||||
|
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
||||||
|
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
||||||
|
// så den koster et gennemløb af id'er hver anden gang.
|
||||||
|
//
|
||||||
|
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
||||||
|
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
||||||
|
// er værre end en, der siger fra.
|
||||||
|
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
||||||
|
return fmt.Errorf("seal notes: %w", err)
|
||||||
|
} else if sealed > 0 {
|
||||||
|
log.Info("notes sealed", "count", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
+7
-35
@@ -1,9 +1,10 @@
|
|||||||
# Calendar and CalDAV
|
# Calendar and CalDAV
|
||||||
|
|
||||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||||
verdande, and none of the three is the same thing.
|
*into* verdande is the other direction and has its own page — see
|
||||||
|
[Calendar](calendar.md).
|
||||||
|
|
||||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||||
@@ -42,39 +43,10 @@ most people actually keep.
|
|||||||
|
|
||||||
## Showing a Google calendar in verdande
|
## Showing a Google calendar in verdande
|
||||||
|
|
||||||
The two sections above send your tasks *out*. This is the other direction: your
|
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
does with either now live on their own page — see [Calendar](calendar.md).
|
||||||
already have in it".
|
|
||||||
|
|
||||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
This page is the other direction: verdande's own tasks, read by something else.
|
||||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
|
||||||
own colour for each one, beside your own tasks with due dates.
|
|
||||||
|
|
||||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
|
||||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
|
||||||
|
|
||||||
!!! warning "Read only"
|
|
||||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
|
||||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
|
||||||
can be changed. Your own tasks in the same grid still drag to another day as
|
|
||||||
they always have.
|
|
||||||
|
|
||||||
Writing would need the `calendar.events` scope and an answer to what should
|
|
||||||
happen when both sides moved the same meeting — which is a synchronisation
|
|
||||||
model rather than a scope, and it is deliberately not built yet.
|
|
||||||
|
|
||||||
!!! note "The Internal client applies here too"
|
|
||||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
|
||||||
used by accounts in that workspace, calendars included. A personal
|
|
||||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
|
||||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
|
||||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
|
||||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
|
||||||
this feature.
|
|
||||||
|
|
||||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
|
||||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
|
||||||
says so rather than showing an empty month.
|
|
||||||
|
|
||||||
## Connecting a CalDAV client
|
## Connecting a CalDAV client
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Calendar
|
||||||
|
|
||||||
|
Your dated tasks as a grid, with other people's calendars laid under them.
|
||||||
|
|
||||||
|
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
|
||||||
|
side, and everything with a time on it in its place. Two things at ten o'clock sit
|
||||||
|
beside each other, because that is a clash you should be able to see rather than
|
||||||
|
read.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Your tasks**, on the day they are due — and at the time, if they have one.
|
||||||
|
- **Events from calendars you have connected**, read-only.
|
||||||
|
|
||||||
|
Drag a task to another day to move it. Drop it on the hour column and it takes that
|
||||||
|
time; drop it in the band across the top and it keeps the day and loses the time.
|
||||||
|
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
|
||||||
|
chip that lets itself be moved is a promise the server cannot keep.
|
||||||
|
|
||||||
|
A project's own calendar is under **Calendar** on the project page, with the same
|
||||||
|
week and month.
|
||||||
|
|
||||||
|
## Subscribing to a calendar
|
||||||
|
|
||||||
|
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
|
||||||
|
once.
|
||||||
|
|
||||||
|
This works with anything that publishes an `.ics` file: public holidays, a club's
|
||||||
|
fixtures, a shared calendar from work, your own calendar's secret address from
|
||||||
|
Google, Apple or Fastmail.
|
||||||
|
|
||||||
|
- **`webcal://` addresses work.** They are the same URL over https with a scheme
|
||||||
|
that makes a desktop hand it to the calendar app; it is rewritten for you.
|
||||||
|
- **Several at a time.** Unlike the Google connection, which is one account, a
|
||||||
|
subscription is one address and you can have as many as you like.
|
||||||
|
- **Read-only, refreshed every fifteen minutes.**
|
||||||
|
- The address is fetched once before it is stored, so a typo tells you now rather
|
||||||
|
than by silently never filling.
|
||||||
|
|
||||||
|
!!! note "This is the way in for a private account"
|
||||||
|
If the instance's Google registration is *Internal*, Google refuses accounts
|
||||||
|
from outside the organisation with `org_internal` — no setting on your side
|
||||||
|
changes that. Your calendar's own secret ICS address goes through this panel
|
||||||
|
and works regardless.
|
||||||
|
|
||||||
|
## Connecting Google Calendar
|
||||||
|
|
||||||
|
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
|
||||||
|
uses, so the instance needs that registration first — see
|
||||||
|
[Configuration](configuration.md#google).
|
||||||
|
|
||||||
|
Two things have to be true in Google Cloud, and only the owner of the registration
|
||||||
|
can do them:
|
||||||
|
|
||||||
|
1. **Google Calendar API** is enabled for the project.
|
||||||
|
2. The calendar callback is listed as an authorised redirect URI. The exact string
|
||||||
|
is printed in the panel — copy it from there rather than typing it. Google
|
||||||
|
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
|
||||||
|
almost always means one of those four differs.
|
||||||
|
|
||||||
|
Once connected, tick the calendars you want to see. The list is refreshed on every
|
||||||
|
sync, so a calendar added or renamed since last time appears under its new name.
|
||||||
|
|
||||||
|
!!! warning "Written to the specification, not yet proven against Google"
|
||||||
|
The Google Calendar half is implemented from Google's documentation and tested
|
||||||
|
against a stand-in server. It has not been exercised against Google itself.
|
||||||
|
The subscription route above and the outgoing feed below have.
|
||||||
|
|
||||||
|
## Your own calendar, elsewhere
|
||||||
|
|
||||||
|
Two ways out, for two different jobs:
|
||||||
|
|
||||||
|
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
|
||||||
|
Google or Thunderbird. Your tasks appear as dated entries.
|
||||||
|
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
|
||||||
|
is ticked off here.
|
||||||
|
|
||||||
|
The feed address is a credential in itself: anybody holding it can read your tasks
|
||||||
|
without logging in, because a calendar client cannot sign in. Rotate it from
|
||||||
|
**Settings → Integrations** if it gets out.
|
||||||
|
|
||||||
|
## What it does not do
|
||||||
|
|
||||||
|
- **It does not write to a connected calendar.** Events are a copy.
|
||||||
|
- **It does not expand repeating events** from a subscribed file. A weekly meeting
|
||||||
|
in somebody else's calendar is stored as its first occurrence.
|
||||||
|
- **It does not resolve named time zones** from a subscribed file. An event carrying
|
||||||
|
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
|
||||||
|
server has moved.
|
||||||
|
- **Only Google, and only reading**, for account connections. Everything else goes
|
||||||
|
through a subscription.
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
Markdown in, Markdown out. Nothing in between.
|
||||||
|
|
||||||
|
A note is written as rich text — headings, lists, quotations, code — and stored as
|
||||||
|
the Markdown it looks like. That is not a detail about the database: it is what
|
||||||
|
makes a note yours. Export gives you the same text you were looking at, and on the
|
||||||
|
day this program is gone, every note opens in any editor.
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
Start typing. **The first line is the title** — always, and re-derived every time
|
||||||
|
you save, so a note you rewrite the top of is called by what it now says rather
|
||||||
|
than by what it once did.
|
||||||
|
|
||||||
|
| Style | Written as | Shortcut |
|
||||||
|
|---|---|---|
|
||||||
|
| Title | `# ` at the start of the line | pick it in **Aa** |
|
||||||
|
| Heading | `## ` | |
|
||||||
|
| Subheading | `### ` | |
|
||||||
|
| Quote | `> ` | |
|
||||||
|
| Code block | three backticks, with an optional language | |
|
||||||
|
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
|
||||||
|
| Bold | two asterisks around the words | ⌘B |
|
||||||
|
| Italic | one asterisk around the words | ⌘I |
|
||||||
|
| Underline | a `u` tag around the words | ⌘U |
|
||||||
|
| Strikethrough | two tildes around the words | |
|
||||||
|
| Code | one backtick around the words | |
|
||||||
|
|
||||||
|
Every line becomes exactly one block. Markdown would normally join consecutive
|
||||||
|
lines into a paragraph; here it does not, because you typed a line and expect to
|
||||||
|
get it back.
|
||||||
|
|
||||||
|
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
|
||||||
|
note.
|
||||||
|
|
||||||
|
### Pictures
|
||||||
|
|
||||||
|
Paste one, or drag it onto the note. It is stored with the note and travels with
|
||||||
|
it in an export. Every raster format works; SVG is stored but not shown inline —
|
||||||
|
it is a document that can run script, and a note is not a place to run somebody
|
||||||
|
else's.
|
||||||
|
|
||||||
|
### Code blocks
|
||||||
|
|
||||||
|
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||||
|
Each one carries a **Copy** button in its corner.
|
||||||
|
|
||||||
|
## Linking
|
||||||
|
|
||||||
|
Three kinds of reference, parsed out of the text as you write it:
|
||||||
|
|
||||||
|
| You type | It means |
|
||||||
|
|---|---|
|
||||||
|
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
|
||||||
|
| `[[Møde med Anders]]` | Another note, by its title |
|
||||||
|
| A pasted task link | That task |
|
||||||
|
|
||||||
|
Links are an index over the text, rebuilt every time the note is saved. The text
|
||||||
|
stays the truth.
|
||||||
|
|
||||||
|
**The interesting direction is backwards.** A task shows what has been written
|
||||||
|
about it, and a project shows the notes filed in it — without anybody having to
|
||||||
|
remember to attach anything. Write `#Firma` in a note and it appears on that
|
||||||
|
project's page.
|
||||||
|
|
||||||
|
A link to something that has been deleted reads as a dead link. It is a fact about
|
||||||
|
the note, and it should not vanish because something it mentioned did.
|
||||||
|
|
||||||
|
## Filing and sharing
|
||||||
|
|
||||||
|
A note belongs to a project, or to nobody.
|
||||||
|
|
||||||
|
- **In a project:** everybody who can read the project can read the note, and an
|
||||||
|
editor can change it. Choose it under **Share in** at the foot of the note.
|
||||||
|
- **In no project:** yours, and nobody else's.
|
||||||
|
|
||||||
|
There is no folder tree. Projects, groups and labels already exist, are already
|
||||||
|
shared and already have roles — a second hierarchy to file things in is one too
|
||||||
|
many, and a note in a project inherits every one of those answers.
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||||
|
week**, and then by month. Sort by created, last touched or name, and the grouping
|
||||||
|
follows — sorted by name it groups by first letter instead.
|
||||||
|
|
||||||
|
Search finds a word wherever it is:
|
||||||
|
|
||||||
|
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
|
||||||
|
stripping diacritics is not enough for Danish. A folded copy of every note is
|
||||||
|
indexed beside the original.
|
||||||
|
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
|
||||||
|
decade of notes is full of commands and identifiers that are several words in
|
||||||
|
one. When the index has nothing, the text itself is read.
|
||||||
|
- Results are ranked by how well they match, 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.
|
||||||
|
|
||||||
|
## Putting notes away
|
||||||
|
|
||||||
|
Two different things, and the difference matters:
|
||||||
|
|
||||||
|
- **Archive** — this is finished and I do not want to read past it. It stays
|
||||||
|
findable and comes back whenever you ask.
|
||||||
|
- **Trash** — this was a mistake. It is gone in thirty days.
|
||||||
|
|
||||||
|
Select several with ⌘-click and shift-click to archive or delete them together.
|
||||||
|
|
||||||
|
## In and out
|
||||||
|
|
||||||
|
**Export** gives you a zip of Markdown files, one per note, with the dates in a
|
||||||
|
YAML front matter block that Obsidian and Bear also read:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
created: 2019-04-02T10:15:00Z
|
||||||
|
modified: 2024-11-30T21:04:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Møde med Anders
|
||||||
|
|
||||||
|
Han vil gerne have kaffe hver uge.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
|
||||||
|
and the links in the text are relative — so a note and its pictures must be in the
|
||||||
|
same archive, which matters when a large export is split into parts.
|
||||||
|
|
||||||
|
Two things to know before importing:
|
||||||
|
|
||||||
|
- **Nothing is merged.** Every run creates new notes. Importing the same archive
|
||||||
|
twice gives you two of everything.
|
||||||
|
- **The dates are read from the front matter.** Without them, a move of a thousand
|
||||||
|
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.
|
||||||
|
|
||||||
|
### From Apple Notes
|
||||||
|
|
||||||
|
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
|
||||||
|
exactly this shape, front matter and pictures included. It is macOS-only, because
|
||||||
|
Apple Notes is.
|
||||||
|
|
||||||
|
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
||||||
|
uploadable parts, keeping each note with its own pictures.
|
||||||
|
|
||||||
|
## What is encrypted, and what is not
|
||||||
|
|
||||||
|
**A note's title and body are sealed** in the database, under a key kept in a file
|
||||||
|
beside it rather than inside it. A backup you download does not carry the key, so
|
||||||
|
it does not carry your notes.
|
||||||
|
|
||||||
|
Both halves, not only the body: a note's title is its first line, so a readable
|
||||||
|
title beside a sealed body would leave the beginning of every note in the open.
|
||||||
|
|
||||||
|
**What is still in the clear:** task titles, descriptions and comments — and the
|
||||||
|
list of what a note links to, so a stolen file shows which projects and tasks a
|
||||||
|
note points at, though not what it says.
|
||||||
|
|
||||||
|
Sealing the notes cost the search its index — a full-text index over a note's body
|
||||||
|
is another copy of that body — so search reads the notes themselves now. At twelve
|
||||||
|
hundred notes that is about forty milliseconds, and the box waits a quarter of a
|
||||||
|
second after you stop typing, so you will not see it.
|
||||||
@@ -2079,6 +2079,95 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/calendar/subscriptions:
|
||||||
|
post:
|
||||||
|
tags: [calendar]
|
||||||
|
summary: Subscribe to a calendar you only have an address to.
|
||||||
|
description: |
|
||||||
|
An ICS or `webcal://` address. Several per person, unlike the Google
|
||||||
|
connection, which is one — the address is the identity here, the way the
|
||||||
|
token is for OAuth.
|
||||||
|
|
||||||
|
The address is fetched once before it is stored: an address that cannot be
|
||||||
|
read is a typo far more often than a server that happens to be down, and
|
||||||
|
saying so while somebody is still looking at the field is worth two
|
||||||
|
seconds. `webcal://` is rewritten to `https://`, and the destination is
|
||||||
|
checked against the private-network wall both when it is written down and
|
||||||
|
again on the resolved address every time it is fetched.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url: { type: string, description: "https:// or webcal://" }
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Optional. The calendar's own name is used when this is empty.
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Subscribed, and fetched once.
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/calendar/subscriptions/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
delete:
|
||||||
|
tags: [calendar]
|
||||||
|
summary: Stop subscribing. The cached events go with it.
|
||||||
|
responses:
|
||||||
|
"204": { description: Gone. }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/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 } }
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type testServer struct {
|
|||||||
*httptest.Server
|
*httptest.Server
|
||||||
db *store.DB
|
db *store.DB
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
// api er den samme server, routeren hænger på. Et par prøver har brug for at
|
||||||
|
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
|
||||||
|
api *Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) *testServer {
|
func newTestServer(t *testing.T) *testServer {
|
||||||
@@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
|||||||
// Discard: a passing test should print nothing, and these handlers log freely.
|
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
srv := httptest.NewServer(New(cfg, db, log, nil))
|
api := New(cfg, db, log, nil)
|
||||||
|
srv := httptest.NewServer(api)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
jar, err := cookiejar.New(nil)
|
jar, err := cookiejar.New(nil)
|
||||||
@@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
|||||||
// The cookie jar is what makes this a session test rather than a
|
// The cookie jar is what makes this a session test rather than a
|
||||||
// sequence of unrelated requests.
|
// sequence of unrelated requests.
|
||||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||||
|
api: api,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/gcal"
|
"github.com/kristianwind/verdande/internal/gcal"
|
||||||
|
"github.com/kristianwind/verdande/internal/ics"
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,9 +212,21 @@ type calendarStatusJSON struct {
|
|||||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||||
// an interface that quietly looks editable is one somebody will try to edit.
|
// an interface that quietly looks editable is one somebody will try to edit.
|
||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
|
|
||||||
|
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
|
||||||
|
// have det ene uden det andet, og det er hele pointen med dem — en privat
|
||||||
|
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
|
||||||
|
Subscriptions []store.CalendarAccount `json:"subscriptions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Læst først, så de også kommer med, når der ingen Google-konto er.
|
||||||
|
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "subscriptions", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
out := calendarStatusJSON{
|
out := calendarStatusJSON{
|
||||||
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if account == nil || account.RefreshToken == "" {
|
if account == nil || account.RefreshToken == "" {
|
||||||
|
out.Subscriptions = subscriptions
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if calendars != nil {
|
if calendars != nil {
|
||||||
out.Calendars = calendars
|
out.Calendars = calendars
|
||||||
}
|
}
|
||||||
|
out.Subscriptions = subscriptions
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,12 +404,23 @@ func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Returns how many events were written. Also used by the background job, which is
|
// Returns how many events were written. Also used by the background job, which is
|
||||||
// why it takes a context and a user rather than a request.
|
// why it takes a context and a user rather than a request.
|
||||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||||
|
// Abonnementer først, og deres fejl tæller ikke mod Google.
|
||||||
|
//
|
||||||
|
// De to har intet med hinanden at gøre ud over at ende samme sted: et
|
||||||
|
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
|
||||||
|
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
|
||||||
|
// Google-forbindelsen er væk — og omvendt.
|
||||||
|
written, err := s.syncSubscriptions(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return written, err
|
||||||
}
|
}
|
||||||
if account == nil || account.RefreshToken == "" {
|
if account == nil || account.RefreshToken == "" {
|
||||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
return written, nil // not connected; nothing to do and nothing to complain about
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||||
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
from, to := calendarWindow(time.Now())
|
from, to := calendarWindow(time.Now())
|
||||||
written := 0
|
|
||||||
for _, calendar := range calendars {
|
for _, calendar := range calendars {
|
||||||
if !calendar.Shown {
|
if !calendar.Shown {
|
||||||
continue
|
continue
|
||||||
@@ -546,3 +577,232 @@ func validDay(s string) bool {
|
|||||||
_, err := time.Parse(calendarDateLayout, s)
|
_, err := time.Parse(calendarDateLayout, s)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- abonnementer -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
|
||||||
|
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
|
||||||
|
// memory on a schedule with nobody watching is how a homelab instance falls over
|
||||||
|
// at three in the morning.
|
||||||
|
const maxICSBytes = 10 << 20
|
||||||
|
|
||||||
|
// normaliseCalendarURL turns what somebody pastes into something that can be
|
||||||
|
// fetched.
|
||||||
|
//
|
||||||
|
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
|
||||||
|
// over https, with a scheme that makes a desktop hand it to the calendar app
|
||||||
|
// instead of the browser — and every publisher writes their links that way. A
|
||||||
|
// person pasting one has pasted the right address; refusing it would be refusing
|
||||||
|
// them on a technicality.
|
||||||
|
func normaliseCalendarURL(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
|
||||||
|
raw = "https://" + after
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
|
||||||
|
return "", errors.New("must be an https:// or webcal:// address")
|
||||||
|
}
|
||||||
|
// The same wall the AI provider's base_url goes through, and for the same
|
||||||
|
// reason: this is an address a user typed, and the server is about to fetch it
|
||||||
|
// on a schedule of their choosing.
|
||||||
|
if reason := safedial.CheckURL(raw); reason != "" {
|
||||||
|
return "", errors.New(reason)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icsClient is the client a subscribed calendar is fetched with.
|
||||||
|
//
|
||||||
|
// safedial, checked on the *resolved* address at connect time and on every
|
||||||
|
// redirect hop — because a name that passed the check when it was pasted can
|
||||||
|
// answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||||
|
//
|
||||||
|
// The field exists so a test can put an ordinary client here: httptest listens on
|
||||||
|
// loopback, which is exactly what this refuses, so a test of the *format* cannot
|
||||||
|
// otherwise reach its own server. It is never set outside a test, and the wall it
|
||||||
|
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
|
||||||
|
// address where it is written down, and `internal/push` proves the connect-time
|
||||||
|
// half.
|
||||||
|
func (s *Server) icsClient() *http.Client {
|
||||||
|
if s.icsFetch != nil {
|
||||||
|
return s.icsFetch
|
||||||
|
}
|
||||||
|
return safedial.Client(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchICS reads a subscribed calendar.
|
||||||
|
//
|
||||||
|
// safedial, not a plain client — checked on the resolved address at connect time
|
||||||
|
// and on every redirect hop, because a name that passed the check when it was
|
||||||
|
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||||
|
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ics.Subscribed{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
|
||||||
|
req.Header.Set("User-Agent", "verdande/"+Version)
|
||||||
|
|
||||||
|
resp, err := s.icsClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ics.Subscribed{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return ics.Subscribed{}, err
|
||||||
|
}
|
||||||
|
if len(body) > maxICSBytes {
|
||||||
|
return ics.Subscribed{}, errors.New("that calendar is too large")
|
||||||
|
}
|
||||||
|
return ics.ParseCalendar(string(body)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncSubscriptions refreshes every calendar this person subscribes to by address.
|
||||||
|
//
|
||||||
|
// One failing address does not stop the others. A subscription is somebody else's
|
||||||
|
// server, and the ordinary state of somebody else's server is that it is fine
|
||||||
|
// until one morning it is not — a holiday feed that 404s must not take the
|
||||||
|
// football club's fixtures down with it.
|
||||||
|
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
|
||||||
|
subs, err := s.db.Subscriptions(ctx, user.ID)
|
||||||
|
if err != nil || len(subs) == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to := calendarWindow(time.Now())
|
||||||
|
written := 0
|
||||||
|
var failures []string
|
||||||
|
|
||||||
|
for _, sub := range subs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return written, ctx.Err()
|
||||||
|
}
|
||||||
|
unlock := s.lockSync("calendar:" + sub.ID)
|
||||||
|
|
||||||
|
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
unlock()
|
||||||
|
failures = append(failures, sub.URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cal, err := s.fetchICS(ctx, sub.URL)
|
||||||
|
if err != nil {
|
||||||
|
unlock()
|
||||||
|
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
|
||||||
|
failures = append(failures, sub.URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name the file calls itself, once. A subscription is added under its
|
||||||
|
// address because that is all there is to go on before it has been fetched.
|
||||||
|
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
|
||||||
|
s.log.Warn("name subscribed calendar", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]store.CalendarEvent, 0, len(cal.Events))
|
||||||
|
for _, e := range cal.Events {
|
||||||
|
// Outside the window is not stored. The grid asks about a window, the
|
||||||
|
// sweep replaces a window, and a decade of holidays in one file would
|
||||||
|
// otherwise be a decade of rows rewritten every fifteen minutes.
|
||||||
|
if e.EndDay < from || e.StartDay > to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, store.CalendarEvent{
|
||||||
|
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
|
||||||
|
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||||
|
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
|
||||||
|
unlock()
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
|
||||||
|
s.log.Warn("mark subscription synced", "err", err)
|
||||||
|
}
|
||||||
|
written += len(rows)
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failures) == len(subs) {
|
||||||
|
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubscribeCalendar adds a calendar somebody only has an address to.
|
||||||
|
//
|
||||||
|
// Fetched once, here, before it is stored. An address that cannot be read is a
|
||||||
|
// typo far more often than it is a server that happens to be down, and telling
|
||||||
|
// somebody now — while they are looking at the field they just pasted into — is
|
||||||
|
// worth the two seconds. A subscription that silently never fills is the same
|
||||||
|
// silence the notifications had.
|
||||||
|
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := normaliseCalendarURL(req.URL)
|
||||||
|
if err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"url": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cal, err := s.fetchICS(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
|
||||||
|
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(cal.Name)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = url
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
|
||||||
|
if err != nil {
|
||||||
|
// The unique index is the one refusal worth naming: subscribing twice to
|
||||||
|
// the same address is the same calendar fetched twice, with no way to say
|
||||||
|
// which one counts.
|
||||||
|
if errors.Is(err, store.ErrDuplicate) {
|
||||||
|
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.storeError(w, r, "subscribe calendar", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The events it already fetched, rather than making the person wait fifteen
|
||||||
|
// minutes for the sweep to prove it worked.
|
||||||
|
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
|
||||||
|
s.log.Warn("first subscription sync", "err", err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
|
||||||
|
s.storeError(w, r, "unsubscribe calendar", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- MCP -----------------------------------------------------------------------------
|
// --- MCP -----------------------------------------------------------------------------
|
||||||
@@ -250,6 +255,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 +1473,172 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et abonnement på en kalender, man kun har en adresse til.
|
||||||
|
//
|
||||||
|
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
|
||||||
|
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
|
||||||
|
// også den eneste vej for en privat konto, som en Internal-registrering afviser
|
||||||
|
// med `org_internal`.
|
||||||
|
//
|
||||||
|
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
|
||||||
|
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
|
||||||
|
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
|
||||||
|
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
|
||||||
|
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
|
||||||
|
func TestSubscribingToACalendarByAddress(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
day := time.Now().AddDate(0, 0, 2).Format("20060102")
|
||||||
|
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:en@example.dk\r\n" +
|
||||||
|
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
|
||||||
|
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
var hits int
|
||||||
|
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits++
|
||||||
|
w.Header().Set("Content-Type", "text/calendar")
|
||||||
|
w.Write([]byte(feedBody))
|
||||||
|
}))
|
||||||
|
defer feed.Close()
|
||||||
|
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
|
||||||
|
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits == 0 {
|
||||||
|
t.Fatal("adressen blev aldrig hentet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
|
||||||
|
// findes ingen standardegenskab for det.
|
||||||
|
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||||
|
subs, _ := status["subscriptions"].([]any)
|
||||||
|
if len(subs) != 1 {
|
||||||
|
t.Fatalf("%d abonnementer i svaret", len(subs))
|
||||||
|
}
|
||||||
|
first, _ := subs[0].(map[string]any)
|
||||||
|
if first["account"] != "Helligdage" {
|
||||||
|
t.Errorf("navnet er %v, want Helligdage", first["account"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og begivenheden står i gitteret.
|
||||||
|
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||||
|
list, _ := events["events"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("%d begivenheder efter abonnementet", len(list))
|
||||||
|
}
|
||||||
|
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
|
||||||
|
t.Errorf("begivenheden er %v", e["summary"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
|
||||||
|
// ingen måde at sige hvilken der gælder.
|
||||||
|
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
|
||||||
|
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fjernet igen, og begivenhederne går med.
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||||
|
if list, _ := events["events"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
|
||||||
|
// taler — det er en konvention, der får et skrivebord til at give adressen til
|
||||||
|
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
|
||||||
|
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
|
||||||
|
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
|
||||||
|
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "https://example.dk/kalender.ics" {
|
||||||
|
t.Errorf("blev til %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
|
||||||
|
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
|
||||||
|
t.Error("en loopback-adresse blev godtaget")
|
||||||
|
}
|
||||||
|
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
|
||||||
|
t.Error("et skema, der ikke er http, blev godtaget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,9 @@ 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
|
||||||
|
// Sat kun af prøver; se icsClient().
|
||||||
|
icsFetch *http.Client
|
||||||
|
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 +88,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 +130,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 +297,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) {
|
||||||
@@ -375,6 +394,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||||
r.Post("/sync", s.handleCalendarSyncNow)
|
r.Post("/sync", s.handleCalendarSyncNow)
|
||||||
r.Get("/events", s.handleCalendarEvents)
|
r.Get("/events", s.handleCalendarEvents)
|
||||||
|
|
||||||
|
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
|
||||||
|
// person, hvor Google er én — det er adressen, der er identiteten,
|
||||||
|
// hvor tokenet er det for OAuth.
|
||||||
|
r.Post("/subscriptions", s.handleSubscribeCalendar)
|
||||||
|
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/import", func(r chi.Router) {
|
r.Route("/import", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,3 +367,180 @@ func unescape(s string) string {
|
|||||||
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
||||||
return r.Replace(s)
|
return r.Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- reading a whole calendar ------------------------------------------------------
|
||||||
|
|
||||||
|
// Event is one VEVENT, reduced to what a grid needs — the same shape the Google
|
||||||
|
// side already stores, so the two arrive in the database identically.
|
||||||
|
//
|
||||||
|
// Days at both ends, inclusive, because that is the question a grid asks: which
|
||||||
|
// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and
|
||||||
|
// all: it is what the chip shows, and reparsing it here would ask *this process*
|
||||||
|
// what time zone it is in.
|
||||||
|
type Event struct {
|
||||||
|
UID string
|
||||||
|
Summary string
|
||||||
|
Location string
|
||||||
|
URL string
|
||||||
|
AllDay bool
|
||||||
|
StartsAt string // RFC3339, empty for an all-day event
|
||||||
|
EndsAt string
|
||||||
|
StartDay string // YYYY-MM-DD
|
||||||
|
EndDay string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribed is what an .ics file somebody subscribed to says.
|
||||||
|
//
|
||||||
|
// Its own name rather than `Calendar`: that one is the *writing* side — what
|
||||||
|
// verdande publishes as a feed — and the two are different documents that happen
|
||||||
|
// to share a format. One name for both would be a promise that a round trip
|
||||||
|
// through this package is lossless, which it is not and does not need to be.
|
||||||
|
type Subscribed struct {
|
||||||
|
Name string
|
||||||
|
TimeZone string
|
||||||
|
Events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCalendar reads an .ics file that somebody subscribed to.
|
||||||
|
//
|
||||||
|
// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was
|
||||||
|
// written by somebody else's software and is fetched on a schedule with nobody
|
||||||
|
// watching. One property this does not understand must not cost the other four
|
||||||
|
// hundred events in the file — so anything unrecognised is skipped, and an event
|
||||||
|
// without the two things a grid cannot do without (a uid and a start) is dropped
|
||||||
|
// rather than stored half-made.
|
||||||
|
//
|
||||||
|
// # What it deliberately does not do
|
||||||
|
//
|
||||||
|
// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then
|
||||||
|
// every Tuesday until 2031", and expanding it here would mean writing a year of
|
||||||
|
// rows per rule and re-deciding what happens when the file changes. A recurring
|
||||||
|
// event is stored as its first occurrence, which is honest — it is what the file
|
||||||
|
// says on its own terms — and the day it matters, the answer is an expander shared
|
||||||
|
// with `internal/recurrence`, not a second one written here.
|
||||||
|
//
|
||||||
|
// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored
|
||||||
|
// with the offset the file carries or, when it carries none, as a local stamp. The
|
||||||
|
// alternative is shipping a tz database and deciding what to do when the file
|
||||||
|
// names a zone this build has never heard of. The day that matters, it is one
|
||||||
|
// place.
|
||||||
|
func ParseCalendar(body string) Subscribed {
|
||||||
|
var cal Subscribed
|
||||||
|
var current *Event
|
||||||
|
depth := 0
|
||||||
|
|
||||||
|
for _, line := range unfold(body) {
|
||||||
|
name, params, value := splitLine(line)
|
||||||
|
upper := strings.ToUpper(name)
|
||||||
|
|
||||||
|
switch upper {
|
||||||
|
case "BEGIN":
|
||||||
|
if strings.EqualFold(value, "VEVENT") {
|
||||||
|
current = &Event{}
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
continue
|
||||||
|
case "END":
|
||||||
|
depth--
|
||||||
|
if strings.EqualFold(value, "VEVENT") && current != nil {
|
||||||
|
if current.UID != "" && current.StartDay != "" {
|
||||||
|
cal.Events = append(cal.Events, *current)
|
||||||
|
}
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
// Calendar-level properties. X-WR-CALNAME is what every publisher uses
|
||||||
|
// to say what the calendar is called; there is no standard property for
|
||||||
|
// it, which is why a non-standard one is read here.
|
||||||
|
switch upper {
|
||||||
|
case "X-WR-CALNAME":
|
||||||
|
cal.Name = unescape(value)
|
||||||
|
case "X-WR-TIMEZONE":
|
||||||
|
cal.TimeZone = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch upper {
|
||||||
|
case "UID":
|
||||||
|
current.UID = value
|
||||||
|
case "SUMMARY":
|
||||||
|
current.Summary = unescape(value)
|
||||||
|
case "LOCATION":
|
||||||
|
current.Location = unescape(value)
|
||||||
|
case "URL":
|
||||||
|
current.URL = value
|
||||||
|
case "DTSTART":
|
||||||
|
current.AllDay = isDateOnly(params, value)
|
||||||
|
current.StartDay, current.StartsAt = readStamp(params, value)
|
||||||
|
case "DTEND":
|
||||||
|
day, at := readStamp(params, value)
|
||||||
|
current.EndsAt = at
|
||||||
|
// DTEND is exclusive for an all-day event: a one-day event ends on the
|
||||||
|
// next morning. Stored inclusive, or every all-day event would cover one
|
||||||
|
// cell too many — and a public holiday would eat the day after it.
|
||||||
|
if current.AllDay {
|
||||||
|
day = shiftDay(day, -1)
|
||||||
|
}
|
||||||
|
current.EndDay = day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing DTEND means the event ends when it starts. Filled after the loop
|
||||||
|
// rather than inside it, because DTEND may arrive before DTSTART in a file
|
||||||
|
// nobody promised would be in order.
|
||||||
|
for i := range cal.Events {
|
||||||
|
if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay {
|
||||||
|
cal.Events[i].EndDay = cal.Events[i].StartDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cal
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE
|
||||||
|
// says so outright; an eight-character value says it by being one.
|
||||||
|
func isDateOnly(params, value string) bool {
|
||||||
|
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(value) == 8 && !strings.ContainsAny(value, "T")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStamp returns the day and, for a timed event, the moment as RFC3339.
|
||||||
|
//
|
||||||
|
// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and
|
||||||
|
// 20260821T140000 with a TZID parameter (a local time in a named zone). The third
|
||||||
|
// is kept as written, without a zone: resolving it needs a tz database, and a
|
||||||
|
// stamp that says 14:00 is closer to the truth than one this process has moved.
|
||||||
|
func readStamp(params, value string) (day, at string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) < 8 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
day = value[0:4] + "-" + value[4:6] + "-" + value[6:8]
|
||||||
|
if len(value) < 15 || value[8] != 'T' {
|
||||||
|
return day, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15]
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(value, "Z"):
|
||||||
|
return day, day + "T" + clock + "Z"
|
||||||
|
default:
|
||||||
|
// A local stamp, with or without a TZID. Written without an offset, which
|
||||||
|
// is what the interface already treats as "the clock the file said".
|
||||||
|
return day, day + "T" + clock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftDay moves a YYYY-MM-DD by whole days.
|
||||||
|
func shiftDay(day string, by int) string {
|
||||||
|
t, err := time.Parse("2006-01-02", day)
|
||||||
|
if err != nil {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
return t.AddDate(0, 0, by).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,3 +220,101 @@ func TestFilename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En abonnementskalender, som den ser ud fra nogen andens software.
|
||||||
|
//
|
||||||
|
// Alle fire former, der findes i naturen, i én fil: en heldagsbegivenhed, en med
|
||||||
|
// et UTC-stempel, en med TZID og lokal tid, og en over flere dage. Plus en, der
|
||||||
|
// mangler det, et gitter ikke kan undvære, og en foldet linje — begge dele
|
||||||
|
// kommer, og de kommer uden varsel.
|
||||||
|
func TestParseCalendarReadsTheFourShapesThatExist(t *testing.T) {
|
||||||
|
body := "BEGIN:VCALENDAR\r\n" +
|
||||||
|
"VERSION:2.0\r\n" +
|
||||||
|
"X-WR-CALNAME:Helligdage\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\n" +
|
||||||
|
"UID:heldag@example.dk\r\n" +
|
||||||
|
"DTSTART;VALUE=DATE:20260824\r\n" +
|
||||||
|
"DTEND;VALUE=DATE:20260825\r\n" +
|
||||||
|
"SUMMARY:Grundlovsdag\r\n" +
|
||||||
|
"END:VEVENT\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\n" +
|
||||||
|
"UID:utc@example.dk\r\n" +
|
||||||
|
"DTSTART:20260825T120000Z\r\n" +
|
||||||
|
"DTEND:20260825T133000Z\r\n" +
|
||||||
|
`SUMMARY:Møde med en meget lang titel\, der er foldet over to lin` + "\r\n" + " jer\r\n" +
|
||||||
|
"END:VEVENT\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\n" +
|
||||||
|
"UID:lokal@example.dk\r\n" +
|
||||||
|
"DTSTART;TZID=Europe/Copenhagen:20260826T090000\r\n" +
|
||||||
|
"DTEND;TZID=Europe/Copenhagen:20260826T100000\r\n" +
|
||||||
|
"SUMMARY:Tandlæge\r\n" +
|
||||||
|
"END:VEVENT\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\n" +
|
||||||
|
"UID:flerdage@example.dk\r\n" +
|
||||||
|
"DTSTART;VALUE=DATE:20260901\r\n" +
|
||||||
|
"DTEND;VALUE=DATE:20260904\r\n" +
|
||||||
|
"SUMMARY:Ferie\r\n" +
|
||||||
|
"END:VEVENT\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\n" +
|
||||||
|
"SUMMARY:Uden uid og uden start\r\n" +
|
||||||
|
"END:VEVENT\r\n" +
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
cal := ParseCalendar(body)
|
||||||
|
|
||||||
|
if cal.Name != "Helligdage" {
|
||||||
|
t.Errorf("kalenderens navn = %q", cal.Name)
|
||||||
|
}
|
||||||
|
if len(cal.Events) != 4 {
|
||||||
|
t.Fatalf("%d begivenheder, want 4 — den femte mangler både uid og start", len(cal.Events))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heldag. DTEND er eksklusiv i filen: en endagsbegivenhed slutter næste morgen.
|
||||||
|
// Gemmes inklusivt, ellers dækker hver eneste heldagsbegivenhed én celle for
|
||||||
|
// meget — og en helligdag æder dagen efter sig.
|
||||||
|
one := cal.Events[0]
|
||||||
|
if !one.AllDay || one.StartDay != "2026-08-24" || one.EndDay != "2026-08-24" {
|
||||||
|
t.Errorf("heldag: %+v", one)
|
||||||
|
}
|
||||||
|
if one.StartsAt != "" {
|
||||||
|
t.Errorf("en heldagsbegivenhed fik et klokkeslæt: %q", one.StartsAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC, og den foldede linje samlet igen.
|
||||||
|
two := cal.Events[1]
|
||||||
|
if two.StartsAt != "2026-08-25T12:00:00Z" || two.EndsAt != "2026-08-25T13:30:00Z" {
|
||||||
|
t.Errorf("utc: %+v", two)
|
||||||
|
}
|
||||||
|
if two.Summary != "Møde med en meget lang titel, der er foldet over to linjer" {
|
||||||
|
t.Errorf("den foldede linje blev ikke samlet: %q", two.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lokal tid med TZID: klokkeslættet står, som filen skrev det. At flytte det
|
||||||
|
// her ville spørge *denne* proces, hvilken tidszone den er i.
|
||||||
|
three := cal.Events[2]
|
||||||
|
if three.StartsAt != "2026-08-26T09:00:00" {
|
||||||
|
t.Errorf("lokal: %q", three.StartsAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flere dage, begge ender inklusive.
|
||||||
|
four := cal.Events[3]
|
||||||
|
if four.StartDay != "2026-09-01" || four.EndDay != "2026-09-03" {
|
||||||
|
t.Errorf("ferie: %s til %s", four.StartDay, four.EndDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En fil, der ikke er en kalender, må ikke koste noget. Den hentes på et skema
|
||||||
|
// uden nogen, der kigger med, og en fejl her ville være en sweep, der stopper.
|
||||||
|
func TestParseCalendarSurvivesRubbish(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"",
|
||||||
|
"ikke en kalender overhovedet",
|
||||||
|
"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
||||||
|
"BEGIN:VEVENT\r\nDTSTART:x\r\nEND:VEVENT\r\n",
|
||||||
|
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:2026\r\nEND:VEVENT\r\n",
|
||||||
|
} {
|
||||||
|
if got := ParseCalendar(body); len(got.Events) != 0 {
|
||||||
|
t.Errorf("%q gav %d begivenheder", body, len(got.Events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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"}
|
||||||
|
}
|
||||||
@@ -650,8 +650,18 @@ func pad2(n int) string {
|
|||||||
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
||||||
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
||||||
// all of it and leave the task not repeating at all.
|
// all of it and leave the task not repeating at all.
|
||||||
|
//
|
||||||
|
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
|
||||||
|
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
|
||||||
|
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
|
||||||
|
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
|
||||||
|
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
|
||||||
|
// enden; den kan ikke hente et, mønsteret aldrig tog med.
|
||||||
|
//
|
||||||
|
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||||
|
// ikke var det.
|
||||||
var reRepeat = regexp.MustCompile(
|
var reRepeat = regexp.MustCompile(
|
||||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||||
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
||||||
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
||||||
|
|
||||||
|
|||||||
@@ -603,3 +603,36 @@ func TestASectionIsWrittenWithASlash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "hver anden tirsdag" er den måde, folk siger det på.
|
||||||
|
//
|
||||||
|
// recurrence-pakken har oversat "hver anden" til "hver 2." siden den blev skrevet,
|
||||||
|
// men sætningen nåede aldrig frem til den: mønsteret her tog kun ét ord efter
|
||||||
|
// "hver", så det, der blev givet videre, var "hver anden" — ikke en regel. Så blev
|
||||||
|
// de to ord stående i titlen, og "tirsdag" blev til en forfaldsdato. Opgaven
|
||||||
|
// gentog sig ikke, og der stod ikke noget om det.
|
||||||
|
//
|
||||||
|
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||||
|
// ikke var det.
|
||||||
|
func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
rule string
|
||||||
|
left string
|
||||||
|
}{
|
||||||
|
{"vande planterne hver anden tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||||
|
{"vande planterne hver 2. tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||||
|
{"water the plants every other tuesday", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "water the plants"},
|
||||||
|
{"gå tur hver tredje søndag", "FREQ=WEEKLY;BYDAY=SU;INTERVAL=3", "gå tur"},
|
||||||
|
// Den, der virkede i forvejen, skal blive ved med at gøre det.
|
||||||
|
{"tømme opvasker hver mandag", "FREQ=WEEKLY;BYDAY=MO", "tømme opvasker"},
|
||||||
|
} {
|
||||||
|
got := Parse(tc.text, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "da")
|
||||||
|
if got.Recurrence != tc.rule {
|
||||||
|
t.Errorf("%q gav reglen %q, want %q", tc.text, got.Recurrence, tc.rule)
|
||||||
|
}
|
||||||
|
if got.Content != tc.left {
|
||||||
|
t.Errorf("%q efterlod titlen %q, want %q", tc.text, got.Content, tc.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+159
-8
@@ -3,7 +3,9 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +16,12 @@ type CalendarAccount struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Account string `json:"account,omitempty"`
|
Account string `json:"account,omitempty"`
|
||||||
|
|
||||||
|
// URL er tom for en OAuth-konto og adressen for et abonnement. Den er
|
||||||
|
// identiteten på et abonnement, som tokenet er det for Google: to rækker med
|
||||||
|
// den samme adresse for den samme person ville være den samme kalender hentet
|
||||||
|
// to gange, uden nogen måde at sige hvilken der gælder.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
RefreshToken string `json:"-"` // never leaves the server
|
RefreshToken string `json:"-"` // never leaves the server
|
||||||
AccessToken string `json:"-"`
|
AccessToken string `json:"-"`
|
||||||
ExpiresAt time.Time `json:"-"`
|
ExpiresAt time.Time `json:"-"`
|
||||||
@@ -65,10 +73,17 @@ type CalendarEvent struct {
|
|||||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||||
access_token, expires_at, last_sync_at, created_at`
|
access_token, expires_at, last_sync_at, created_at`
|
||||||
|
|
||||||
|
// Med adressen. Skrivningen tilføjer den selv, fordi indsættelsen navngiver
|
||||||
|
// søjlerne; læsningen skal bede om den.
|
||||||
|
const calendarAccountRead = calendarAccountColumns + `, url`
|
||||||
|
|
||||||
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
|
||||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
// udbyder kunne svare på spørgsmålet "hvad er personen forbundet til med
|
||||||
|
// Google", og et abonnement har hverken token eller konto at svare med.
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountRead+`
|
||||||
|
FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, userID, provider)
|
||||||
a, err := db.scanCalendarAccount(row)
|
a, err := db.scanCalendarAccount(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -106,27 +121,37 @@ func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error
|
|||||||
// struct with no id in it, and matching on the id would insert a second row
|
// struct with no id in it, and matching on the id would insert a second row
|
||||||
// that the unique index then refuses — which reads to the person as "connecting
|
// that the unique index then refuses — which reads to the person as "connecting
|
||||||
// failed" when what happened is that it already had.
|
// failed" when what happened is that it already had.
|
||||||
|
// `WHERE url = ''` fordi indekset er delvist siden 0026.
|
||||||
|
//
|
||||||
|
// En OAuth-konto er én pr. person og udbyder — det er tokenet, der er
|
||||||
|
// identiteten — mens et abonnement er én pr. adresse, og man har flere. De to
|
||||||
|
// bor i samme tabel, fordi et abonnement *er* en kalender med begivenheder i,
|
||||||
|
// og den halvdel er den samme. Målet for konflikten skal sige, hvilken af de to
|
||||||
|
// slags rækker der tales om, ellers ved SQLite det ikke.
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
|
||||||
account = excluded.account,
|
account = excluded.account,
|
||||||
refresh_token = excluded.refresh_token,
|
refresh_token = excluded.refresh_token,
|
||||||
access_token = excluded.access_token,
|
access_token = excluded.access_token,
|
||||||
expires_at = excluded.expires_at,
|
expires_at = excluded.expires_at,
|
||||||
last_sync_at = excluded.last_sync_at`,
|
last_sync_at = excluded.last_sync_at`,
|
||||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if a.URL != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// The row that is actually there wins. On a reconnect the insert above updated
|
// The row that is actually there wins. On a reconnect the insert above updated
|
||||||
// an existing row, so the id in hand is one nothing points at — and the
|
// an existing row, so the id in hand is one nothing points at — and the
|
||||||
// calendars are hung off the id, so writing them against it would attach them
|
// calendars are hung off the id, so writing them against it would attach them
|
||||||
// to nothing.
|
// to nothing.
|
||||||
return db.QueryRowContext(ctx,
|
return db.QueryRowContext(ctx,
|
||||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
|
||||||
a.UserID, a.Provider).Scan(&a.ID)
|
a.UserID, a.Provider).Scan(&a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
|||||||
var a CalendarAccount
|
var a CalendarAccount
|
||||||
var expires, lastSync, created int64
|
var expires, lastSync, created int64
|
||||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||||
&a.AccessToken, &expires, &lastSync, &created)
|
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
@@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
|||||||
a.CreatedAt = time.Unix(created, 0)
|
a.CreatedAt = time.Unix(created, 0)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- abonnementer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// SubscribeCalendar adds a calendar somebody only has an address to.
|
||||||
|
//
|
||||||
|
// One row in `calendar_accounts` with a url and no tokens, and one row in
|
||||||
|
// `calendars` hung off it — because a subscription *is* one calendar, where an
|
||||||
|
// OAuth account is a list of them somebody picks from. Written together so a
|
||||||
|
// subscription is never a row with nothing under it: the sweep looks for accounts,
|
||||||
|
// and one without a calendar would be fetched for ever and stored nowhere.
|
||||||
|
//
|
||||||
|
// Shown from the start. Ticking a box after adding an address is a second step for
|
||||||
|
// a decision the person already made by adding it.
|
||||||
|
func (db *DB) SubscribeCalendar(ctx context.Context, userID, url, name string) (*CalendarAccount, error) {
|
||||||
|
account := &CalendarAccount{
|
||||||
|
ID: NewID(),
|
||||||
|
UserID: userID,
|
||||||
|
Provider: "ics",
|
||||||
|
Account: name,
|
||||||
|
URL: url,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO calendar_accounts (id, user_id, provider, account, url, created_at)
|
||||||
|
VALUES (?, ?, 'ics', ?, ?, ?)`,
|
||||||
|
account.ID, userID, name, url, account.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO calendars (id, account_id, remote_id, name, shown)
|
||||||
|
VALUES (?, ?, ?, ?, 1)`,
|
||||||
|
NewID(), account.ID, url, name)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrDuplicate
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions lists the calendars a person subscribes to by address.
|
||||||
|
func (db *DB) Subscriptions(ctx context.Context, userID string) ([]CalendarAccount, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||||
|
FROM calendar_accounts WHERE user_id = ? AND url <> ''
|
||||||
|
ORDER BY created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []CalendarAccount{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanCalendarAccount(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllSubscriptions is every subscription on the instance, for the sweep.
|
||||||
|
//
|
||||||
|
// The sweep runs for the server rather than for a session, so it cannot ask "whose
|
||||||
|
// is this" the way a handler can — it is handed the user id along with the row and
|
||||||
|
// writes against that.
|
||||||
|
func (db *DB) AllSubscriptions(ctx context.Context) ([]CalendarAccount, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||||
|
FROM calendar_accounts WHERE url <> '' ORDER BY last_sync_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []CalendarAccount{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanCalendarAccount(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameSubscribedCalendar writes the name the file calls itself.
|
||||||
|
//
|
||||||
|
// A subscription is added with the address as its name, because that is all there
|
||||||
|
// is to go on before it has been fetched once. The file usually says better —
|
||||||
|
// X-WR-CALNAME is what every publisher uses — and taking it means the list says
|
||||||
|
// "Danske helligdage" instead of a URL nobody reads.
|
||||||
|
//
|
||||||
|
// Only when the person has not renamed it themselves: a name they typed is an
|
||||||
|
// instruction, and a name from the file is a guess.
|
||||||
|
func (db *DB) RenameSubscribedCalendar(ctx context.Context, accountID, name string) error {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE calendar_accounts SET account = ?
|
||||||
|
WHERE id = ? AND (account = '' OR account = url)`, name, accountID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE calendars SET name = ?
|
||||||
|
WHERE account_id = ? AND (name = '' OR name = remote_id)`, name, accountID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribedCalendarID is the single calendar row under a subscription.
|
||||||
|
func (db *DB) SubscribedCalendarID(ctx context.Context, accountID string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM calendars WHERE account_id = ? LIMIT 1`, accountID).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Abonnement på en kalender, man kun har en adresse til.
|
||||||
|
--
|
||||||
|
-- Google-vejen er OAuth: én konto pr. person, hvor tokenet er identiteten, og
|
||||||
|
-- `calendar_accounts_once` sørgede for netop det — én række pr. (person,
|
||||||
|
-- udbyder). Et abonnement er en anden slags ting. Der er intet token og ingen
|
||||||
|
-- konto, kun en adresse, og man har flere: helligdage, en fodboldklub, en
|
||||||
|
-- fælleskalender fra arbejdet. Det er derfor det blev bedt om som "flere
|
||||||
|
-- kalendere".
|
||||||
|
--
|
||||||
|
-- Samme tabel frem for en ny. Et abonnement er én kalender med begivenheder i, og
|
||||||
|
-- det er præcis det, `calendars` og `calendar_events` allerede beskriver — med
|
||||||
|
-- vinduet, `shown`, farven og hele fejemaskinen omkring sig. En tabel til ville
|
||||||
|
-- være en anden vej til det samme sted, og to veje til ét sted er to steder at
|
||||||
|
-- rette den dag noget om begivenheder ændrer sig.
|
||||||
|
--
|
||||||
|
-- Adressen bliver identiteten, som tokenet er det for Google. To rækker med den
|
||||||
|
-- samme adresse for den samme person ville være den samme kalender hentet to
|
||||||
|
-- gange, og der er ingen måde at sige hvilken der gælder.
|
||||||
|
ALTER TABLE calendar_accounts ADD COLUMN url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Det gamle indeks holder stadig for OAuth-rækkerne — dem uden adresse — og
|
||||||
|
-- slipper abonnementerne fri. Delvise indekser er den ene ting SQLite kan her,
|
||||||
|
-- som en tabelombygning ellers skulle til for.
|
||||||
|
DROP INDEX calendar_accounts_once;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX calendar_accounts_once
|
||||||
|
ON calendar_accounts (user_id, provider) WHERE url = '';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX calendar_subscription_once
|
||||||
|
ON calendar_accounts (user_id, url) WHERE url <> '';
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- verdande:rebuild-tables
|
||||||
|
--
|
||||||
|
-- Notekroppe forsegles.
|
||||||
|
--
|
||||||
|
-- Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
|
||||||
|
-- sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem
|
||||||
|
-- hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler
|
||||||
|
-- ned — det ved vi, fordi der ligger sådan nogle i denne installation.
|
||||||
|
--
|
||||||
|
-- # Hvorfor det ikke var gjort for længe siden
|
||||||
|
--
|
||||||
|
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
|
||||||
|
--
|
||||||
|
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
|
||||||
|
-- "grøn" kan findes ved at skrive "gron".
|
||||||
|
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
|
||||||
|
--
|
||||||
|
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
|
||||||
|
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
|
||||||
|
-- skal skrives om til at åbne teksten i Go.
|
||||||
|
--
|
||||||
|
-- # Hvad det koster, og hvornår formen er forkert
|
||||||
|
--
|
||||||
|
-- Søgningen læser nu hver note. Målt: tolv hundrede noter à 3,3 kB koster 33–43 ms
|
||||||
|
-- pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så
|
||||||
|
-- det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne, men med at
|
||||||
|
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
|
||||||
|
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
|
||||||
|
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
|
||||||
|
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
|
||||||
|
-- delordssøgningen, der findes i dag, ville falde bort.
|
||||||
|
--
|
||||||
|
-- # Hvad der stadig ligger i klartekst
|
||||||
|
--
|
||||||
|
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
|
||||||
|
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
|
||||||
|
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
|
||||||
|
-- ikke løst her.
|
||||||
|
--
|
||||||
|
-- # Selve ombygningen
|
||||||
|
--
|
||||||
|
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
|
||||||
|
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
|
||||||
|
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
|
||||||
|
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
|
||||||
|
-- at kunne læses uden at gætte.
|
||||||
|
CREATE TABLE notes_sealed (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
archived_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notes_sealed
|
||||||
|
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
|
||||||
|
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
|
||||||
|
FROM notes;
|
||||||
|
|
||||||
|
DROP TRIGGER notes_fts_insert;
|
||||||
|
DROP TRIGGER notes_fts_delete;
|
||||||
|
DROP TRIGGER notes_fts_update;
|
||||||
|
DROP TABLE notes_fts;
|
||||||
|
DROP TABLE notes;
|
||||||
|
|
||||||
|
ALTER TABLE notes_sealed RENAME TO notes;
|
||||||
|
|
||||||
|
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
|
||||||
|
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
+133
-30
@@ -3,7 +3,9 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -89,6 +91,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
// it, and every caller gets the same answer because none of them can set it.
|
// it, and every caller gets the same answer because none of them can set it.
|
||||||
n.Title = firstLine(n.Body)
|
n.Title = firstLine(n.Body)
|
||||||
|
|
||||||
|
// Forseglet på vej ned, åbnet på vej op.
|
||||||
|
//
|
||||||
|
// Både titel og krop: titlen *er* første linje, så en forseglet krop ved siden
|
||||||
|
// af en læselig titel ville efterlade begyndelsen af hver eneste note i
|
||||||
|
// klartekst — og det er dér, folk skriver hvad noten handler om.
|
||||||
|
//
|
||||||
|
// `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den
|
||||||
|
// bagefter; at forsegle den på plads ville give en note, hvis krop er
|
||||||
|
// ciffertekst, til den, der lige har gemt den.
|
||||||
|
title, err := db.sealValue(n.Title)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note title: %w", err)
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(n.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
if n.ProjectID != "" {
|
if n.ProjectID != "" {
|
||||||
@@ -108,7 +128,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
pinned = excluded.pinned,
|
pinned = excluded.pinned,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
|
n.ID, projectID, title, body, createdBy, n.Pinned,
|
||||||
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -182,7 +202,7 @@ func firstLine(body string) string {
|
|||||||
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
||||||
row := db.QueryRowContext(ctx,
|
row := db.QueryRowContext(ctx,
|
||||||
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
||||||
n, err := scanNote(row)
|
n, err := db.scanNote(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -263,38 +283,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note,
|
|||||||
|
|
||||||
// SearchNotes matches title and body, in either spelling of a Danish word.
|
// SearchNotes matches title and body, in either spelling of a Danish word.
|
||||||
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||||
if strings.TrimSpace(query) == "" {
|
terms := SearchTerms(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
expr := MatchExprOver(query, "title", "body")
|
|
||||||
if expr == "" {
|
// Hver note læses og åbnes.
|
||||||
return nil, nil
|
//
|
||||||
|
// Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts`
|
||||||
|
// og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en
|
||||||
|
// forseglet krop ved siden af dem ville have været teater — en fil ville stadig
|
||||||
|
// røbe hver note. Se 0027.
|
||||||
|
//
|
||||||
|
// Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr.
|
||||||
|
// søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det
|
||||||
|
// ligger inde i pausen og ses ikke.
|
||||||
|
//
|
||||||
|
// Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få
|
||||||
|
// millisekunder — men med at folde og småskrive hver krop for hver søgning.
|
||||||
|
// Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det
|
||||||
|
// den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under
|
||||||
|
// samme nøgle. Bemærk hvad det koster, når det bliver aktuelt — et nøglet
|
||||||
|
// indeks kan slå et helt ord op og ikke andet, så præfiks- og delordssøgningen,
|
||||||
|
// der findes i dag, ville falde bort.
|
||||||
|
//
|
||||||
|
// Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning,
|
||||||
|
// der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne
|
||||||
|
// det.
|
||||||
|
notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
// Ordered by how well it matched, not by when it was last touched.
|
|
||||||
//
|
type scored struct {
|
||||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
note Note
|
||||||
// useless at twelve hundred: every one of them was imported the same evening,
|
score int
|
||||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
}
|
||||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
found := make([]scored, 0, limit)
|
||||||
// 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.
|
for _, note := range notes {
|
||||||
//
|
score := scoreNote(note, terms)
|
||||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
if score == 0 {
|
||||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
continue
|
||||||
// carries the same text transliterated, so it must weigh the same as the body
|
}
|
||||||
// or a Danish match would outrank an exact one.
|
found = append(found, scored{note: note, score: score})
|
||||||
//
|
}
|
||||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
|
||||||
// subquery throws it away.
|
// Bedste først, og ved lige stand den senest rørte. Uden det andet led er
|
||||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
// rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den
|
||||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
// skifter mellem to søgninger på det samme.
|
||||||
WHERE notes.deleted_at IS NULL
|
sort.SliceStable(found, func(i, j int) bool {
|
||||||
AND notes_fts MATCH ?
|
if found[i].score != found[j].score {
|
||||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
return found[i].score > found[j].score
|
||||||
LIMIT ?`, expr, limit)
|
}
|
||||||
|
return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Note, 0, min(limit, len(found)))
|
||||||
|
for i, f := range found {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, f.note)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke.
|
||||||
|
//
|
||||||
|
// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at
|
||||||
|
// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og
|
||||||
|
// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den
|
||||||
|
// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er
|
||||||
|
// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale.
|
||||||
|
//
|
||||||
|
// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet
|
||||||
|
// med AND.
|
||||||
|
func scoreNote(note Note, terms []string) int {
|
||||||
|
title := FoldDanish(strings.ToLower(note.Title))
|
||||||
|
body := FoldDanish(strings.ToLower(note.Body))
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
for _, term := range terms {
|
||||||
|
needle := FoldDanish(strings.ToLower(term))
|
||||||
|
inTitle := strings.Count(title, needle)
|
||||||
|
inBody := strings.Count(body, needle)
|
||||||
|
if inTitle == 0 && inBody == 0 {
|
||||||
|
// Ét ord, der ikke findes, er et nej for hele noten.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der
|
||||||
|
// nævner alle ordene én gang. Det er den fejl, en ren optælling laver, og
|
||||||
|
// den er let at lave og svær at få øje på.
|
||||||
|
score += 10*min(inTitle, 3) + min(inBody, 5)
|
||||||
|
}
|
||||||
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNoteTimes writes the dates a note had somewhere else.
|
// SetNoteTimes writes the dates a note had somewhere else.
|
||||||
@@ -380,7 +466,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any)
|
|||||||
|
|
||||||
var out []Note
|
var out []Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
n, err := scanNote(rows)
|
n, err := db.scanNote(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -408,7 +494,11 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanNote(row scanner) (Note, error) {
|
// scanNote læser en række og åbner det, der er forseglet.
|
||||||
|
//
|
||||||
|
// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste
|
||||||
|
// grund; alt andet i den er den samme aflæsning som før.
|
||||||
|
func (db *DB) scanNote(row scanner) (Note, error) {
|
||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
@@ -430,5 +520,18 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(archived.Int64, 0)
|
t := time.Unix(archived.Int64, 0)
|
||||||
n.ArchivedAt = &t
|
n.ArchivedAt = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst.
|
||||||
|
//
|
||||||
|
// En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:`
|
||||||
|
// findes netop for at kunne læse en blandet søjle — så en installation, der
|
||||||
|
// endnu ikke har fået sin bagudfyldning, virker imens.
|
||||||
|
for _, field := range []*string{&n.Title, &n.Body} {
|
||||||
|
plain, err := db.unsealValue(*field)
|
||||||
|
if err != nil {
|
||||||
|
return n, fmt.Errorf("open note %s: %w", n.ID, err)
|
||||||
|
}
|
||||||
|
*field = plain
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,210 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
|
||||||
|
//
|
||||||
|
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
|
||||||
|
// gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi,
|
||||||
|
// fordi der ligger sådan nogle i den installation, det her blev bygget til.
|
||||||
|
func TestANoteIsNotStoredInTheClear(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const secretLine = "gamerule keepInventory true"
|
||||||
|
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title, body string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, secretLine) {
|
||||||
|
t.Errorf("kroppen står i klartekst i rækken: %s", body)
|
||||||
|
}
|
||||||
|
// Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en
|
||||||
|
// læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.
|
||||||
|
if strings.Contains(title, "Minecraft") {
|
||||||
|
t.Errorf("titlen står i klartekst i rækken: %s", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
|
||||||
|
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
|
||||||
|
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Læst tilbage er den sig selv igen.
|
||||||
|
back, err := db.Note(ctx, n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
|
||||||
|
t.Errorf("noten kom anderledes tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
|
||||||
|
// indeks, der var en klartekstkopi af den.
|
||||||
|
found, err := db.SearchNotes(ctx, "keep inventory", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
|
||||||
|
//
|
||||||
|
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
|
||||||
|
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
|
||||||
|
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
|
||||||
|
func TestOldNotesAreSealedOnStartup(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
|
||||||
|
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
|
||||||
|
userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sealed != 1 {
|
||||||
|
t.Fatalf("%d noter blev lukket, want 1", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
var updated int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "hemmeligt") {
|
||||||
|
t.Errorf("den gamle note ligger stadig åben: %s", body)
|
||||||
|
}
|
||||||
|
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
|
||||||
|
// rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if updated != 200 {
|
||||||
|
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den kan læses.
|
||||||
|
back, err := db.Note(ctx, "n-gammel")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
|
||||||
|
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
|
||||||
|
// præfiks.
|
||||||
|
again, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("anden kørsel lukkede %d noter om igen", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,81 @@ type DB struct {
|
|||||||
// misplaced key look like a corrupt disk.
|
// misplaced key look like a corrupt disk.
|
||||||
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
||||||
|
|
||||||
|
// SealNotes lukker de noter, der stadig ligger åbne.
|
||||||
|
//
|
||||||
|
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
|
||||||
|
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
|
||||||
|
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
|
||||||
|
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
|
||||||
|
// den her ændring rent faktisk beskytter noget på.
|
||||||
|
//
|
||||||
|
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
|
||||||
|
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
|
||||||
|
// id'er og en længde.
|
||||||
|
//
|
||||||
|
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
|
||||||
|
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
|
||||||
|
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
|
||||||
|
func (db *DB) SealNotes(ctx context.Context) (int, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type open struct{ id, title, body string }
|
||||||
|
var pending []open
|
||||||
|
for rows.Next() {
|
||||||
|
var o open
|
||||||
|
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, o)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
|
||||||
|
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
|
||||||
|
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, o := range pending {
|
||||||
|
title, err := db.sealValue(o.title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(o.body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
|
||||||
|
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(pending), nil
|
||||||
|
}
|
||||||
|
|
||||||
// dsn is the connection string.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
|
|||||||
+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+")")
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ var (
|
|||||||
ErrNotFound = errors.New("store: not found")
|
ErrNotFound = errors.New("store: not found")
|
||||||
ErrEmailInUse = errors.New("store: that email address already has an account")
|
ErrEmailInUse = errors.New("store: that email address already has an account")
|
||||||
ErrInviteInvalid = errors.New("store: invite is not valid")
|
ErrInviteInvalid = errors.New("store: invite is not valid")
|
||||||
|
// ErrDuplicate er en unik nøgle, der blev brudt, oversat til noget en handler
|
||||||
|
// kan spørge om. Drivereren udstiller ikke sin egen fejltype brugbart, og en
|
||||||
|
// handler, der matcher på tekst, er en handler, der går i stykker den dag
|
||||||
|
// driveren omformulerer sig.
|
||||||
|
ErrDuplicate = errors.New("store: already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|||||||
+3
-1
@@ -50,10 +50,12 @@ nav:
|
|||||||
- Using it:
|
- Using it:
|
||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
- Quick add: quick-add.md
|
- Quick add: quick-add.md
|
||||||
|
- Notes: notes.md
|
||||||
- Filters: filters.md
|
- Filters: filters.md
|
||||||
- Importing from Todoist: import.md
|
- Importing from Todoist: import.md
|
||||||
- Connecting things:
|
- Connecting things:
|
||||||
- Calendar and CalDAV: caldav.md
|
- Calendar: calendar.md
|
||||||
|
- Feeds and CalDAV: caldav.md
|
||||||
- Mail to task: mail.md
|
- Mail to task: mail.md
|
||||||
- Mailboxes: mailboxes.md
|
- Mailboxes: mailboxes.md
|
||||||
- Claude (MCP): mcp.md
|
- Claude (MCP): mcp.md
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+442
-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,415 @@ 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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notelisten står i grupper.
|
||||||
|
*
|
||||||
|
* Den føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række sagde
|
||||||
|
* tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste række,
|
||||||
|
* også når tyve noter deler dag, og projektet fik en linje for sig selv.
|
||||||
|
*
|
||||||
|
* Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
|
||||||
|
* de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
|
||||||
|
* måde, for det er dér, de tolv hundrede importerede ligger.
|
||||||
|
*/
|
||||||
|
test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
for (const title of ['Gruppe én', 'Gruppe to']) {
|
||||||
|
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 page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Brødtekst i ' + title);
|
||||||
|
await expect(ed.locator('h1')).toHaveText(title);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Skrevet i dag, så de står under dagens overskrift.
|
||||||
|
const today = page.getByRole('heading', { name: /^I dag/ });
|
||||||
|
await expect(today, 'der er ingen datogruppe').toBeVisible();
|
||||||
|
|
||||||
|
// En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
|
||||||
|
// der står ikke hvorfor.
|
||||||
|
await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
|
||||||
|
const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
|
||||||
|
await row.hover();
|
||||||
|
await row.getByRole('button', { name: /favorit/i }).click();
|
||||||
|
const favourites = page.getByRole('heading', { name: /Favoritter/i });
|
||||||
|
await expect(favourites).toBeVisible();
|
||||||
|
|
||||||
|
// Og den ligger under den, ikke under dagen.
|
||||||
|
const inFavourites = page
|
||||||
|
.locator('h3.group', { hasText: /Favoritter/i })
|
||||||
|
.locator('+ ul li')
|
||||||
|
.filter({ hasText: 'Gruppe én' });
|
||||||
|
await expect(inFavourites).toBeVisible();
|
||||||
|
|
||||||
|
// Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
|
||||||
|
// åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
|
||||||
|
await favourites.getByRole('button').click();
|
||||||
|
await expect(inFavourites).toBeHidden();
|
||||||
|
await favourites.getByRole('button').click();
|
||||||
|
await expect(inFavourites).toBeVisible();
|
||||||
|
|
||||||
|
// En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
|
||||||
|
// matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
|
||||||
|
// andet end det, der blev spurgt om.
|
||||||
|
await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
|
||||||
|
await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('h3.group')).toHaveCount(0);
|
||||||
|
await page.getByPlaceholder('Søg i noter').fill('');
|
||||||
|
|
||||||
|
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
|
||||||
|
await row.hover();
|
||||||
|
await row.getByRole('button', { name: /Arkivér/i }).click();
|
||||||
|
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator('h3.group'),
|
||||||
|
'arkivet står ugrupperet'
|
||||||
|
).not.toHaveCount(0);
|
||||||
|
await expect(page.locator('li').filter({ hasText: 'Gruppe én' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Et projekts kalender kan vises som en uge.
|
||||||
|
*
|
||||||
|
* Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
|
||||||
|
* det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
|
||||||
|
*
|
||||||
|
* Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
|
||||||
|
* og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
|
||||||
|
* bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
|
||||||
|
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
|
||||||
|
*/
|
||||||
|
test('et projekts kalender kan vises som en uge', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
|
||||||
|
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill('male døren i dag kl 10');
|
||||||
|
await box.press('Enter');
|
||||||
|
await expect(page.getByText('male døren')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
|
||||||
|
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
|
||||||
|
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
|
||||||
|
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||||
|
await expect(placed).toContainText('10:00');
|
||||||
|
|
||||||
|
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
|
||||||
|
// afgør det, og projektet er delt med andre skærme.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
|
await expect(page.locator('.weekgrid')).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk.
|
||||||
|
*
|
||||||
|
* `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med:
|
||||||
|
* `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én
|
||||||
|
* nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive
|
||||||
|
* den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører
|
||||||
|
* hjemme dér, hvor læseren er.
|
||||||
|
*
|
||||||
|
* De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som
|
||||||
|
* fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens
|
||||||
|
* tekst — og den er dansk, hvad end kontoen er sat til.
|
||||||
|
*/
|
||||||
|
test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
for (const [text, says] of [
|
||||||
|
['vande planterne hver mandag', 'hver mandag'],
|
||||||
|
['tømme opvasker hverdage', 'hverdage']
|
||||||
|
]) {
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill(text);
|
||||||
|
await box.press('Enter');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// I indbakken og ikke på I dag: en ugentlig regel sætter den første
|
||||||
|
// forfaldsdag til den næste sådan dag, som sjældent er i dag.
|
||||||
|
await page
|
||||||
|
.getByRole('navigation', { name: 'Hovedmenu' })
|
||||||
|
.getByRole('link', { name: /^Indbakke/ })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const name = text.split(' ').slice(0, 2).join(' ');
|
||||||
|
const row = page.locator('.row', { hasText: name }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
row.locator('.repeat'),
|
||||||
|
`"${text}" blev ikke læst som en gentagelse`
|
||||||
|
).toContainText(says);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
@@ -381,6 +383,10 @@ export const api = {
|
|||||||
// an event, only which calendars to look at.
|
// an event, only which calendars to look at.
|
||||||
getCalendar: () => get('/calendar'),
|
getCalendar: () => get('/calendar'),
|
||||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||||
|
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
|
||||||
|
// én: adressen er identiteten, som tokenet er det for OAuth.
|
||||||
|
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
|
||||||
|
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
|
||||||
disconnectCalendar: () => del('/calendar'),
|
disconnectCalendar: () => del('/calendar'),
|
||||||
authorizeCalendar: () => post('/calendar/authorize'),
|
authorizeCalendar: () => post('/calendar/authorize'),
|
||||||
syncCalendar: () => post('/calendar/sync'),
|
syncCalendar: () => post('/calendar/sync'),
|
||||||
|
|||||||
@@ -64,7 +64,14 @@
|
|||||||
events = []
|
events = []
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
// Sat til i dag og lagt til rette af effekten nedenfor.
|
||||||
|
//
|
||||||
|
// Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
|
||||||
|
// øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
|
||||||
|
// byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
|
||||||
|
// komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
|
||||||
|
// projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
|
||||||
|
let cursor = $state(new Date());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The weekday names, from Intl rather than written out.
|
* The weekday names, from Intl rather than written out.
|
||||||
@@ -101,13 +108,26 @@
|
|||||||
// week with a cursor on the 1st would otherwise show the week containing the
|
// week with a cursor on the 1st would otherwise show the week containing the
|
||||||
// 1st rather than the week you were looking at, and switching back would land
|
// 1st rather than the week you were looking at, and switching back would land
|
||||||
// on whichever month that week happened to start in.
|
// on whichever month that week happened to start in.
|
||||||
|
//
|
||||||
|
// Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
|
||||||
|
// en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
|
||||||
|
// til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
|
||||||
|
// ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
|
||||||
|
// projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const aligned = startOfSpan(cursor);
|
const anchor =
|
||||||
|
span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
|
||||||
|
? new Date()
|
||||||
|
: cursor;
|
||||||
|
const aligned = startOfSpan(anchor);
|
||||||
// Compared by value, not identity: assigning a fresh Date on every run would
|
// Compared by value, not identity: assigning a fresh Date on every run would
|
||||||
// re-trigger this effect forever, since the effect reads what it writes.
|
// re-trigger this effect forever, since the effect reads what it writes.
|
||||||
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
|
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMonthStart = (d) => d.getDate() === 1;
|
||||||
|
const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
||||||
|
|
||||||
function iso(d) {
|
function iso(d) {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
d.getDate()
|
d.getDate()
|
||||||
@@ -272,6 +292,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 +308,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 +503,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 +755,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 +782,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 +790,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;
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export const da = {
|
|||||||
'notes.syntaxProject': 'projekt',
|
'notes.syntaxProject': 'projekt',
|
||||||
'notes.syntaxNote': 'en anden note',
|
'notes.syntaxNote': 'en anden note',
|
||||||
'notes.yesterday': 'i går',
|
'notes.yesterday': 'i går',
|
||||||
|
'notes.groupFavourites': 'Favoritter',
|
||||||
|
'notes.groupToday': 'I dag',
|
||||||
|
'notes.groupYesterday': 'I går',
|
||||||
|
'notes.groupWeek': 'Denne uge',
|
||||||
|
'notes.groupUnknown': 'Uden dato',
|
||||||
'notes.title': 'Noter',
|
'notes.title': 'Noter',
|
||||||
'notes.new': 'Ny note',
|
'notes.new': 'Ny note',
|
||||||
'notes.search': 'Søg i noter',
|
'notes.search': 'Søg i noter',
|
||||||
@@ -147,6 +152,25 @@ export const da = {
|
|||||||
'task.yesterday': 'I går',
|
'task.yesterday': 'I går',
|
||||||
'task.overdue': '{n} dage forsinket',
|
'task.overdue': '{n} dage forsinket',
|
||||||
'task.repeats': 'Gentages {rule}',
|
'task.repeats': 'Gentages {rule}',
|
||||||
|
'repeat.weekdays': 'hverdage',
|
||||||
|
'repeat.weekends': 'hver weekend',
|
||||||
|
'repeat.and': 'og',
|
||||||
|
'repeat.everyDay': 'hver {day}',
|
||||||
|
'repeat.everyNthWeekOn': 'hver {n}. uge {day}',
|
||||||
|
'repeat.everyOtherWeekOn': 'hver anden uge {day}',
|
||||||
|
'repeat.monthlyOn': 'den {day}. hver måned',
|
||||||
|
'repeat.every.day': 'hver dag',
|
||||||
|
'repeat.every.week': 'hver uge',
|
||||||
|
'repeat.every.month': 'hver måned',
|
||||||
|
'repeat.every.year': 'hvert år',
|
||||||
|
'repeat.everyOther.day': 'hver anden dag',
|
||||||
|
'repeat.everyOther.week': 'hver anden uge',
|
||||||
|
'repeat.everyOther.month': 'hver anden måned',
|
||||||
|
'repeat.everyOther.year': 'hvert andet år',
|
||||||
|
'repeat.everyN.day': 'hver {n}. dag',
|
||||||
|
'repeat.everyN.week': 'hver {n}. uge',
|
||||||
|
'repeat.everyN.month': 'hver {n}. måned',
|
||||||
|
'repeat.everyN.year': 'hvert {n}. år',
|
||||||
'task.assignee': 'Ansvarlig: {name}',
|
'task.assignee': 'Ansvarlig: {name}',
|
||||||
'task.subtasks': '{done} af {total} undertasks er lukket',
|
'task.subtasks': '{done} af {total} undertasks er lukket',
|
||||||
'task.attachments': '{n} vedhæftet',
|
'task.attachments': '{n} vedhæftet',
|
||||||
@@ -242,6 +266,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 +582,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',
|
||||||
@@ -658,6 +690,13 @@ export const da = {
|
|||||||
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
||||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||||
|
'int.subscriptions': 'Abonnementer',
|
||||||
|
'int.subscriptionsHint': 'En kalender, du kun har en adresse til — helligdage, en klub, en fælleskalender fra arbejdet. Den hentes hvert kvarter og er skrivebeskyttet. webcal://-adresser virker; de er https med et andet skema foran.',
|
||||||
|
'int.subscriptionURL': 'Kalenderens adresse',
|
||||||
|
'int.subscribe': 'Abonnér',
|
||||||
|
'int.subscribing': 'Henter …',
|
||||||
|
'int.unsubscribe': 'Fjern',
|
||||||
|
'int.unsubscribeQuestion': 'Fjern abonnementet på "{name}"? Begivenhederne forsvinder fra kalenderen.',
|
||||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||||
@@ -814,6 +853,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',
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const en = {
|
|||||||
'notes.syntaxProject': 'project',
|
'notes.syntaxProject': 'project',
|
||||||
'notes.syntaxNote': 'another note',
|
'notes.syntaxNote': 'another note',
|
||||||
'notes.yesterday': 'yesterday',
|
'notes.yesterday': 'yesterday',
|
||||||
|
'notes.groupFavourites': 'Favourites',
|
||||||
|
'notes.groupToday': 'Today',
|
||||||
|
'notes.groupYesterday': 'Yesterday',
|
||||||
|
'notes.groupWeek': 'This week',
|
||||||
|
'notes.groupUnknown': 'No date',
|
||||||
'notes.title': 'Notes',
|
'notes.title': 'Notes',
|
||||||
'notes.new': 'New note',
|
'notes.new': 'New note',
|
||||||
'notes.search': 'Search notes',
|
'notes.search': 'Search notes',
|
||||||
@@ -143,6 +148,25 @@ export const en = {
|
|||||||
'task.yesterday': 'Yesterday',
|
'task.yesterday': 'Yesterday',
|
||||||
'task.overdue': '{n} days overdue',
|
'task.overdue': '{n} days overdue',
|
||||||
'task.repeats': 'Repeats {rule}',
|
'task.repeats': 'Repeats {rule}',
|
||||||
|
'repeat.weekdays': 'weekdays',
|
||||||
|
'repeat.weekends': 'every weekend',
|
||||||
|
'repeat.and': 'and',
|
||||||
|
'repeat.everyDay': 'every {day}',
|
||||||
|
'repeat.everyNthWeekOn': 'every {n} weeks on {day}',
|
||||||
|
'repeat.everyOtherWeekOn': 'every other week on {day}',
|
||||||
|
'repeat.monthlyOn': 'the {day}th of every month',
|
||||||
|
'repeat.every.day': 'every day',
|
||||||
|
'repeat.every.week': 'every week',
|
||||||
|
'repeat.every.month': 'every month',
|
||||||
|
'repeat.every.year': 'every year',
|
||||||
|
'repeat.everyOther.day': 'every other day',
|
||||||
|
'repeat.everyOther.week': 'every other week',
|
||||||
|
'repeat.everyOther.month': 'every other month',
|
||||||
|
'repeat.everyOther.year': 'every other year',
|
||||||
|
'repeat.everyN.day': 'every {n} days',
|
||||||
|
'repeat.everyN.week': 'every {n} weeks',
|
||||||
|
'repeat.everyN.month': 'every {n} months',
|
||||||
|
'repeat.everyN.year': 'every {n} years',
|
||||||
'task.assignee': 'Assigned to {name}',
|
'task.assignee': 'Assigned to {name}',
|
||||||
'task.subtasks': '{done} of {total} sub-tasks are done',
|
'task.subtasks': '{done} of {total} sub-tasks are done',
|
||||||
'task.attachments': '{n} attached',
|
'task.attachments': '{n} attached',
|
||||||
@@ -238,6 +262,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 +578,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',
|
||||||
@@ -654,6 +686,13 @@ export const en = {
|
|||||||
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
|
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
|
||||||
'int.whichCalendars': 'Which calendars are shown',
|
'int.whichCalendars': 'Which calendars are shown',
|
||||||
'int.noCalendars': 'The account has no calendars to show.',
|
'int.noCalendars': 'The account has no calendars to show.',
|
||||||
|
'int.subscriptions': 'Subscriptions',
|
||||||
|
'int.subscriptionsHint': 'A calendar you only have an address to — holidays, a club, a shared calendar from work. Fetched every fifteen minutes, and read-only. webcal:// addresses work; they are https with a different scheme in front.',
|
||||||
|
'int.subscriptionURL': "The calendar's address",
|
||||||
|
'int.subscribe': 'Subscribe',
|
||||||
|
'int.subscribing': 'Fetching …',
|
||||||
|
'int.unsubscribe': 'Remove',
|
||||||
|
'int.unsubscribeQuestion': 'Stop subscribing to "{name}"? Its events go from the calendar.',
|
||||||
'int.calendarLastSync': 'Last fetched {when}.',
|
'int.calendarLastSync': 'Last fetched {when}.',
|
||||||
'int.calendarNeverSynced': 'Not fetched yet.',
|
'int.calendarNeverSynced': 'Not fetched yet.',
|
||||||
'int.calendarFetchedOne': '{n} event fetched.',
|
'int.calendarFetchedOne': '{n} event fetched.',
|
||||||
@@ -807,6 +846,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) {
|
||||||
@@ -851,27 +890,55 @@ export const upcomingView = new UpcomingView();
|
|||||||
const CALENDAR_MODES = ['week', 'month'];
|
const CALENDAR_MODES = ['week', 'month'];
|
||||||
|
|
||||||
class CalendarViewMode {
|
class CalendarViewMode {
|
||||||
mode = $state(readCalendarMode());
|
mode = $state('month');
|
||||||
|
|
||||||
|
// Nøglen som argument frem for som felt på en underklasse.
|
||||||
|
//
|
||||||
|
// Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
|
||||||
|
// basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
|
||||||
|
// og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
|
||||||
|
// andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
|
||||||
|
// Et argument har ingen rækkefølge at tage fejl af.
|
||||||
|
constructor(storageKey = 'verdande:calendar') {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
this.mode = readCalendarMode(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
set(next) {
|
set(next) {
|
||||||
if (!CALENDAR_MODES.includes(next)) return;
|
if (!CALENDAR_MODES.includes(next)) return;
|
||||||
this.mode = next;
|
this.mode = next;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('verdande:calendar', next);
|
localStorage.setItem(this.storageKey, next);
|
||||||
} catch {
|
} catch {
|
||||||
// Private browsing; the choice simply will not persist.
|
// Private browsing; the choice simply will not persist.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCalendarMode() {
|
function readCalendarMode(key) {
|
||||||
if (typeof localStorage === 'undefined') return 'month';
|
if (typeof localStorage === 'undefined') return 'month';
|
||||||
const stored = localStorage.getItem('verdande:calendar');
|
const stored = localStorage.getItem(key);
|
||||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calendarView = new CalendarViewMode();
|
export const calendarView = new CalendarViewMode();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Måned eller uge på et projekts kalender.
|
||||||
|
*
|
||||||
|
* Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
|
||||||
|
* hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
|
||||||
|
* *er* gitteret, og et projekts kalender er en del af en side, der også kan være
|
||||||
|
* en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
|
||||||
|
* og fandt et andet ændret.
|
||||||
|
*
|
||||||
|
* Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
|
||||||
|
* se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
|
||||||
|
* hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
|
||||||
|
* ligger i localStorage af.
|
||||||
|
*/
|
||||||
|
export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the lists show what has already been finished.
|
* Whether the lists show what has already been finished.
|
||||||
*
|
*
|
||||||
|
|||||||
+108
-2
@@ -73,8 +73,114 @@ export const REPEATS = [
|
|||||||
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** What a repeating task says on its row. */
|
/**
|
||||||
|
* What a repeating task says on its row.
|
||||||
|
*
|
||||||
|
* Beskrevet her og ikke på serveren, og det er ikke en smagssag.
|
||||||
|
*
|
||||||
|
* `recurrence.Describe` skriver dansk. Den kan ikke bare tage en locale med:
|
||||||
|
* `toTaskJSON` har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser,
|
||||||
|
* hvor én nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at
|
||||||
|
* skrive den i. Reglen ligger allerede i nyttelasten, og den er den samme uanset
|
||||||
|
* hvem der læser den. Så beskrivelsen hører hjemme dér, hvor læseren er.
|
||||||
|
*
|
||||||
|
* Serverens tekst bliver som reserve. Den er stadig den, et API-kald og en
|
||||||
|
* MCP-forbindelse får, og de har ingen flade at oversætte i.
|
||||||
|
*/
|
||||||
export function repeatLabel(rule, fallback = '') {
|
export function repeatLabel(rule, fallback = '') {
|
||||||
|
if (!rule) return '';
|
||||||
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||||
return known ? t(known.label) : fallback;
|
if (known) return t(known.label);
|
||||||
|
return describeRule(rule) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ugedagene i den rækkefølge RRULE navngiver dem. */
|
||||||
|
const RRULE_DAYS = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ugedagens navn på læserens sprog, fra Intl frem for fra en tabel.
|
||||||
|
*
|
||||||
|
* Ikke for at spare syv strenge pr. sprog — for at få de navne, sproget faktisk
|
||||||
|
* bruger. Den 1. januar 2024 var en mandag; enhver mandag ville gøre det, og en
|
||||||
|
* fast en holder det ude af tegningen.
|
||||||
|
*/
|
||||||
|
function dayName(code) {
|
||||||
|
const index = RRULE_DAYS[code];
|
||||||
|
if (index === undefined) return code;
|
||||||
|
return new Intl.DateTimeFormat(tag(), { weekday: 'long' }).format(
|
||||||
|
new Date(Date.UTC(2024, 0, 1 + index))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En RRULE sagt med ord.
|
||||||
|
*
|
||||||
|
* Dækker det, verdandes egen hurtig tilføjelse kan lave, og falder tilbage til
|
||||||
|
* reglen selv for alt andet — en kalenderklient kan sende ting, ingen her har
|
||||||
|
* skrevet, og en halv oversættelse af dem ville være værre end den rå regel.
|
||||||
|
*/
|
||||||
|
export function describeRule(rule) {
|
||||||
|
if (!rule) return '';
|
||||||
|
const parts = {};
|
||||||
|
for (const pair of rule.replace(/^RRULE:/i, '').split(';')) {
|
||||||
|
const [key, value] = pair.split('=');
|
||||||
|
if (key && value !== undefined) parts[key.toUpperCase()] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Number(parts.INTERVAL) > 0 ? Number(parts.INTERVAL) : 1;
|
||||||
|
|
||||||
|
if (parts.BYDAY) {
|
||||||
|
if (parts.BYDAY === 'MO,TU,WE,TH,FR') return t('repeat.weekdays');
|
||||||
|
if (parts.BYDAY === 'SA,SU') return t('repeat.weekends');
|
||||||
|
|
||||||
|
// Sorteret som ugen går, ikke som reglen tilfældigvis skrev dem: "mandag og
|
||||||
|
// torsdag" og "torsdag og mandag" er den samme regel, og den ene af dem er
|
||||||
|
// den, folk siger.
|
||||||
|
const names = parts.BYDAY.split(',')
|
||||||
|
.filter((code) => code in RRULE_DAYS)
|
||||||
|
.sort((a, b) => RRULE_DAYS[a] - RRULE_DAYS[b])
|
||||||
|
.map(dayName);
|
||||||
|
if (!names.length) return '';
|
||||||
|
|
||||||
|
const joined =
|
||||||
|
names.length === 1
|
||||||
|
? names[0]
|
||||||
|
: names.slice(0, -1).join(', ') + ' ' + t('repeat.and') + ' ' + names.at(-1);
|
||||||
|
|
||||||
|
// "hver mandag", ikke "hver uge mandag". En ugentlig regel, der navngiver
|
||||||
|
// sine dage, har ikke også brug for ordet for intervallet.
|
||||||
|
if (interval <= 1) return t('repeat.everyDay', { day: joined });
|
||||||
|
// To har sit eget ord på begge sprog — "hver anden", "every other" — og det
|
||||||
|
// er ikke tallet to sat ind i den samme sætning. Et sprog, der bøjer, kan
|
||||||
|
// ikke sættes sammen af stumper.
|
||||||
|
if (interval === 2) return t('repeat.everyOtherWeekOn', { day: joined });
|
||||||
|
return t('repeat.everyNthWeekOn', { n: interval, day: joined });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (parts.FREQ?.toUpperCase()) {
|
||||||
|
case 'DAILY':
|
||||||
|
return every(interval, 'day');
|
||||||
|
case 'WEEKLY':
|
||||||
|
return every(interval, 'week');
|
||||||
|
case 'MONTHLY':
|
||||||
|
if (parts.BYMONTHDAY) return t('repeat.monthlyOn', { day: parts.BYMONTHDAY });
|
||||||
|
return every(interval, 'month');
|
||||||
|
case 'YEARLY':
|
||||||
|
return every(interval, 'year');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "hver dag", "hver anden uge", "hver tredje måned".
|
||||||
|
*
|
||||||
|
* Tallet står i teksten frem for at blive sat sammen af stumper: "every other
|
||||||
|
* week" og "hver anden uge" er ikke to ord byttet om, og et sprog, der bøjer
|
||||||
|
* enheden efter tallet, kan ikke sættes sammen af to nøgler.
|
||||||
|
*/
|
||||||
|
function every(interval, unit) {
|
||||||
|
if (interval <= 1) return t(`repeat.every.${unit}`);
|
||||||
|
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||||
|
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- abonnementer ---------------------------------------------------------
|
||||||
|
let subscriptionURL = $state('');
|
||||||
|
let subscribing = $state(false);
|
||||||
|
let subscriptionError = $state('');
|
||||||
|
|
||||||
|
async function subscribe(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const url = subscriptionURL.trim();
|
||||||
|
if (!url) return;
|
||||||
|
subscribing = true;
|
||||||
|
subscriptionError = '';
|
||||||
|
try {
|
||||||
|
await api.subscribeCalendar(url, '');
|
||||||
|
subscriptionURL = '';
|
||||||
|
calendar = await api.getCalendar();
|
||||||
|
} catch (e) {
|
||||||
|
// Feltets egen fejl frem for en toast: den hører til det felt, der lige
|
||||||
|
// blev skrevet i, og en toast, der forsvinder, er en fejlmeddelelse, man
|
||||||
|
// skal huske.
|
||||||
|
subscriptionError = e.fields?.url ?? humanMessage(e);
|
||||||
|
} finally {
|
||||||
|
subscribing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe(sub) {
|
||||||
|
if (!confirm(t('int.unsubscribeQuestion', { name: sub.account || sub.url }))) return;
|
||||||
|
try {
|
||||||
|
await api.unsubscribeCalendar(sub.id);
|
||||||
|
calendar = await api.getCalendar();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCalendars(event) {
|
async function saveCalendars(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
savingCalendars = true;
|
savingCalendars = true;
|
||||||
@@ -548,6 +583,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Abonnementer står uden for Google-forgreningen ovenfor, og det er hele
|
||||||
|
pointen: en konto, der ikke kan bruge instansens OAuth-klient — en privat
|
||||||
|
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||||
|
{#if calendar !== null}
|
||||||
|
<div class="subs">
|
||||||
|
<h3>{t('int.subscriptions')}</h3>
|
||||||
|
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||||
|
|
||||||
|
{#if calendar.subscriptions?.length}
|
||||||
|
<ul class="subs-list">
|
||||||
|
{#each calendar.subscriptions as sub (sub.id)}
|
||||||
|
<li>
|
||||||
|
<span class="name">{sub.account || sub.url}</span>
|
||||||
|
<span class="url mono">{sub.url}</span>
|
||||||
|
<button class="danger" onclick={() => unsubscribe(sub)}>
|
||||||
|
{t('int.unsubscribe')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={subscribe}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="calendar-url">{t('int.subscriptionURL')}</label>
|
||||||
|
<input
|
||||||
|
id="calendar-url"
|
||||||
|
class="mono"
|
||||||
|
bind:value={subscriptionURL}
|
||||||
|
placeholder="https://… eller webcal://…"
|
||||||
|
/>
|
||||||
|
{#if subscriptionError}<p class="error">{subscriptionError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" type="submit" disabled={subscribing}>
|
||||||
|
{subscribing ? t('int.subscribing') : t('int.subscribe')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
|
import { colorVar } from '$lib/colors.js';
|
||||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
@@ -71,6 +72,100 @@
|
|||||||
const field = order === 'created' ? 'created_at' : 'updated_at';
|
const field = order === 'created' ? 'created_at' : 'updated_at';
|
||||||
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- listen i grupper ----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Listen føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række
|
||||||
|
// sagde tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste
|
||||||
|
// række — også når tyve noter deler dag — og projektet fik en linje for sig selv.
|
||||||
|
// Tre linjer pr. note, gange tolv hundrede.
|
||||||
|
//
|
||||||
|
// Gentagelserne flyttes derhen, hvor de siger noget. Datoen bliver til
|
||||||
|
// overskriften over en gruppe, og så forsvinder den fra rækken; projektet bliver
|
||||||
|
// en lille mærkat ude til højre. Noten går fra tre linjer til to, og luften
|
||||||
|
// kommer af, at der er mindre at skrive — ikke af at listen bliver længere.
|
||||||
|
|
||||||
|
/** Grupper, der er foldet sammen. Kun i denne session. */
|
||||||
|
let folded = $state(new Set());
|
||||||
|
|
||||||
|
function toggleGroup(key) {
|
||||||
|
const next = new Set(folded);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
folded = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken dato en gruppe måles på: den, der er sorteret efter.
|
||||||
|
*
|
||||||
|
* Ellers ville listen stå i én rækkefølge og være grupperet efter en anden, og
|
||||||
|
* så er overskrifterne løgn — en note ville stå under "August" mellem to fra
|
||||||
|
* juli, fordi den var *rørt* i august og *oprettet* i juli.
|
||||||
|
*/
|
||||||
|
const groupDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvad en gruppe hedder. I dag, i går, denne uge — og derefter måneden.
|
||||||
|
*
|
||||||
|
* Nøglen og etiketten er to ting: etiketten er sproget, og nøglen er den, der
|
||||||
|
* afgør om to noter hører sammen. Måneden som nøgle må bære året med sig, eller
|
||||||
|
* ville august 2026 og august 2025 blive én bunke.
|
||||||
|
*/
|
||||||
|
function bucketOf(note) {
|
||||||
|
if (order === 'title') {
|
||||||
|
// Sorteret på navn er der ingen dato at gruppere på, så det bliver
|
||||||
|
// forbogstavet — og datoen kommer tilbage i rækken, hvor den så er den
|
||||||
|
// eneste, der siger noget.
|
||||||
|
const first = (note.title || '').trim().charAt(0).toUpperCase();
|
||||||
|
const letter = /\p{L}|\p{N}/u.test(first) ? first : '#';
|
||||||
|
return { key: 'a-' + letter, label: letter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = new Date(groupDate(note));
|
||||||
|
if (Number.isNaN(at.getTime())) return { key: 'ukendt', label: t('notes.groupUnknown') };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const days = Math.floor((midnight - new Date(at.getFullYear(), at.getMonth(), at.getDate())) / 86400000);
|
||||||
|
|
||||||
|
if (days <= 0) return { key: 'i-dag', label: t('notes.groupToday') };
|
||||||
|
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
||||||
|
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
||||||
|
|
||||||
|
const label = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
||||||
|
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen, delt op.
|
||||||
|
*
|
||||||
|
* En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
|
||||||
|
* matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget,
|
||||||
|
* der ikke er svaret på det, der blev spurgt om.
|
||||||
|
*/
|
||||||
|
let groups = $derived.by(() => {
|
||||||
|
if (query.trim()) return [{ key: 'fundet', label: '', notes: ordered, plain: true }];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const favourites = ordered.filter((n) => n.pinned);
|
||||||
|
if (favourites.length) {
|
||||||
|
out.push({ key: 'favoritter', label: t('notes.groupFavourites'), notes: favourites, star: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = null;
|
||||||
|
for (const note of ordered) {
|
||||||
|
if (note.pinned) continue;
|
||||||
|
const bucket = bucketOf(note);
|
||||||
|
if (!current || current.key !== bucket.key) {
|
||||||
|
current = { key: bucket.key, label: bucket.label, notes: [] };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.notes.push(note);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Projektets egen farve, som i sidebjælken. */
|
||||||
|
const projectColour = (id) => colorVar(app.projects.find((p) => p.id === id)?.color);
|
||||||
let selected = $state(null);
|
let selected = $state(null);
|
||||||
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||||
let selectedId = $state(null);
|
let selectedId = $state(null);
|
||||||
@@ -258,6 +353,25 @@
|
|||||||
* which is exactly the thing the editor was built to stop showing. What is
|
* which is exactly the thing the editor was built to stop showing. What is
|
||||||
* wanted here is the sentence, not the notation.
|
* wanted here is the sentence, not the notation.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Uddraget, uden titlen foran.
|
||||||
|
*
|
||||||
|
* Titlen *er* første linje — det er hele reglen for, hvad en note hedder — så et
|
||||||
|
* uddrag, der begynder fra toppen, siger navnet én gang til. Det var svært at se,
|
||||||
|
* så længe en dato stod imellem dem; nu står de oven på hinanden, og så står der
|
||||||
|
* "Verdande" og under det "Verdande Forbindelse til connectoren".
|
||||||
|
*/
|
||||||
|
function preview(note) {
|
||||||
|
const body = note.preview ?? note.body ?? '';
|
||||||
|
const nl = body.indexOf('\n');
|
||||||
|
const rest = nl === -1 ? '' : body.slice(nl + 1);
|
||||||
|
// Kun hvis den første linje faktisk *er* titlen. Et uddrag fra serveren er
|
||||||
|
// afkortet, så en lang første linje kan være hele uddraget — og så er der
|
||||||
|
// ikke andet at vise end den.
|
||||||
|
const withoutTitle = plain(rest);
|
||||||
|
return withoutTitle || plain(body);
|
||||||
|
}
|
||||||
|
|
||||||
function plain(body) {
|
function plain(body) {
|
||||||
return (body ?? '')
|
return (body ?? '')
|
||||||
// Et billede har ingen ord i sig. Uden det her stod der
|
// Et billede har ingen ord i sig. Uden det her stod der
|
||||||
@@ -515,69 +629,99 @@
|
|||||||
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
{#each groups as group (group.key)}
|
||||||
{#each ordered as note (note.id)}
|
<!-- Overskriften klæber, mens man ruller. Med tolv hundrede noter er det
|
||||||
<li>
|
forskellen på at rulle og på at lede: man kan altid se, hvilken måned
|
||||||
<div class="rowline">
|
man er nede i. -->
|
||||||
<button
|
{#if !group.plain}
|
||||||
class="row"
|
<h3 class="group">
|
||||||
class:on={selectedId === note.id}
|
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
|
||||||
class:picked={picked.has(note.id)}
|
navn, og knappen *er* overskriften — så overskriften ville hedde
|
||||||
onclick={(e) => rowClick(e, note)}
|
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
|
||||||
draggable="true"
|
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
|
||||||
ondragstart={(e) => onDragStart(e, note)}
|
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
||||||
ondragend={() => (dragging = null)}
|
{#if group.star}
|
||||||
>
|
<span class="mark favmark" aria-hidden="true">★</span>
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
{:else}
|
||||||
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
|
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
||||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
{/if}
|
||||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
{group.label}
|
||||||
<span class="under">
|
<span class="count">{group.notes.length}</span>
|
||||||
<!-- Datoen, der svarer til den rækkefølge, listen står i.
|
</button>
|
||||||
Den viste altid `updated_at`, og for tolv hundrede
|
</h3>
|
||||||
importerede noter er det i aftes — så en note fra 2017
|
{/if}
|
||||||
stod som "i går", og den dato, man sorterede efter, var
|
{#if !folded.has(group.key)}
|
||||||
ingen steder at se. -->
|
<ul>
|
||||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
{#each group.notes as note (note.id)}
|
||||||
<span class="preview">{plain(note.preview ?? note.body)}</span>
|
<li>
|
||||||
</span>
|
<div class="rowline">
|
||||||
{#if note.project_id}
|
<button
|
||||||
<span class="filed">{projectName(note.project_id)}</span>
|
class="row"
|
||||||
{/if}
|
class:on={selectedId === note.id}
|
||||||
</button>
|
class:picked={picked.has(note.id)}
|
||||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
onclick={(e) => rowClick(e, note)}
|
||||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
draggable="true"
|
||||||
der er tre hundrede bred — og de bruges sjældent, mens
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
titlen og uddraget læses hele tiden. -->
|
ondragend={() => (dragging = null)}
|
||||||
<div class="rowactions">
|
>
|
||||||
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
||||||
til flere; det her er til den ene, man står med — og i
|
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
||||||
arkivet er det vejen tilbage. -->
|
på navn, for så er der ingen overskrift, der siger den, og så er
|
||||||
<button
|
den det eneste tilbage, der gør. -->
|
||||||
class="archive-one"
|
<span class="head">
|
||||||
onclick={() => archiveOne(note)}
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
{#if order === 'title'}
|
||||||
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
>
|
{/if}
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
{#if note.project_id}
|
||||||
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
||||||
</svg>
|
Mærkaten sagde det samme som alle de andre i en grå, der var
|
||||||
</button>
|
lysere end uddraget — og projektet er dét, man skanner efter,
|
||||||
<button
|
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
||||||
class="star"
|
Claude uden at læse ordet. -->
|
||||||
class:on={note.pinned}
|
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
||||||
onclick={() => favourite(note)}
|
<span class="dot" aria-hidden="true"></span>
|
||||||
aria-pressed={note.pinned}
|
{projectName(note.project_id)}
|
||||||
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
</span>
|
||||||
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
{/if}
|
||||||
>
|
</span>
|
||||||
{note.pinned ? '★' : '☆'}
|
<span class="preview">{preview(note)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||||
|
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||||
|
der er tre hundrede bred — og de bruges sjældent, mens
|
||||||
|
titlen og uddraget læses hele tiden. -->
|
||||||
|
<div class="rowactions">
|
||||||
|
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||||
|
til flere; det her er til den ene, man står med — og i
|
||||||
|
arkivet er det vejen tilbage. -->
|
||||||
|
<button
|
||||||
|
class="archive-one"
|
||||||
|
onclick={() => archiveOne(note)}
|
||||||
|
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="star"
|
||||||
|
class:on={note.pinned}
|
||||||
|
onclick={() => favourite(note)}
|
||||||
|
aria-pressed={note.pinned}
|
||||||
|
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
>
|
||||||
|
{note.pinned ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
{/each}
|
||||||
{/each}
|
</ul>
|
||||||
</ul>
|
{/if}
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -925,19 +1069,99 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- grupperne ----------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Overskriften klæber. Med tolv hundrede noter er det forskellen på at rulle og
|
||||||
|
* på at lede: man kan altid se, hvilken måned man er nede i.
|
||||||
|
*
|
||||||
|
* Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
|
||||||
|
* overskrift med rækkerne kørende igennem sig. */
|
||||||
|
h3.group {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--ground);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.group button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.group button:hover {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.group .mark {
|
||||||
|
flex: none;
|
||||||
|
width: 0.75em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
transition: transform var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.group .mark.closed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sit eget navn og ikke `.star`. Rækkens favoritknap hedder også det og er
|
||||||
|
gennemsigtig, indtil man peger på rækken — så overskriftens stjerne arvede
|
||||||
|
den gennemsigtighed og stod der aldrig. To ting, der ligner hinanden, er
|
||||||
|
ikke det samme, og et delt klassenavn er en påstand om, at de er. */
|
||||||
|
h3.group .favmark {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.group .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stregen mellem rækkerne er væk.
|
||||||
|
*
|
||||||
|
* Grupperne giver strukturen nu, og en streg pr. note er tolv hundrede streger.
|
||||||
|
* Luften kommer af, at rækken siger to ting i stedet for tre — ikke af at listen
|
||||||
|
* bliver længere. */
|
||||||
.row {
|
.row {
|
||||||
display: block;
|
display: block;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--s2) var(--s2) var(--s3);
|
/* Plads til højre for de to knapper, der ligger oven på rækken. Uden den
|
||||||
|
løber uddraget ind under stjernen — usynligt, indtil man peger på rækken
|
||||||
|
og knappen dukker op oven i ordene. */
|
||||||
|
padding: var(--s2) 26px calc(var(--s2) + 2px) var(--s2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
li:last-child .row {
|
/* Titel, dato og projekt på én linje. Projektet ude til højre, hvor øjet kan
|
||||||
border-bottom: 0;
|
løbe ned ad søjlen uden at læse resten. */
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row:hover {
|
||||||
@@ -964,33 +1188,56 @@
|
|||||||
|
|
||||||
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||||
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
||||||
.under {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s2);
|
|
||||||
min-width: 0;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.when {
|
.when {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `display: block`, og det er ikke pynt.
|
||||||
|
*
|
||||||
|
* Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
|
||||||
|
* op i gruppens overskrift, forsvandt den boks — og et inline-element klippes
|
||||||
|
* ikke af `overflow: hidden`, så den sidste sætning løb videre ind under
|
||||||
|
* favoritstjernen. Reglerne skal stå på det element, der faktisk skal klippes. */
|
||||||
.preview {
|
.preview {
|
||||||
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
|
/* Projektets egen farve, den samme som prikken i sidebjælken.
|
||||||
og den eneste linje her, der ikke er notens eget indhold. */
|
*
|
||||||
|
* Mærkaten sagde det samme som alle de andre i en grå, der var lysere end
|
||||||
|
* uddraget — og projektet er dét, man skanner efter, når man leder i tolv
|
||||||
|
* hundrede noter. En prik i projektets farve og en tekst, der ikke er lysere end
|
||||||
|
* det, den står ved siden af: Hjem kan kendes fra Claude uden at ordet læses.
|
||||||
|
*
|
||||||
|
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
||||||
|
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
||||||
|
* ikke læsbar. */
|
||||||
.filed {
|
.filed {
|
||||||
display: block;
|
display: flex;
|
||||||
margin-top: 2px;
|
align-items: center;
|
||||||
|
gap: 0.375em;
|
||||||
|
flex: none;
|
||||||
|
margin-left: auto;
|
||||||
|
max-width: 45%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filed .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--project, var(--line-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.filed::before {
|
.filed::before {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
@@ -786,7 +786,26 @@
|
|||||||
onsectionadded={(section) => (sections = [...sections, section])}
|
onsectionadded={(section) => (sections = [...sections, section])}
|
||||||
/>
|
/>
|
||||||
{:else if mode === 'calendar'}
|
{:else if mode === 'calendar'}
|
||||||
<CalendarView projectId={project.id} />
|
<!-- Uge eller måned inde i kalendervisningen frem for som en fjerde
|
||||||
|
`view_mode`.
|
||||||
|
`view_mode` er projektets og delt med alle, der kan se det; uge eller
|
||||||
|
måned er et spørgsmål om, hvor bred skærmen er, foran hvilken der
|
||||||
|
sidder én person. Og en fjerde værdi ville kræve en tabelombygning:
|
||||||
|
`CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
|
||||||
|
ændre på plads. -->
|
||||||
|
<div class="span" role="group" aria-label={t('view.mode')}>
|
||||||
|
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||||
|
<button
|
||||||
|
class:active={projectCalendarView.mode === value}
|
||||||
|
onclick={() => projectCalendarView.set(value)}
|
||||||
|
aria-pressed={projectCalendarView.mode === value}>{label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<CalendarView
|
||||||
|
projectId={project.id}
|
||||||
|
span={projectCalendarView.mode === 'week' ? 'week' : 'month'}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<section
|
<section
|
||||||
@@ -1263,6 +1282,32 @@
|
|||||||
0 0 0 3px var(--ink);
|
0 0 0 3px var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Den samme lille vælger som visningerne ovenfor, men ude til højre: den hører
|
||||||
|
til kalenderen, ikke til siden, og at stille den ved siden af Liste/Board/
|
||||||
|
Kalender ville lade som om der er fem visninger. */
|
||||||
|
.span {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--s1);
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span button {
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span button.active {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.views {
|
.views {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user