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.
|
||||
@@ -1396,6 +1396,57 @@ paths:
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/bulk:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Archive, unarchive or delete a set of notes in one call.
|
||||
description: |
|
||||
One call rather than one per note, because the thing being asked is one
|
||||
thing: "put these away". Fifty separate requests would be fifty round
|
||||
trips, fifty chances for half of them to fail, and a list that redraws
|
||||
fifty times — and after an import of twelve hundred notes, selecting fifty
|
||||
is the ordinary case rather than the extreme one.
|
||||
|
||||
Each note is still checked on its own. A set is a convenience for the
|
||||
caller and never a way to touch something they could not have touched one
|
||||
at a time; anything they may not write to is counted in `skipped` rather
|
||||
than failing the whole call, so one note somebody else just deleted does
|
||||
not stop the other forty-nine.
|
||||
|
||||
Archiving is not deleting. The trash says "this was a mistake, and in
|
||||
thirty days it is gone"; the archive says "this is finished, and I want to
|
||||
keep it".
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: At most 500.
|
||||
archived:
|
||||
type: boolean
|
||||
description: Put away, or bring back out.
|
||||
delete:
|
||||
type: boolean
|
||||
description: Into the trash instead. Wins over `archived`.
|
||||
responses:
|
||||
"200":
|
||||
description: How many were changed, and how many were passed over.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
done: { type: integer }
|
||||
skipped: { type: integer }
|
||||
"422":
|
||||
description: No ids, too many, or neither archived nor delete was asked for.
|
||||
|
||||
/api/v1/notes/{noteID}/attachments:
|
||||
post:
|
||||
tags: [notes]
|
||||
@@ -1998,6 +2049,125 @@ paths:
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}/sections/reorder:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order the project's sections appear in. Editors and owners.
|
||||
description: |
|
||||
The whole list at once, for the same reasons as `/projects/reorder`: a
|
||||
project has a handful of sections, so sending the order you want is
|
||||
simpler than midpoints and cannot land half-applied.
|
||||
|
||||
Ids belonging to another project are skipped rather than refused.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Section ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/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}:
|
||||
parameters:
|
||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||
|
||||
@@ -27,6 +27,9 @@ type testServer struct {
|
||||
*httptest.Server
|
||||
db *store.DB
|
||||
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 {
|
||||
@@ -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.
|
||||
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)
|
||||
|
||||
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
|
||||
// sequence of unrelated requests.
|
||||
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)
|
||||
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;
|
||||
// everything else the task carries stays as it is.
|
||||
content := parsed.Summary
|
||||
|
||||
@@ -3,10 +3,17 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"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
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
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) {
|
||||
// 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())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
out.Subscriptions = subscriptions
|
||||
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
|
||||
// why it takes a context and a user rather than a request.
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return written, err
|
||||
}
|
||||
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
|
||||
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
@@ -546,3 +577,232 @@ func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP -----------------------------------------------------------------------------
|
||||
@@ -250,6 +255,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
||||
return token
|
||||
}
|
||||
|
||||
// The promise of sharing has to hold at every door, not just the API's.
|
||||
//
|
||||
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
|
||||
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
|
||||
// authorised differently: it checked the caller's standing in the project *named
|
||||
// in the URL* and then took the task id from the URL as well. Nothing tied the
|
||||
// two together.
|
||||
//
|
||||
// So a viewer on somebody else's project could name a project of their own —
|
||||
// their Inbox, which everybody owns and everybody may edit — and put the other
|
||||
// project's task id in the filename. The project check passed on the Inbox, and
|
||||
// the write landed on a task in a project they may only read.
|
||||
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "[email protected]", "Viewer")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "en opgave", "project_id": projectID,
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "viewer",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
them, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, them.ID)
|
||||
|
||||
// Their own Inbox: a project they own and may edit, and the one every account
|
||||
// has. It is what makes the URL check pass.
|
||||
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var inbox string
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
inbox = p.ID
|
||||
}
|
||||
}
|
||||
if inbox == "" {
|
||||
t.Fatal("the viewer has no inbox")
|
||||
}
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
|
||||
token, them.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the task is as its owner left it. The status alone is not the assertion:
|
||||
// the question is whether anything moved.
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "en opgave" {
|
||||
t.Errorf("the task was renamed by a viewer: %v", after["content"])
|
||||
}
|
||||
if after["completed"] != false {
|
||||
t.Error("the task was completed by a viewer")
|
||||
}
|
||||
}
|
||||
|
||||
// A task may only be addressed under the project it is actually in.
|
||||
//
|
||||
// The same hole seen from the other side: an editor on two projects could reach a
|
||||
// task in one of them through the other's URL. Nothing is escalated by it — they
|
||||
// may edit both — but it is the check that was missing, and a URL that lies about
|
||||
// where a resource lives is how the viewer case got in.
|
||||
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, user.ID)
|
||||
|
||||
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
|
||||
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "hører til Et", "project_id": one["id"].(string),
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
|
||||
token, user.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "hører til Et" {
|
||||
t.Errorf("the task changed: %v", after["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -562,6 +677,48 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting something else must not delete the AI provider.
|
||||
//
|
||||
// Reported from use as "the AI settings are not saved": they were saved, and then
|
||||
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
|
||||
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
|
||||
// one row and each write deleted the other two. Every write returned 204.
|
||||
//
|
||||
// Through the handlers, because that is where it was seen. The shape is asserted
|
||||
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
|
||||
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
|
||||
})
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the calendar and the mailbox handlers do when a handshake starts: write
|
||||
// their own scope for the same person.
|
||||
for _, scope := range []string{"calendar", "gmail"} {
|
||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
|
||||
map[string]any{"pkce_state": "s-" + scope}); err != nil {
|
||||
t.Fatalf("save %s: %v", scope, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["provider"] != "anthropic" {
|
||||
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
|
||||
}
|
||||
if body["model"] != "claude-sonnet-5" {
|
||||
t.Errorf("the model is %v", body["model"])
|
||||
}
|
||||
if body["has_key"] != true {
|
||||
t.Error("the key was taken with it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSettings(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -1316,3 +1473,172 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||
//
|
||||
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||
"title": "Prøve", "body": "kom den frem?",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["subscriptions"] != float64(0) {
|
||||
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||
}
|
||||
|
||||
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||
// som et navngivent afslag og ikke som tavshed.
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||
UserID: user.ID,
|
||||
Endpoint: "https://push.example.invalid/abc",
|
||||
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||
if body["subscriptions"] != float64(1) {
|
||||
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||
}
|
||||
failed, _ := body["failed"].([]any)
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||
}
|
||||
first, _ := failed[0].(map[string]any)
|
||||
if first["service"] != "push.example.invalid" {
|
||||
t.Errorf("service = %v", first["service"])
|
||||
}
|
||||
if first["reason"] == "" || first["reason"] == nil {
|
||||
t.Error("afslaget kom uden en grund")
|
||||
}
|
||||
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 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:[email protected]\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(), "[email protected]")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
return
|
||||
}
|
||||
|
||||
// The archive is the same list asked the other way round, so it is the same
|
||||
// endpoint with a flag rather than a second one that would have to be kept in
|
||||
// step with this.
|
||||
if r.URL.Query().Get("archived") != "" {
|
||||
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
//
|
||||
// The list shows a title and about ninety characters. It was being sent whole
|
||||
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||
// parsed and held in memory on every visit.
|
||||
//
|
||||
// Body is emptied rather than shortened, and that is the important half. A field
|
||||
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||
// it, the debounce would save it, and the note would be cut down to its own
|
||||
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||
// check, and it does — see the notes page, which fetches the whole note when it
|
||||
// opens one.
|
||||
//
|
||||
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||
// it shows anything, and a heading marker and a bold word are characters that
|
||||
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||
// is meant to fill.
|
||||
const previewChars = 400
|
||||
|
||||
func briefly(notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
body := []rune(n.Body)
|
||||
if len(body) > previewChars {
|
||||
body = body[:previewChars]
|
||||
}
|
||||
n.Preview = string(body)
|
||||
n.Body = ""
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
@@ -687,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// --- putting notes away, one or many ---------------------------------------------
|
||||
|
||||
type noteBulkRequest struct {
|
||||
IDs []string `json:"ids"`
|
||||
Archived *bool `json:"archived"`
|
||||
Delete bool `json:"delete"`
|
||||
}
|
||||
|
||||
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
||||
// and few enough that the loop below cannot become somebody's afternoon.
|
||||
const maxNoteBulk = 500
|
||||
|
||||
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
||||
//
|
||||
// One call rather than one per note, because the thing being asked is one thing:
|
||||
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
||||
// chances for half of them to fail, and a list that redraws fifty times — and
|
||||
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
||||
// not the extreme one.
|
||||
//
|
||||
// Each note is still checked on its own. A set is a convenience for the caller
|
||||
// and must never become a way to touch something they could not have touched one
|
||||
// at a time.
|
||||
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req noteBulkRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "required"})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > maxNoteBulk {
|
||||
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
||||
return
|
||||
}
|
||||
|
||||
done, skipped := 0, 0
|
||||
for _, id := range req.IDs {
|
||||
n, err := s.db.Note(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// Not an error, and not a 404 for the whole call: a set can contain a note
|
||||
// somebody else deleted a second ago, and the other forty-nine should still
|
||||
// be put away. Counted so the answer is honest about it.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case req.Delete:
|
||||
err = s.db.DeleteNote(r.Context(), id)
|
||||
case req.Archived != nil:
|
||||
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
||||
default:
|
||||
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "note bulk", err)
|
||||
return
|
||||
}
|
||||
done++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
||||
}
|
||||
|
||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||
// fetched by id, which is what the interface does when it opens one.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
noteID := notes[0].(map[string]any)["id"].(string)
|
||||
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||
}
|
||||
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
body := whole["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -106,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
var out pushOutcome
|
||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return
|
||||
return out
|
||||
}
|
||||
out.Subscriptions = len(subs)
|
||||
|
||||
public, private, err := s.vapidKeys(ctx)
|
||||
if err != nil {
|
||||
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}
|
||||
@@ -128,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||
}
|
||||
|
||||
// handleReorderSections sets the order of the sections inside one project.
|
||||
//
|
||||
// Under /projects/{projectID}/ rather than beside the other section routes, which
|
||||
// hang off a section id: the thing being changed here is the project's list, not
|
||||
// any one section in it. That also puts it behind the same project permission
|
||||
// check as everything else in this subtree, instead of resolving a section to its
|
||||
// project one id at a time and hoping they all named the same one.
|
||||
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// The same ceiling as the projects list, and a bound on the transaction rather
|
||||
// than an opinion about how many sections a project may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
|
||||
s.internal(w, r, "reorder sections", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||
// it. Sections have no permissions of their own — they belong to a project, and
|
||||
// that is where access is decided.
|
||||
|
||||
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sections can be dragged into another order, and the order is the project's.
|
||||
//
|
||||
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
|
||||
// order the work happens in — and until this endpoint existed the only way to
|
||||
// change it was to delete a section and make it again, which moves its tasks to
|
||||
// the unsectioned area on the way past.
|
||||
func TestSectionsCanBeReordered(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||
id := project["id"].(string)
|
||||
|
||||
ids := map[string]string{}
|
||||
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||
for _, name := range names {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
|
||||
ids[name] = body["id"].(string)
|
||||
}
|
||||
|
||||
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
|
||||
order := make([]string, len(want))
|
||||
for i, name := range want {
|
||||
order[i] = ids[name]
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": order})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||
got, _ := listed["sections"].([]any)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
|
||||
}
|
||||
for i, raw := range got {
|
||||
if name := raw.(map[string]any)["name"]; name != want[i] {
|
||||
t.Errorf("section %d is %v, want %q", i, name, want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// A section belonging to somebody else's project is not moved by naming it
|
||||
// here. The ids in the body are not checked one by one — the WHERE clause is
|
||||
// what refuses them — so this is the assertion that it actually does.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
|
||||
otherID := other["id"].(string)
|
||||
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
|
||||
map[string]any{"name": "Havearbejde"})
|
||||
foreignID := foreign["id"].(string)
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": []string{foreignID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
|
||||
elsewhere, _ := stillThere["sections"].([]any)
|
||||
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
|
||||
t.Errorf("the other project's section did not survive: %v", elsewhere)
|
||||
}
|
||||
}
|
||||
|
||||
// A project can be created straight into a group.
|
||||
//
|
||||
// `group_id` was declared on the request type, documented on it, and applied by
|
||||
|
||||
@@ -63,6 +63,9 @@ type Server struct {
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
@@ -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),
|
||||
hub: realtime.NewHub(log),
|
||||
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),
|
||||
// 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
|
||||
@@ -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
|
||||
// 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
|
||||
// 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.Post("/subscribe", s.handleSubscribePush)
|
||||
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) {
|
||||
@@ -333,6 +352,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// files in and nothing else could, which made the most ordinary act
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||
r.Post("/bulk", s.handleNoteBulk)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -372,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("/sync", s.handleCalendarSyncNow)
|
||||
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) {
|
||||
@@ -423,6 +451,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1777,3 +1778,42 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
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", `\,`, ",", `\;`, ";", `\\`, `\`)
|
||||
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:[email protected]\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:[email protected]\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:[email protected]\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:[email protected]\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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
)
|
||||
|
||||
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("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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A subscription must not be able to point the server at its own network.
|
||||
//
|
||||
// The endpoint is whatever the browser handed over, which is to say whatever the
|
||||
// person signed in handed over, and this server POSTs to it on a schedule they
|
||||
// choose. The subscribe handler refuses a private address when it is written
|
||||
// down — and that check runs on the URL as written, so a name it approves can
|
||||
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
|
||||
// address that will actually be dialled, checked at the moment of dialling.
|
||||
func TestSendRefusesToReachInside(t *testing.T) {
|
||||
reached := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
// httptest listens on 127.0.0.1, which is exactly the destination in question.
|
||||
if !strings.Contains(srv.URL, "127.0.0.1") {
|
||||
t.Skipf("the test server is not on loopback: %s", srv.URL)
|
||||
}
|
||||
|
||||
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
|
||||
if err == nil {
|
||||
t.Fatal("Send reached a loopback address without complaining")
|
||||
}
|
||||
if reached {
|
||||
t.Error("the request arrived: the wall let it through and only the answer failed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSubscription(t *testing.T, endpoint string) Subscription {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Subscription{
|
||||
Endpoint: endpoint,
|
||||
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
|
||||
Auth: b64.EncodeToString(auth),
|
||||
}
|
||||
}
|
||||
|
||||
func testVAPID(t *testing.T) VAPID {
|
||||
t.Helper()
|
||||
pub, priv, err := GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:[email protected]"}
|
||||
}
|
||||
+159
-8
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,6 +16,12 @@ type CalendarAccount struct {
|
||||
Provider string `json:"provider"`
|
||||
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
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
@@ -65,10 +73,17 @@ type CalendarEvent struct {
|
||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||
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.
|
||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
||||
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
|
||||
// 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)
|
||||
if err == sql.ErrNoRows {
|
||||
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
|
||||
// that the unique index then refuses — which reads to the person as "connecting
|
||||
// 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, `
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
|
||||
account = excluded.account,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.URL != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The row that is actually there wins. On a reconnect the insert above updated
|
||||
// an existing row, so the id in hand is one nothing points at — and the
|
||||
// calendars are hung off the id, so writing them against it would attach them
|
||||
// to nothing.
|
||||
return db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
|
||||
a.UserID, a.Provider).Scan(&a.ID)
|
||||
}
|
||||
|
||||
@@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
var a CalendarAccount
|
||||
var expires, lastSync, created int64
|
||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||
&a.AccessToken, &expires, &lastSync, &created)
|
||||
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
@@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
a.CreatedAt = time.Unix(created, 0)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
||||
}
|
||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
|
||||
// The conflict target is (user_id, scope) since 0025 — before that the table
|
||||
// held one row per person and the upsert renamed its scope, which is the bug
|
||||
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
|
||||
// against the schema this test is running on.
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||
VALUES (?, 'gmail', ?, 0)
|
||||
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
||||
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
|
||||
userID, values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- A note can be put away without being thrown away.
|
||||
--
|
||||
-- The trash already exists and is the wrong tool. It says "this was a mistake,
|
||||
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
|
||||
-- an archive of twelve hundred imported notes is exactly the case where most of
|
||||
-- them are worth keeping and almost none are worth looking at. Without somewhere
|
||||
-- to put them, the list is the whole archive forever, and finding this week's note
|
||||
-- means reading past a decade.
|
||||
--
|
||||
-- A column rather than a folder. Verdande already refused a second hierarchy for
|
||||
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
|
||||
-- note is current, and it has to be answerable next to `deleted_at`, which is the
|
||||
-- other bit of the same kind.
|
||||
--
|
||||
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
|
||||
-- one: "when was this put away" answers questions a flag cannot, and it costs the
|
||||
-- same byte.
|
||||
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
|
||||
|
||||
-- The list asks "not deleted, not archived, newest first" on every load, and with
|
||||
-- twelve hundred notes that is the query that has to be quick.
|
||||
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- One row per person *and scope*, which is what the table always meant.
|
||||
--
|
||||
-- `user_settings` was created with `user_id` as its whole primary key, and its
|
||||
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
|
||||
-- and whichever scope was written last was the only one that survived: saving an
|
||||
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
|
||||
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
|
||||
-- row it landed on.
|
||||
--
|
||||
-- This was seen coming twice. Migration 0011 gave the instance's settings their
|
||||
-- own table partly for this reason and wrote the warning down in as many words;
|
||||
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
|
||||
-- time". The third time was the calendar, which added a `calendar` scope beside
|
||||
-- `ai` and `gmail` — and walked in.
|
||||
--
|
||||
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
|
||||
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
|
||||
-- left to warn about.
|
||||
--
|
||||
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
|
||||
-- rows are copied as they are: each person has at most one, and it holds whatever
|
||||
-- scope won last. What the earlier writes lost is already gone and cannot be
|
||||
-- recovered here — the values were overwritten, not orphaned.
|
||||
CREATE TABLE user_settings_new (
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT 'general',
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, scope)
|
||||
);
|
||||
|
||||
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
|
||||
SELECT user_id, scope, values_json, updated_at FROM user_settings;
|
||||
|
||||
DROP TABLE user_settings;
|
||||
|
||||
ALTER TABLE user_settings_new RENAME TO user_settings;
|
||||
@@ -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 <> '';
|
||||
+163
-21
@@ -10,15 +10,26 @@ import (
|
||||
|
||||
// Note is a page of Markdown belonging to a project, or to nobody.
|
||||
type Note struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
|
||||
// Preview is the first of the body, for a list that shows one line of it.
|
||||
//
|
||||
// It exists so a list of twelve hundred notes does not have to carry twelve
|
||||
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
|
||||
// characters of each. When it is set, Body is deliberately empty — see
|
||||
// handleListNotes. That is not tidiness but a guard: an editor handed a
|
||||
// truncated body would save the truncation, and the note would be cut down to
|
||||
// its own preview.
|
||||
Preview string `json:"preview,omitempty"`
|
||||
|
||||
// Links is what the body points at, read out of the text on the way in. Never
|
||||
// set by a caller: it is derived, and a caller that set it would be describing
|
||||
@@ -33,7 +44,16 @@ type NoteLink struct {
|
||||
}
|
||||
|
||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||
created_at, updated_at, deleted_at`
|
||||
created_at, updated_at, deleted_at, archived_at`
|
||||
|
||||
// The same columns, said with the table in front of them.
|
||||
//
|
||||
// Needed only by the search, which joins `notes_fts` — and that table carries its
|
||||
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
|
||||
notes.archived_at`
|
||||
|
||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||
@@ -199,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
// shared, and a list that dropped it at that moment would make sharing look like
|
||||
// deleting — which is exactly how it read before this existed.
|
||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
return db.allNotes(ctx, userID, false)
|
||||
}
|
||||
|
||||
// ArchivedNotes is the same list, the other way round: what has been put away.
|
||||
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.allNotes(ctx, userID, true)
|
||||
}
|
||||
|
||||
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
|
||||
// Archived notes are left out of the ordinary list rather than mixed in and
|
||||
// greyed: the point of putting something away is not seeing it.
|
||||
state := "notes.archived_at IS NULL"
|
||||
if archived {
|
||||
state = "notes.archived_at IS NOT NULL"
|
||||
}
|
||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||
WHERE `+state+` AND deleted_at IS NULL
|
||||
AND (
|
||||
(project_id IS NULL AND created_by = ?)
|
||||
OR project_id IN (
|
||||
@@ -238,13 +273,89 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
|
||||
// on it would work and read the whole index to do it.
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
|
||||
ORDER BY updated_at DESC
|
||||
// Ordered by how well it matched, not by when it was last touched.
|
||||
//
|
||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||
// useless at twelve hundred: every one of them was imported the same evening,
|
||||
// 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
|
||||
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||
// that does not work, because from where the person is sitting it is one.
|
||||
//
|
||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||
// carries the same text transliterated, so it must weigh the same as the body
|
||||
// or a Danish match would outrank an exact one.
|
||||
//
|
||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||
// subquery throws it away.
|
||||
found, err := db.notesJoin(ctx, noteColumnsQualified, `
|
||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||
WHERE notes.deleted_at IS NULL
|
||||
AND notes_fts MATCH ?
|
||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||
LIMIT ?`, expr, limit)
|
||||
if err != nil || len(found) > 0 {
|
||||
return found, err
|
||||
}
|
||||
return db.searchNotesInside(ctx, query, limit)
|
||||
}
|
||||
|
||||
// searchNotesInside finds a word that lives *inside* another word.
|
||||
//
|
||||
// An index is built of tokens, and a token is what a tokenizer decided was a
|
||||
// word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
|
||||
// gets nothing. Reported as "søgning har ikke det store held", which from where
|
||||
// the person is sitting is exactly what it is: the note is right there, and the
|
||||
// search says there is nothing.
|
||||
//
|
||||
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
|
||||
// cannot, because it is not a prefix of anything — and the two are joined with AND,
|
||||
// so the half that works is thrown away by the half that cannot.
|
||||
//
|
||||
// This is not an exotic case in a notes app. A decade of notes is full of
|
||||
// commands, identifiers and file names, and every one of them is several words the
|
||||
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
|
||||
//
|
||||
// So: the index answers first, and when it has nothing, the text is read. Every
|
||||
// term has to appear somewhere in the note, anywhere, in any word.
|
||||
//
|
||||
// # What it costs, and when to stop doing it this way
|
||||
//
|
||||
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
|
||||
// hundred notes that is four megabytes and a millisecond or two, and it only runs
|
||||
// when the fast path already came back empty — which is the search that was about
|
||||
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
|
||||
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
|
||||
//
|
||||
// It does not rank. bm25 needs a match to rank, and there is no match here — that
|
||||
// is the whole situation. The rows are the most recently touched first, and the
|
||||
// set is small, because the index already said there was nothing in it.
|
||||
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||
terms := SearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
|
||||
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
|
||||
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
|
||||
// answer to "inventory".
|
||||
//
|
||||
// The needle is safe to interpolate into a pattern because `tokenize` splits on
|
||||
// everything that is not a letter or a digit: a term cannot contain % or _, so
|
||||
// there is no wildcard for somebody to smuggle in. It is still a bound
|
||||
// parameter — that is about SQL, and this is about LIKE.
|
||||
where := "WHERE deleted_at IS NULL"
|
||||
args := make([]any, 0, len(terms)+1)
|
||||
for _, term := range terms {
|
||||
where += " AND fold LIKE ?"
|
||||
args = append(args, "%"+FoldDanish(term)+"%")
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
|
||||
}
|
||||
|
||||
// SetNoteTimes writes the dates a note had somewhere else.
|
||||
@@ -278,6 +389,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNoteArchived puts a note away, or takes it back out.
|
||||
//
|
||||
// Separate from DeleteNote because they mean different things and the difference
|
||||
// is the whole feature: the trash says "this was a mistake, and in thirty days it
|
||||
// is gone", and the archive says "this is finished, and I want to keep it". A
|
||||
// twelve-hundred-note import is mostly the second, and without somewhere to put
|
||||
// them the list is the whole archive forever.
|
||||
//
|
||||
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
|
||||
// sorted by when things were last written should not be reordered by somebody
|
||||
// tidying up.
|
||||
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
|
||||
var at any
|
||||
if archived {
|
||||
at = time.Now().Unix()
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
@@ -295,7 +427,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
|
||||
return db.notesJoin(ctx, noteColumns, where, args...)
|
||||
}
|
||||
|
||||
// notesJoin is notesWhere with the column list spelled out, for the one caller
|
||||
// that joins another table and must therefore say which `id` it means.
|
||||
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -335,10 +473,10 @@ func scanNote(row scanner) (Note, error) {
|
||||
var n Note
|
||||
var projectID, createdBy sql.NullString
|
||||
var created, updated int64
|
||||
var deleted sql.NullInt64
|
||||
var deleted, archived sql.NullInt64
|
||||
|
||||
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
||||
&n.Pinned, &created, &updated, &deleted); err != nil {
|
||||
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.ProjectID = projectID.String
|
||||
@@ -349,5 +487,9 @@ func scanNote(row scanner) (Note, error) {
|
||||
t := time.Unix(deleted.Int64, 0)
|
||||
n.DeletedAt = &t
|
||||
}
|
||||
if archived.Valid {
|
||||
t := time.Unix(archived.Int64, 0)
|
||||
n.ArchivedAt = &t
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -333,3 +333,103 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||
t.Error("a note title did not match itself")
|
||||
}
|
||||
}
|
||||
|
||||
// En søgning skal svare med det, der passer bedst, øverst.
|
||||
//
|
||||
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
||||
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
||||
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
||||
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
||||
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
||||
// fra brugerens plads er præcis dét.
|
||||
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
||||
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
||||
for _, body := range []string{
|
||||
"# Tesla Model Y\n\nBilen selv.\n",
|
||||
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
||||
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
||||
} {
|
||||
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 3 {
|
||||
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
||||
}
|
||||
if found[0].Title != "Tesla Model Y" {
|
||||
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// A word inside a word.
|
||||
//
|
||||
// Reported as "søgning har ikke det store held": a note holding
|
||||
// `gamerule keepInventory true` could not be found by typing "keep inventory".
|
||||
// The index is built of tokens, and `keepInventory` is one token — so the two
|
||||
// words are nowhere, and the search says there is nothing while the note is right
|
||||
// there.
|
||||
//
|
||||
// A decade of notes is full of these: commands, identifiers, file names. Every one
|
||||
// of them is several words the index thinks is one.
|
||||
func TestAWordInsideAWordIsStillFound(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second note, so a fallback that simply returns everything would be caught.
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Body: "Indkøb\n\nKaffe og filtre",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, q := range []string{
|
||||
"keep inventory", // the two words, which is how somebody looks for it
|
||||
"inventory", // the tail on its own — no prefix query can reach this
|
||||
"keepinventory", // written as one, which the index does hold
|
||||
"KEEP INVENTORY", // and case is not a question anybody is asking
|
||||
"vacuum", // not in either note
|
||||
} {
|
||||
found, err := db.SearchNotes(ctx, q, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", q, err)
|
||||
}
|
||||
want := 1
|
||||
if q == "vacuum" {
|
||||
want = 0
|
||||
}
|
||||
if len(found) != want {
|
||||
t.Errorf("%q found %d notes, want %d", q, len(found), want)
|
||||
continue
|
||||
}
|
||||
if want == 1 && found[0].Title != "Minecraft Bedrock" {
|
||||
t.Errorf("%q found %q", q, found[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// The fast path is still the fast path: a word the index does hold must not be
|
||||
// answered by the scan, or the ranking that was built for twelve hundred notes
|
||||
// is quietly gone.
|
||||
found, err := db.SearchNotes(ctx, "Bedrock", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("an ordinary word found %d notes", len(found))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
|
||||
// alone and set `scope = excluded.scope` — so writing one scope did not add a
|
||||
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
|
||||
// `calendar` settings each deleted the other two, and the write reported
|
||||
// success every time.
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
ON CONFLICT (user_id, scope) DO UPDATE SET
|
||||
values_json = excluded.values_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
userID, scope, string(raw), time.Now().Unix())
|
||||
|
||||
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderSections writes the order of one project's sections, top to bottom.
|
||||
//
|
||||
// The whole list at once, the same as ReorderProjects and for the same reasons: a
|
||||
// project has a handful of sections, so writing 0, 1, 2 … is one small
|
||||
// transaction that cannot run out of precision, and a list is impossible to get
|
||||
// into the half-ordered state a sequence of individual moves leaves behind when
|
||||
// one of them fails.
|
||||
//
|
||||
// Scoped to the project rather than to a user. A section has no owner of its own —
|
||||
// it belongs to a project, and whether the caller may rearrange it is a question
|
||||
// about that project, which the handler has already asked. Ids belonging to some
|
||||
// other project are skipped by the WHERE clause rather than refused, so a stale
|
||||
// list from a client that has just moved a section elsewhere cannot rearrange a
|
||||
// project the caller was never looking at.
|
||||
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().Unix()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE sections SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||
float64(i), now, id, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SectionByName resolves a "/name" from quick add within one project.
|
||||
//
|
||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||
|
||||
@@ -90,6 +90,20 @@ func MatchExprOver(query string, columns ...string) 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
|
||||
// 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.
|
||||
|
||||
@@ -124,3 +124,156 @@ func sealedStore(t *testing.T) (*DB, string) {
|
||||
}
|
||||
return db, user.ID
|
||||
}
|
||||
|
||||
// Three integrations, one person, and none of them may delete the others.
|
||||
//
|
||||
// `user_settings` had `user_id` as its whole primary key and an upsert that
|
||||
// rewrote `scope` on conflict, so every person had exactly one settings row and
|
||||
// the last write won the lot: saving an AI provider deleted the Gmail connection,
|
||||
// and connecting a calendar deleted the AI provider. The write reported success
|
||||
// each time, which is why it was reported as "the AI settings are not saved"
|
||||
// rather than as "connecting the calendar deleted them".
|
||||
//
|
||||
// Written against the store rather than the handlers, because the shape was the
|
||||
// bug: every caller was correct on its own.
|
||||
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
want := map[string]map[string]any{
|
||||
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
|
||||
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
|
||||
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
|
||||
}
|
||||
// In this order on purpose: the AI settings are written first and read last,
|
||||
// so a scope that steals the row is caught by the one written earliest.
|
||||
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
|
||||
t.Fatalf("save %s: %v", scope, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||
got, err := db.UserSettings(ctx, userID, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", scope, err)
|
||||
}
|
||||
for key, value := range want[scope] {
|
||||
if got[key] != value {
|
||||
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a second write to one scope updates it rather than adding another row —
|
||||
// the upsert still has to upsert.
|
||||
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 3 {
|
||||
t.Errorf("the person has %d settings rows, want 3", rows)
|
||||
}
|
||||
back, err := db.UserSettings(ctx, userID, "ai")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back["provider"] != "openai" {
|
||||
t.Errorf("the second write did not land: %v", back["provider"])
|
||||
}
|
||||
}
|
||||
|
||||
// The upgrade path, which is the one nobody runs: an existing database with a row
|
||||
// in the old shape, opened by a binary that carries 0025.
|
||||
//
|
||||
// Every other test here starts empty and runs all the migrations in order, which
|
||||
// exercises the migration's SQL but not its *situation*. The situation is a table
|
||||
// that already holds somebody's settings, and the question is whether they come
|
||||
// through it and whether a second scope can be written afterwards.
|
||||
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
user := &User{Email: "[email protected]", Name: "Kristian"}
|
||||
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
|
||||
// key, and 0025 not yet applied.
|
||||
for _, stmt := range []string{
|
||||
`PRAGMA foreign_keys = OFF`,
|
||||
`CREATE TABLE us_old (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT 'general',
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
|
||||
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
|
||||
`DROP TABLE user_settings`,
|
||||
`ALTER TABLE us_old RENAME TO user_settings`,
|
||||
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
|
||||
`PRAGMA foreign_keys = ON`,
|
||||
} {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("rewind (%s): %v", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Gmail connection, written the way the old upsert wrote one.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// And now the new binary opens it.
|
||||
db, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the upgrade refused to open the database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gmail["label"] != "Verdande" {
|
||||
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
|
||||
}
|
||||
|
||||
// The thing that was reported: set up the AI provider, and have it still be
|
||||
// there afterwards.
|
||||
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
|
||||
"provider": "anthropic", "model": "claude-sonnet-5",
|
||||
}); err != nil {
|
||||
t.Fatalf("saving the AI provider after the upgrade: %v", err)
|
||||
}
|
||||
ai, err := db.UserSettings(ctx, user.ID, "ai")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ai["provider"] != "anthropic" {
|
||||
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
|
||||
}
|
||||
again, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again["label"] != "Verdande" {
|
||||
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -166,15 +166,42 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
// labels are short, and their names are typed by the person searching for
|
||||
// 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.
|
||||
//
|
||||
// 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, `(
|
||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||
OR t.id IN (
|
||||
SELECT tl.task_id FROM task_labels tl
|
||||
JOIN labels l ON l.id = tl.label_id
|
||||
WHERE lower(l.name) LIKE ?
|
||||
)
|
||||
)`+text+`
|
||||
)`)
|
||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||
args = append(args, insideArgs...)
|
||||
}
|
||||
if f.FilterSQL != "" {
|
||||
where = append(where, "("+f.FilterSQL+")")
|
||||
|
||||
@@ -16,6 +16,11 @@ var (
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
ErrEmailInUse = errors.New("store: that email address already has an account")
|
||||
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 {
|
||||
|
||||
+596
-81
@@ -6,12 +6,12 @@
|
||||
<title>verdande — self-hosted tasks, shared</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
||||
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
|
||||
/>
|
||||
<meta name="theme-color" content="#111312" />
|
||||
|
||||
<meta property="og:title" content="verdande" />
|
||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
||||
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="screenshots/today.webp" />
|
||||
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
||||
@@ -76,10 +76,10 @@
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
/* In the bar now rather than floating over the corner. Fixed, it sat
|
||||
on top of whatever happened to scroll under it — on a narrow window
|
||||
that was the first screenshot's own corner. */
|
||||
flex: none;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: grid;
|
||||
@@ -141,10 +141,86 @@
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* --- the bar that stays -------------------------------------------
|
||||
|
||||
A page this long had its only way out at the very top: somebody who
|
||||
read as far as "What it will not do" had to scroll back past nine
|
||||
screenshots to reach the documentation. The bar is the way out from
|
||||
wherever you are.
|
||||
|
||||
`sticky` and not `fixed`: sticky keeps its space in the flow, so
|
||||
nothing below has to be pushed down by a number that then has to be
|
||||
kept in step with the bar's own height.
|
||||
|
||||
Full width, with the wrap *inside* it. Put the other way round, the
|
||||
background would end where the column ends and the page would scroll
|
||||
past it on both sides. */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--ground);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
}
|
||||
|
||||
.bar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bar-brand .rune {
|
||||
color: var(--accent);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bar-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bar-links a {
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bar-links a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The three in-page links go first on a narrow screen: they are the ones
|
||||
a phone can reach by scrolling anyway. Documentation and Source cannot
|
||||
be reached any other way, so they stay. */
|
||||
@media (max-width: 640px) {
|
||||
.bar-links .inpage {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Or the bar covers the heading you just jumped to. */
|
||||
:target {
|
||||
scroll-margin-top: 4.5rem;
|
||||
}
|
||||
|
||||
/* --- hero --- */
|
||||
|
||||
header {
|
||||
padding: 6rem 0 4rem;
|
||||
.hero {
|
||||
padding: 5rem 0 3.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -172,7 +248,7 @@
|
||||
}
|
||||
|
||||
.norn {
|
||||
margin: 2rem auto 0;
|
||||
margin: 0 auto 1.75rem;
|
||||
max-width: 34rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-faint);
|
||||
@@ -289,44 +365,104 @@
|
||||
|
||||
/* --- features --- */
|
||||
|
||||
/* A hairline between sections rather than under every heading: the page
|
||||
is one column, and the line says where one subject stops. */
|
||||
section {
|
||||
padding: 3rem 0;
|
||||
padding: 3.5rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
/* A short accent rule in front of every h2. The sections had nothing to
|
||||
mark them but their own words, and at a glance the page read as one
|
||||
long run of paragraphs. */
|
||||
h2::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 1.125rem;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
transform: translateY(-0.35em);
|
||||
}
|
||||
|
||||
/* Three groups rather than eighteen cards in a row. Grouped, the list is
|
||||
read as three answers — what it does, what it talks to, what it
|
||||
promises — and any of the three can be skipped. */
|
||||
.group-label {
|
||||
margin: 2.5rem 0 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.group-label:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Cards with their own edges, not cells in one table.
|
||||
The table shared a hairline grid, which meant the last row had to be
|
||||
full or it was a hole in the drawing — and it was a hole, for as long
|
||||
as there were ten of them. A card that stands alone on its row is a
|
||||
card; an empty cell is a mistake. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--ground);
|
||||
padding: 1.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.125rem 1.25rem 1.25rem;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.cell h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.4375rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.cell p {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.cell code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* A card that has something to admit rather than something to offer.
|
||||
It is not a footnote and it is not fine print: it is the same size as
|
||||
everything else, and it says the same thing the README says. */
|
||||
.cell .caveat {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* --- install --- */
|
||||
|
||||
pre {
|
||||
@@ -374,6 +510,168 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Four facts under the buttons, in the place where a landing page usually
|
||||
puts logos it has not earned. They are the four questions somebody
|
||||
self-hosting asks before anything else. */
|
||||
ul.facts {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem 1.25rem;
|
||||
margin: 2rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
ul.facts li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* The separator belongs between two facts, not after the last one — so it
|
||||
is drawn by the item that follows it. */
|
||||
ul.facts li + li::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
/* On a phone the row wraps, and a separator drawn by the item that follows
|
||||
it lands at the *start* of the new line — a dot with nothing before it.
|
||||
No separators there; the wrap is the separation. */
|
||||
@media (max-width: 640px) {
|
||||
ul.facts {
|
||||
gap: 0.375rem 1.125rem;
|
||||
}
|
||||
|
||||
ul.facts li + li::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two ways to run it, side by side, because they are alternatives rather
|
||||
than steps. Stacked, the second read as what you do after the first. */
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
/* Sunk rather than raised: inside a card, a block in --surface would be
|
||||
the same colour as the card it sits in. */
|
||||
.panel pre {
|
||||
background: var(--ground);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.panel p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Versionsnummeret i sin egen søjle, så øjet kan løbe ned ad dem uden at
|
||||
læse sætningerne. `ch` frem for pixels: søjlen er lige så bred som de
|
||||
tal, der står i den, og den følger med, hvis skriften skifter. */
|
||||
ul.lately {
|
||||
list-style: none;
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul.lately li {
|
||||
display: grid;
|
||||
grid-template-columns: 5ch 1fr;
|
||||
gap: 0 1rem;
|
||||
padding: 0.875rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
ul.lately .ver {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink-faint);
|
||||
/* Tallet står på linje med den første tekstlinje og ikke over den. */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
ul.lately li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Risikoen står i kort, ikke i småt.
|
||||
Et forbehold sat med mindre skrift end resten er et forbehold, der er
|
||||
skrevet for at kunne peges på bagefter — og det er den ene ting, denne
|
||||
side ikke skal gøre. Samme størrelse som alt andet. */
|
||||
.risk {
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.risk p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.risk p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.risk strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The non-goals were one paragraph of seven sentences, which is the shape
|
||||
nobody reads. Each is its own line now, and they can be counted. */
|
||||
ul.nogo {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 0.5rem 1.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
ul.nogo li {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
ul.nogo li::before {
|
||||
content: '—';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
margin: 1rem 0 0;
|
||||
font-family: var(--font);
|
||||
@@ -524,27 +822,38 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
||||
<span aria-hidden="true">◐</span>
|
||||
</button>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<div class="wrap bar">
|
||||
<a class="bar-brand" href="#top">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
verdande
|
||||
</a>
|
||||
<nav class="bar-links" aria-label="Sections">
|
||||
<a class="inpage" href="#screens">Screens</a>
|
||||
<a class="inpage" href="#features">Features</a>
|
||||
<a class="inpage" href="#running">Running it</a>
|
||||
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
<a href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
||||
<span aria-hidden="true">◐</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap" id="top">
|
||||
<header class="hero">
|
||||
<span class="mark" aria-hidden="true">ᚹ</span>
|
||||
<h1>verdande</h1>
|
||||
<p class="lede">
|
||||
Self-hosted tasks and projects, shared with the people you share the work with.
|
||||
One binary, SQLite inside it.
|
||||
Self-hosted tasks, notes and projects, shared with the people you share the work
|
||||
with. One binary, SQLite inside it.
|
||||
</p>
|
||||
<p class="rune-note">
|
||||
Built to be deployed as a <strong>Rune</strong> in
|
||||
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
||||
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
||||
</p>
|
||||
<p class="norn">
|
||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
||||
in the middle of. Which is what a to-do list is.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
@@ -553,6 +862,14 @@
|
||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
</div>
|
||||
|
||||
<ul class="facts">
|
||||
<li>One Go binary</li>
|
||||
<li>SQLite inside it</li>
|
||||
<li>amd64 and arm64</li>
|
||||
<li>No account anywhere but yours</li>
|
||||
<li>MIT</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
||||
@@ -584,7 +901,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<section id="screens">
|
||||
<h2>What it looks like</h2>
|
||||
<figure class="shot only-dark">
|
||||
<img
|
||||
@@ -669,11 +986,43 @@
|
||||
/>
|
||||
<figcaption>What has been done on the server, across every project.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/delegated.webp"
|
||||
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/notes-source.webp"
|
||||
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/settings.webp"
|
||||
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="features">
|
||||
<h2>What it does</h2>
|
||||
|
||||
<p class="group-label">Doing the work</p>
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<h3>Reads what you type</h3>
|
||||
@@ -683,20 +1032,54 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Shared, properly</h3>
|
||||
<h3>Nothing to remember</h3>
|
||||
<p>
|
||||
Owner, editor and viewer roles. People outside your instance join by
|
||||
invite link. Changes appear live.
|
||||
Start typing anywhere and you are writing a task, with the letter you
|
||||
pressed already in the field. <code>⌘K</code> goes anywhere else.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Search that reads Danish</h3>
|
||||
<p>
|
||||
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
|
||||
consider <code>ø</code> an accented <code>o</code>, so a search that
|
||||
leans on that alone is broken for the people this was built for.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Repeats, and says so in words</h3>
|
||||
<p>
|
||||
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||
screen. Reminders are absolute or relative, and go out to the devices
|
||||
you have allowed them on.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Notes, beside the work</h3>
|
||||
<p>
|
||||
Rich text to write in, Markdown on disk. File a note in a project and
|
||||
everybody in it can read it; <code>#project</code> and
|
||||
Rich text to write in, Markdown on disk — and a source view to see it.
|
||||
Images are pasted or dragged in. <code>#project</code> and
|
||||
<code>[[another note]]</code> are links, and a task shows what has been
|
||||
written about it. A zip of Markdown goes in and comes back out the same
|
||||
shape, so nothing is trapped here.
|
||||
written about it. Archive what is done with it; the bin is for mistakes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Waiting on others</h3>
|
||||
<p>
|
||||
What you have handed to somebody else, on its own page, grouped under
|
||||
the person who has it. The thing a shared list stops being able to tell
|
||||
you the moment it is shared.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="group-label">Connected to what you already use</p>
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<h3>Shared, properly</h3>
|
||||
<p>
|
||||
Owner, editor and viewer roles. People outside your instance join by
|
||||
invite link. Changes appear live.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -706,6 +1089,27 @@
|
||||
here. Not a read-only feed pretending.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>A calendar of its own</h3>
|
||||
<p>
|
||||
A month or a week of what is due, and a secret ICS address to subscribe
|
||||
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
|
||||
laid over the top, read-only.
|
||||
</p>
|
||||
<p class="caveat">
|
||||
The Google half is written to Google's documentation and has not yet
|
||||
been run against Google. The feed and CalDAV have.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Your mail, as an inbox</h3>
|
||||
<p>
|
||||
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||
it becomes a task. Forward one to your own address instead and the
|
||||
subject is parsed like anything else you type. One way: unstarring it
|
||||
later does nothing to the task you made.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Claude, connected</h3>
|
||||
<p>
|
||||
@@ -714,35 +1118,39 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Your mail, as an inbox</h3>
|
||||
<h3>AI only if you ask for it</h3>
|
||||
<p>
|
||||
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
||||
a task. Forward one to your own address instead and the subject is
|
||||
parsed like anything else you type. One way: unstarring it later does
|
||||
nothing to the task you made.
|
||||
Off by default, your own key, and your choice of Anthropic, OpenAI,
|
||||
Google or something local. Nothing leaves the server until you have
|
||||
filled that in, and everything works without it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="group-label">Yours, and checkable</p>
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<h3>Nothing to remember</h3>
|
||||
<h3>More than one way in</h3>
|
||||
<p>
|
||||
Start typing anywhere and you are writing a task, with the letter you
|
||||
pressed already in the field. Sign in with a passkey and there is no
|
||||
password to forget either.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Yours to leave</h3>
|
||||
<p>
|
||||
Import from Todoist, export back to it, or take everything as JSON. The
|
||||
round trip is tested, not promised.
|
||||
Passkeys, or a password hashed with Argon2id and a second factor with
|
||||
recovery codes. Every device you are signed in on is listed, and any of
|
||||
them can be signed out from here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Keys stay behind</h3>
|
||||
<p>
|
||||
Mail tokens and mailbox passwords are encrypted in the database, and the
|
||||
key lives in a file beside it — not inside it. So a backup you download,
|
||||
or one that ends up somewhere it should not, does not open your mail.
|
||||
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
|
||||
encrypted in the database, and the key lives in a file beside it — not
|
||||
inside it. So a backup you download, or one that ends up somewhere it
|
||||
should not, does not open your mail.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Nobody signs themselves up</h3>
|
||||
<p>
|
||||
The first account is the administrator, and after that people arrive by
|
||||
invitation. An address on the internet is not an open door.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -753,50 +1161,157 @@
|
||||
instance's record, not the person's.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Yours to leave</h3>
|
||||
<p>
|
||||
Import from Todoist, export back to it, take everything as JSON, or
|
||||
hand the notes a zip of Markdown and get the same zip back out. The
|
||||
round trip is tested, not promised.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>It counts itself, out loud</h3>
|
||||
<p>
|
||||
Once a day an instance says it exists — a random id, the version, and
|
||||
nothing else. No tasks, no names, no addresses. It is on to begin with,
|
||||
the settings page prints the exact payload, and one switch stops it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="recently">
|
||||
<h2>Lately</h2>
|
||||
<p class="muted">
|
||||
A landing page can say a project is alive; a list of what landed says it
|
||||
better. This is not a changelog — every release has notes, and they are on
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<ul class="lately">
|
||||
<li>
|
||||
<span class="ver">0.34</span>
|
||||
Sections can be dragged into the order the work actually happens in. The
|
||||
sidebar keeps its head still while the projects scroll.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.33</span>
|
||||
Notes grew an archive, selection of many at once, pictures you can paste
|
||||
or drag in, a source view, and a copy button on code blocks. Search
|
||||
stopped answering by date and started answering by relevance.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.32</span>
|
||||
A calendar of its own as a menu item, with a read-only Google calendar
|
||||
that can be laid over it.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.31</span>
|
||||
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
||||
task — not only Gmail.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.30</span>
|
||||
An installation count that says exactly what it sends, and can be turned
|
||||
off in one click.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="running">
|
||||
<h2>Running it</h2>
|
||||
|
||||
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
|
||||
<p class="muted">
|
||||
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
|
||||
<code>rune/verdande.yaml</code>, create a server from it, set the public address
|
||||
and your mail server, and start it. No port forwarding — give it a subdomain and
|
||||
the panel routes it through your Cloudflare Tunnel.
|
||||
</p>
|
||||
|
||||
<h3 class="sub">Or with Docker, anywhere</h3>
|
||||
<pre><code>docker run -d --name verdande \
|
||||
<div class="panels">
|
||||
<div class="panel">
|
||||
<h3>As a Rune in Yggdrasil Panel</h3>
|
||||
<p>
|
||||
This is what it was built for. <strong>Runes → Carve a rune</strong>,
|
||||
upload <code>rune/verdande.yaml</code>, create a server from it, set the
|
||||
public address and your mail server, and start it. No port forwarding —
|
||||
give it a subdomain and the panel routes it through your Cloudflare
|
||||
Tunnel.
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Or with Docker, anywhere</h3>
|
||||
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
|
||||
<pre><code>docker run -d --name verdande \
|
||||
-p 8080:8080 \
|
||||
-v verdande-data:/data \
|
||||
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
||||
ghcr.io/kristianwind/verdande:latest</code></pre>
|
||||
<p class="muted" style="margin-top: 1rem">
|
||||
Open the address and create the first account. That one is the administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="plain">
|
||||
<li>No database to provision — SQLite is inside the binary.</li>
|
||||
<li>Distroless image: no shell, no package manager, one static file.</li>
|
||||
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
||||
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
||||
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="limits">
|
||||
<h2>What it will not do</h2>
|
||||
<p class="muted">
|
||||
No karma or points. No team workspaces. No native apps. No mail integration
|
||||
beyond Gmail and the forwarding address. No languages beyond Danish and English.
|
||||
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
|
||||
</p>
|
||||
<ul class="nogo">
|
||||
<li>No karma, streaks or points.</li>
|
||||
<li>No team workspaces — projects and the people in them are the whole model.</li>
|
||||
<li>No native apps. It is a PWA, and it installs on a phone.</li>
|
||||
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
|
||||
<li>No languages beyond Danish and English.</li>
|
||||
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
|
||||
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="risk">
|
||||
<h2>At your own risk</h2>
|
||||
<div class="risk">
|
||||
<p>
|
||||
verdande is <strong>MIT licensed and provided as is, without warranty of
|
||||
any kind</strong>. That sentence is in every MIT licence and it is usually
|
||||
skipped, so here it is in the open: if you run this, you run it at your
|
||||
own risk, and nobody is on the hook if it loses your work.
|
||||
</p>
|
||||
<p>
|
||||
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>
|
||||
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
||||
where it stood between somebody arriving and the buttons they came
|
||||
for — and it is a closing thought, not an opening one. -->
|
||||
<p class="norn">
|
||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
||||
in the middle of. Which is what a to-do list is.
|
||||
</p>
|
||||
<p>
|
||||
MIT licensed ·
|
||||
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
||||
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
</p>
|
||||
<p>
|
||||
Built to run as a Rune in
|
||||
|
||||
+648
-10
@@ -358,6 +358,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
'aria-expanded',
|
||||
'false'
|
||||
);
|
||||
|
||||
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
||||
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
||||
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
||||
// ingenting at male henover den med.
|
||||
const chevron = await sidebar
|
||||
.locator('.folder-head .chevron-button')
|
||||
.evaluate((el) => ({
|
||||
background: getComputedStyle(el).backgroundColor,
|
||||
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
||||
}));
|
||||
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
||||
|
||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||
|
||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||
@@ -962,10 +976,19 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
||||
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-allday="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
@@ -1117,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 af kontoens kalendere er valgt endnu.')).toBeHidden();
|
||||
|
||||
// Both kinds are in the same cell, which is the whole point of the view.
|
||||
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
|
||||
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
|
||||
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
|
||||
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
|
||||
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
|
||||
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
|
||||
// lagt klokken nul ville påstå, den holdt fra midnat til midnat.
|
||||
const band = page.locator(`[data-allday="${day}"]`);
|
||||
const event = cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' });
|
||||
const allDay = band.locator('.event').filter({ hasText: 'Feriedag' });
|
||||
const task = band.locator('.chip').filter({ hasText: 'vande blomster' });
|
||||
await expect(event).toBeVisible();
|
||||
await expect(allDay).toBeVisible();
|
||||
await expect(task).toBeVisible();
|
||||
@@ -1145,11 +1172,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
||||
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
||||
|
||||
// The task still reschedules by drag, in a grid that now has events in it.
|
||||
await task.dragTo(page.locator(`[data-date="${next}"]`));
|
||||
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(cell.getByText('vande blomster')).toBeHidden();
|
||||
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
|
||||
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||
// slipper der, hvor man kan se, man slipper.
|
||||
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
|
||||
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||
await expect(cell.locator('.event')).toHaveCount(2);
|
||||
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||
await expect(band.locator('.event')).toHaveCount(1);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
@@ -1814,6 +1848,14 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
|
||||
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
|
||||
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
|
||||
const inner = await sidebar
|
||||
.locator('.scroller')
|
||||
.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||
expect(inner, 'rulleboksen kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// And the whole page, for the same reason at a different scale.
|
||||
const page_sideways = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
@@ -1827,6 +1869,58 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Mærket og de faste visninger bliver stående, når resten ruller.
|
||||
*
|
||||
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
|
||||
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
|
||||
*
|
||||
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
|
||||
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
|
||||
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
|
||||
*/
|
||||
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 420 });
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
const scroller = sidebar.locator('.scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
// Rullebjælken begynder under de faste visninger og ikke bag dem.
|
||||
const geometry = await sidebar.evaluate((el) => {
|
||||
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
|
||||
const scroll = el.querySelector('.scroller');
|
||||
return {
|
||||
views: box('.views'),
|
||||
scroller: box('.scroller'),
|
||||
overflows: scroll.scrollHeight - scroll.clientHeight
|
||||
};
|
||||
});
|
||||
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
expect(
|
||||
geometry.scroller.top,
|
||||
'rulleboksen begynder oppe i de faste visninger'
|
||||
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
|
||||
|
||||
// Og hovedet bliver, hvor det er, hele vejen ned.
|
||||
const brand = sidebar.locator('.brand');
|
||||
const before = await brand.boundingBox();
|
||||
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await expect
|
||||
.poll(async () => scroller.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
const after = await brand.boundingBox();
|
||||
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
|
||||
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A project can have more than one section.
|
||||
*
|
||||
@@ -1899,6 +1993,76 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sektioner kan trækkes op og ned.
|
||||
*
|
||||
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
|
||||
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
|
||||
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
|
||||
* område på vejen.
|
||||
*
|
||||
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
|
||||
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
|
||||
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
|
||||
*/
|
||||
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Havehus');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
|
||||
|
||||
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await field.fill(name);
|
||||
await field.press('Enter');
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const order = () =>
|
||||
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
|
||||
|
||||
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||
|
||||
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
|
||||
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
|
||||
// under, så positionen er skrevet ud og ikke overladt til midten.
|
||||
await page
|
||||
.locator('.section-head')
|
||||
.filter({ hasText: 'Tag' })
|
||||
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
|
||||
targetPosition: { x: 40, y: 3 }
|
||||
});
|
||||
|
||||
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
|
||||
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
|
||||
'Tag',
|
||||
'Fundament',
|
||||
'Rejsning'
|
||||
]);
|
||||
|
||||
// Og den anden vej: sluppet i den nederste halvdel lander den under.
|
||||
await page
|
||||
.locator('.section-head')
|
||||
.filter({ hasText: 'Tag' })
|
||||
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
|
||||
targetPosition: { x: 40, y: 28 }
|
||||
});
|
||||
|
||||
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Form fields on a touch device are at least 16px.
|
||||
*
|
||||
@@ -2740,3 +2904,477 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await page.goto('/');
|
||||
await sidebar.getByLabel('Nyt projekt').click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
|
||||
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: '# Løs note\n\nSkal flyttes ved at trække.\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
await page.evaluate((noteId) => (window.__noteId = noteId), id);
|
||||
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
|
||||
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
|
||||
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
|
||||
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
|
||||
await page.evaluate(() => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('application/x-verdande-note', window.__noteId);
|
||||
const target = [...document.querySelectorAll('nav a')].find((a) =>
|
||||
a.textContent.includes('Trækmål')
|
||||
);
|
||||
for (const type of ['dragover', 'drop']) {
|
||||
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Det, der tæller: noten ligger i projektet bagefter.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return await r.json();
|
||||
}, id);
|
||||
expect(body.project_id).toBeTruthy();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.goto('/noter');
|
||||
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const copy = ed.locator('pre .copy');
|
||||
await expect(copy).toBeVisible();
|
||||
await copy.click();
|
||||
await expect(copy).toHaveText('Kopieret');
|
||||
|
||||
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toBe('docker pull verdande');
|
||||
|
||||
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
|
||||
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
|
||||
// fælde, den her prøve findes for.
|
||||
await ed.click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' ');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
expect(body).toContain('docker pull verdande');
|
||||
expect(body).not.toContain('Kopiér');
|
||||
expect(body).not.toContain('Kopieret');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
|
||||
await page.evaluate(async (t) => {
|
||||
await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
|
||||
});
|
||||
}, title);
|
||||
}
|
||||
await page.reload();
|
||||
const rows = page.locator('.notes button.row');
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
|
||||
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
|
||||
// hører med i markeringen — ellers ville det andet klik markere én og glemme
|
||||
// den, man stod på.
|
||||
await rows.filter({ hasText: 'Alfa' }).click();
|
||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Vis arkivet').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
|
||||
|
||||
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
|
||||
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
|
||||
await rows.filter({ hasText: 'Alfa' }).hover();
|
||||
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||
|
||||
await page.getByLabel('Vis arkivet').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
|
||||
*
|
||||
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
|
||||
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
|
||||
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
|
||||
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
|
||||
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
|
||||
* bagefter. Klikket kan ikke engang lande.
|
||||
*
|
||||
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
|
||||
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
|
||||
* kunne låse de tolv hundrede andre ude.
|
||||
*/
|
||||
test('en note uden krop må ikke låse ruden', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await page.goto('/noter');
|
||||
for (const title of ['Alfa', 'Beta']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type(title);
|
||||
await expect(ed.locator('h1')).toHaveText(title);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Brødtekst i ' + title);
|
||||
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
|
||||
|
||||
await page.route('**/api/v1/notes/*', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const res = await route.fetch();
|
||||
const note = await res.json();
|
||||
delete note.body;
|
||||
await route.fulfill({ response: res, json: note });
|
||||
});
|
||||
|
||||
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
|
||||
|
||||
await page.unroute('**/api/v1/notes/*');
|
||||
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
|
||||
'ruden er låst: den næste note åbner ikke'
|
||||
).toHaveText('Beta', { timeout: 5000 });
|
||||
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Ugen er et døgn, ikke syv punktopstillinger.
|
||||
*
|
||||
* En uge, hvor hver dag er en liste, svarer på "hvad skal der ske torsdag" og ikke
|
||||
* på "hvornår" — og det andet er det, man åbner en uge for. To møder klokken ti er
|
||||
* en konflikt, man skal kunne se, ikke læse sig til.
|
||||
*
|
||||
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
|
||||
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
|
||||
* er en prøve, der aldrig kører.
|
||||
*/
|
||||
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Mandag i den uge, siden åbner på.
|
||||
const monday = (n) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
const at = (n, from, to, id, summary) => ({
|
||||
id,
|
||||
calendar_id: 'c1',
|
||||
summary,
|
||||
starts_at: `${monday(n)}T${from}:00+02:00`,
|
||||
ends_at: `${monday(n)}T${to}:00+02:00`,
|
||||
start_day: monday(n),
|
||||
end_day: monday(n),
|
||||
all_day: false,
|
||||
calendar_name: 'Privat'
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/calendar/events*', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
events: [
|
||||
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
|
||||
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
|
||||
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
|
||||
{
|
||||
id: 'e4',
|
||||
calendar_id: 'c1',
|
||||
summary: 'Aalborg',
|
||||
start_day: monday(1),
|
||||
end_day: monday(1),
|
||||
all_day: true,
|
||||
calendar_name: 'Privat'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/kalender');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await expect(page.locator('.weekgrid')).toBeVisible();
|
||||
|
||||
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
|
||||
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
|
||||
const widths = await page
|
||||
.locator('.weekgrid .daycol')
|
||||
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
|
||||
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
|
||||
|
||||
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
|
||||
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
|
||||
const morning = await box('Morgenmøde');
|
||||
const evening = await box('Sent på dagen');
|
||||
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
|
||||
|
||||
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
|
||||
// den konflikt, visningen findes for at vise.
|
||||
const overlapping = await box('Oveni det første');
|
||||
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
|
||||
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
|
||||
|
||||
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
|
||||
// ville påstå, den holdt fra midnat til midnat.
|
||||
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
|
||||
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
|
||||
|
||||
// Og måneden er stadig lige bred hele vejen rundt.
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
const monthWidths = await page
|
||||
.locator('.grid .day')
|
||||
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||
expect(
|
||||
Math.max(...monthWidths) - Math.min(...monthWidths),
|
||||
'månedens dage er ikke lige brede'
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tid på en opgave, sat i ruden og justeret i kalenderen.
|
||||
*
|
||||
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
|
||||
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
|
||||
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
|
||||
* det samme gjorde et træk i kalenderen.
|
||||
*/
|
||||
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
|
||||
// åbner på længere nede.
|
||||
await box.fill('ringe til tandlægen i dag');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
|
||||
|
||||
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
|
||||
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
const time = page.getByLabel('Klokkeslæt');
|
||||
await expect(time).toHaveValue('');
|
||||
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
|
||||
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
|
||||
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
|
||||
// browseren taber.
|
||||
await time.fill('08:30');
|
||||
await time.blur();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
|
||||
|
||||
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||
const date = page.locator('#due');
|
||||
const day = await date.inputValue();
|
||||
await date.fill(day);
|
||||
await date.blur();
|
||||
await page.waitForTimeout(400);
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(
|
||||
page.getByLabel('Klokkeslæt'),
|
||||
'et gem af datoen ryddede klokkeslættet'
|
||||
).toHaveValue('08:30');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
|
||||
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||
await expect(placed).toContainText('08:30');
|
||||
|
||||
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
|
||||
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
|
||||
// forveksles.
|
||||
//
|
||||
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
|
||||
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
|
||||
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
|
||||
// der prøves her.
|
||||
const band = page
|
||||
.locator('.weekgrid > .allday')
|
||||
.filter({ hasNot: page.locator('.chip, .event') })
|
||||
.first();
|
||||
const today = await band.getAttribute('data-allday');
|
||||
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
|
||||
// over fire hundrede pixels i et travlt gitter giver det browseren for få
|
||||
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
|
||||
// vejen, og det er også det, der skal prøves.
|
||||
const fromBox = await placed.boundingBox();
|
||||
const toBox = await band.boundingBox();
|
||||
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
|
||||
await page.mouse.up();
|
||||
await expect(
|
||||
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
|
||||
'opgaven kom ikke op i båndet'
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
|
||||
'opgaven står stadig på et klokkeslæt'
|
||||
).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
|
||||
@@ -283,6 +283,10 @@ export const api = {
|
||||
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
|
||||
updateSection: (id, data) => patch(`/sections/${id}`, data),
|
||||
deleteSection: (id) => del(`/sections/${id}`),
|
||||
// The whole list, the same as projects and groups: a project has a handful of
|
||||
// sections, and an order that cannot land half-applied is worth more than the
|
||||
// bytes a single move would save.
|
||||
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||
|
||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
||||
@@ -345,6 +349,7 @@ export const api = {
|
||||
|
||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||
noteBulk: (body) => post('/notes/bulk', body),
|
||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||
|
||||
@@ -361,6 +366,8 @@ export const api = {
|
||||
pushKey: () => get('/push/key'),
|
||||
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
||||
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
|
||||
getMailAddress: () => get('/mail-address'),
|
||||
@@ -376,6 +383,10 @@ export const api = {
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
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'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
|
||||
@@ -272,6 +272,15 @@
|
||||
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) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
@@ -279,6 +288,184 @@
|
||||
if (!id) return;
|
||||
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>
|
||||
|
||||
<div class="calendar" class:week={span === 'week'}>
|
||||
@@ -296,95 +483,195 @@
|
||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
||||
</header>
|
||||
|
||||
<div class="weekdays" aria-hidden="true">
|
||||
{#each WEEKDAYS as day}
|
||||
<span>{day}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<!--
|
||||
Én brik, tegnet ét sted.
|
||||
|
||||
<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>
|
||||
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
|
||||
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
|
||||
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
|
||||
det er kun placeringen, der står to steder.
|
||||
|
||||
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||
{@const link = linkOf(event)}
|
||||
{@const label = t('cal.eventCannotMove', {
|
||||
name: event.summary,
|
||||
calendar: event.calendar_name || t('cal.title')
|
||||
})}
|
||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
||||
browser dragging an anchor of its own accord, and the title says
|
||||
why to anybody who tries. The drop targets would refuse it anyway
|
||||
— they only accept the task MIME type — but a chip that lifts off
|
||||
the page and then will not land reads as a bug rather than as a
|
||||
rule. It is also not a button: there is nothing here to complete,
|
||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'span'}
|
||||
class="event"
|
||||
class:allday={event.all_day}
|
||||
draggable="false"
|
||||
href={link || undefined}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noreferrer noopener' : undefined}
|
||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
||||
>
|
||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
||||
{event.summary}
|
||||
</svelte:element>
|
||||
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
|
||||
klokkeslættet, når det ikke allerede står i brikken selv.
|
||||
-->
|
||||
{#snippet eventChip(event, date, kind, extra = '', at = '')}
|
||||
{@const link = linkOf(event)}
|
||||
{@const label = t('cal.eventCannotMove', {
|
||||
name: event.summary,
|
||||
calendar: event.calendar_name || t('cal.title')
|
||||
})}
|
||||
<!-- Not draggable, and it says so twice: the attribute stops the browser
|
||||
dragging an anchor of its own accord, and the title says why to anybody
|
||||
who tries. The drop targets would refuse it anyway — they only accept the
|
||||
task MIME type — but a chip that lifts off the page and then will not land
|
||||
reads as a bug rather than as a rule. It is also not a button: there is
|
||||
nothing here to complete, and a checkbox next to a meeting is an offer
|
||||
verdande cannot keep. -->
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'span'}
|
||||
class={kind}
|
||||
class:allday={event.all_day}
|
||||
draggable="false"
|
||||
href={link || undefined}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noreferrer noopener' : undefined}
|
||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||
style={[swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : '', extra]
|
||||
.filter(Boolean)
|
||||
.join(';')}
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{:else if timeOn(event, date)}<span class="at"
|
||||
>{timeOn(event, date)}</span
|
||||
>{/if}
|
||||
{event.summary}
|
||||
</svelte:element>
|
||||
{/snippet}
|
||||
|
||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
||||
<!-- Draggable as well as clickable: moving something to another day is the
|
||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||
of movement before it calls a press a drag, so the click survives. -->
|
||||
<button
|
||||
class={kind}
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
ondragend={() => (over = null)}
|
||||
onclick={() => onselect?.(task)}
|
||||
style={extra}
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{/if}
|
||||
{task.content}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if span === 'week'}
|
||||
<!-- Ugen som et døgn, ikke som syv punktopstillinger.
|
||||
Rullet vandret på en smal skærm frem for stablet til én søjle: der er
|
||||
ét sæt afleveringsmål her, og et andet sæt til telefonen ville være
|
||||
endnu et sted, den samme regel kunne være næsten rigtig. -->
|
||||
<div class="weekwrap">
|
||||
<div class="weekgrid" style="--hours: {hours.count}">
|
||||
<div class="corner"></div>
|
||||
{#each grid as date (date.toISOString())}
|
||||
<div class="head" class:today={iso(date) === todayISO}>
|
||||
<span class="wd">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
|
||||
<span class="num">{date.getDate()}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
drag, so the click survives. -->
|
||||
<button
|
||||
class="chip"
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
ondragend={() => (over = null)}
|
||||
onclick={() => onselect?.(task)}
|
||||
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
|
||||
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
|
||||
påstå, den holdt fra midnat til midnat. -->
|
||||
<div class="gutter">{t('cal.allDay')}</div>
|
||||
{#each grid as date (date.toISOString())}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="allday"
|
||||
data-allday={iso(date)}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDropAllDay(e, date)}
|
||||
>
|
||||
{task.content}
|
||||
</button>
|
||||
{#each untimedOn(date) as item (item.kind + item.ref.id)}
|
||||
{#if item.kind === 'event'}
|
||||
{@render eventChip(item.ref, date, 'event')}
|
||||
{:else}
|
||||
{@render taskChip(item.ref, 'chip')}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if hidden > 0}
|
||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||
{/if}
|
||||
<div class="gutter times">
|
||||
{#each Array(hours.count) as _, i}
|
||||
<span class="hour">{clock((hours.from + i) * 60)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#each grid as date (date.toISOString())}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- `data-date` er dagens navn udadtil. Månedens ruder har altid haft
|
||||
det, og ugens søjler skal have det af samme grund: det er sådan
|
||||
noget uden for komponenten peger på en dag — og det er det, en
|
||||
prøve slipper en opgave på. Heldagsbåndet får sit eget navn, så
|
||||
de to ikke bliver til det samme svar på ét spørgsmål. -->
|
||||
<div
|
||||
class="daycol"
|
||||
data-date={iso(date)}
|
||||
class:today={iso(date) === todayISO}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDropAt(e, date)}
|
||||
>
|
||||
{#each Array(hours.count) as _, i}
|
||||
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
|
||||
{/each}
|
||||
{#each packed(timedOn(date)) as item (item.kind + item.ref.id)}
|
||||
{#if item.kind === 'event'}
|
||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||
{:else}
|
||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
@@ -448,9 +735,19 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
@@ -465,7 +762,7 @@
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
@@ -473,6 +770,168 @@
|
||||
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 {
|
||||
background: var(--ground);
|
||||
min-height: 92px;
|
||||
|
||||
@@ -35,20 +35,53 @@
|
||||
let loadedId = $state(null);
|
||||
$effect(() => {
|
||||
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
|
||||
// note whose first line is body text is a note called by its first sentence.
|
||||
|
||||
// Sat *før* tegningen, ikke efter.
|
||||
//
|
||||
// Tested on the text and not on the rendered result: an empty body renders as
|
||||
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||
// Kaster noget herunder, er noten stadig den, der er forsøgt indlæst — og
|
||||
// uden det her ville effekten prøve igen ved hver eneste ændring bagefter og
|
||||
// kaste hver gang.
|
||||
loadedId = note.id;
|
||||
active = {};
|
||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||
// uden at nogen har bedt om det.
|
||||
asSource = false;
|
||||
colourCode();
|
||||
|
||||
// É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 = [
|
||||
@@ -121,12 +154,71 @@
|
||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||
* man læser den.
|
||||
*/
|
||||
/**
|
||||
* En kopiér-knap på hver kodeblok.
|
||||
*
|
||||
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
|
||||
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
|
||||
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
|
||||
* den findes for øjet og musen, ikke for dokumentet.
|
||||
*
|
||||
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
|
||||
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
|
||||
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
|
||||
*/
|
||||
function addCopyButtons() {
|
||||
if (!editor) return;
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre.querySelector('.copy')) continue;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy';
|
||||
btn.type = 'button';
|
||||
btn.contentEditable = 'false';
|
||||
btn.textContent = t('notes.copyCode');
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = codeTextOf(pre);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||
btn.textContent = t('notes.copied');
|
||||
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
|
||||
} catch {
|
||||
app.toast(t('notes.copyFailed'));
|
||||
}
|
||||
});
|
||||
pre.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||
*
|
||||
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
||||
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
||||
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
||||
*/
|
||||
function codeTextOf(pre) {
|
||||
const from = pre.querySelector('code') ?? pre;
|
||||
return [...from.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
const inside = currentBlock();
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre === inside || pre.contains(inside)) continue;
|
||||
const code = pre.textContent ?? '';
|
||||
// Blokkens tekst uden det, vi selv har lagt oven på den.
|
||||
//
|
||||
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
|
||||
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||
// spiste den kode, den var sat der for at kopiere.
|
||||
const code = codeTextOf(pre);
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
@@ -135,6 +227,7 @@
|
||||
const target = pre.querySelector('code') ?? pre;
|
||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||
}
|
||||
addCopyButtons();
|
||||
}
|
||||
|
||||
function changed() {
|
||||
@@ -814,8 +907,30 @@
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
/* Bredden vokser med ruden, men ikke i det uendelige.
|
||||
*
|
||||
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
|
||||
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
|
||||
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
|
||||
* bredde, blev klemt sammen uden grund.
|
||||
*
|
||||
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
||||
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
||||
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
||||
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
|
||||
* der afgør det.
|
||||
*
|
||||
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
|
||||
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
|
||||
* håndflade tom rude til højre for sig og svævede midt i arket. Med
|
||||
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
|
||||
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
|
||||
*
|
||||
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
|
||||
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
|
||||
* polstringen. Uden det ville det her give en vandret rullebjælke. */
|
||||
width: 100%;
|
||||
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@@ -915,6 +1030,40 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Kopiér-knappen, øverst til højre i blokken.
|
||||
*
|
||||
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
|
||||
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
|
||||
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
|
||||
* opmærksomhed fra koden.
|
||||
*
|
||||
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
|
||||
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
|
||||
.page :global(pre) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page :global(pre .copy) {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
font-family: var(--font);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.page :global(pre:hover .copy),
|
||||
.page :global(pre .copy:hover),
|
||||
.page :global(pre .copy:focus-visible) {
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
||||
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
|
||||
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||
|
||||
@@ -221,6 +221,13 @@
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* Luft under stregen.
|
||||
*
|
||||
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
|
||||
* sad den første sektion klods op ad den — så den læste som en kant om
|
||||
* sektionen frem for som en afslutning på feltet over. En streg skal have
|
||||
* plads på begge sider for at kunne ses som en streg. */
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.plus {
|
||||
|
||||
@@ -363,6 +363,15 @@
|
||||
overProject = project.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Et slip på et projekt, uanset hvad der bliver sluppet.
|
||||
*
|
||||
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
|
||||
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
|
||||
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
|
||||
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
|
||||
* virke, helt indtil man slap.
|
||||
*/
|
||||
async function onTaskDrop(event, project) {
|
||||
event.preventDefault();
|
||||
overProject = null;
|
||||
@@ -371,9 +380,12 @@
|
||||
// The same target takes both. A note dropped on a project is filed there,
|
||||
// which is also how it gets shared — one gesture, one meaning, and the
|
||||
// sidebar already reads as "put this here".
|
||||
const noteID = dragged(event, NOTE);
|
||||
if (noteID) {
|
||||
await app.moveNoteToProject(noteID, project.id);
|
||||
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
|
||||
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
|
||||
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
|
||||
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
|
||||
if (noteIDs.length) {
|
||||
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -569,14 +581,17 @@
|
||||
ondragstart={(e) => sortable && onDragStart(e, project)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => {
|
||||
if (carries(e, TASK)) onTaskDragOver(e, project);
|
||||
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
|
||||
else if (sortable) onDragOver(e, project);
|
||||
}}
|
||||
ondragleave={() => {
|
||||
overId = null;
|
||||
overProject = null;
|
||||
}}
|
||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||
ondrop={(e) =>
|
||||
carries(e, TASK) || carries(e, NOTE)
|
||||
? onTaskDrop(e, project)
|
||||
: sortable && onDrop(e, project)}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{projectName(project)}
|
||||
@@ -624,301 +639,313 @@
|
||||
</h2>
|
||||
{/snippet}
|
||||
|
||||
<div class="group">
|
||||
<!-- The heading is also the drop target for "no group": without it, a project
|
||||
dragged into a group could only be got out again by emptying the group,
|
||||
and with every project filed there would be no loose row left to aim at. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head"
|
||||
class:over={overGroup === ''}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, PROJECT)) return;
|
||||
accept(e);
|
||||
overGroup = '';
|
||||
overId = null;
|
||||
}}
|
||||
ondragleave={() => (overGroup = null)}
|
||||
ondrop={(e) => onDropInGroup(e, '')}
|
||||
>
|
||||
{@render foldHeading('projects', t('sidebar.projects'))}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => {
|
||||
adding = !adding;
|
||||
addingGroup = false;
|
||||
}}
|
||||
aria-label={t('sidebar.newProject')}>+</button
|
||||
>
|
||||
<button
|
||||
class="icon new-group"
|
||||
onclick={() => {
|
||||
addingGroup = !addingGroup;
|
||||
adding = false;
|
||||
newGroupName = '';
|
||||
}}
|
||||
aria-label={t('sidebar.newGroup')}
|
||||
title={t('sidebar.newGroup')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Alt herunder ruller; mærket og de faste visninger gør ikke.
|
||||
Hele sidebjælken rullede før, så runen og I dag forsvandt op over kanten,
|
||||
så snart der var projekter nok — og de er dem, man er på vej hen til
|
||||
oftest. En rulleboks indeni frem for `position: sticky` udenom, fordi et
|
||||
klæbende hoved efterlader rullebjælken i fuld højde ved siden af sig: den
|
||||
skal begynde under hovedet, ikke bag det.
|
||||
|
||||
{#if adding}
|
||||
<form onsubmit={createProject}>
|
||||
<input
|
||||
bind:value={newName}
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.projectName')}
|
||||
aria-label={t('sidebar.projectName')}
|
||||
onblur={() => !newName.trim() && (adding = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if addingGroup}
|
||||
<form onsubmit={createGroup}>
|
||||
<input
|
||||
bind:value={newGroupName}
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.groupName')}
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if !app.sectionCollapsed('projects')}
|
||||
{#each ungrouped as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
|
||||
{#if own.length === 0 && !adding}
|
||||
<p class="empty">{t('sidebar.noProjects')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each groups as group (group.id)}
|
||||
{@const inside = inGroup(group.id)}
|
||||
<div class="group folder">
|
||||
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
|
||||
the pointer from selecting text in the field it contains. -->
|
||||
Snippet-erklæringerne bliver liggende udenfor. De tegner ikke noget selv,
|
||||
men de kaldes oppe fra .views, og en snippet erklæret herinde hører kun
|
||||
til herinde. -->
|
||||
<div class="scroller">
|
||||
<div class="group">
|
||||
<!-- The heading is also the drop target for "no group": without it, a project
|
||||
dragged into a group could only be got out again by emptying the group,
|
||||
and with every project filed there would be no loose row left to aim at. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head folder-head"
|
||||
class:over={overGroup === group.id}
|
||||
class:drop-above={overId === group.id && !overBelow}
|
||||
class:drop-below={overId === group.id && overBelow}
|
||||
class:dragging={draggingGroup === group.id}
|
||||
draggable={renamingGroup !== group.id}
|
||||
ondragstart={(e) => onGroupDragStart(e, group)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => onGroupDragOver(e, group)}
|
||||
ondragleave={() => {
|
||||
overGroup = null;
|
||||
class="group-head"
|
||||
class:over={overGroup === ''}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, PROJECT)) return;
|
||||
accept(e);
|
||||
overGroup = '';
|
||||
overId = null;
|
||||
}}
|
||||
ondrop={(e) => onGroupDrop(e, group)}
|
||||
ondblclick={() => startRename(group)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(group);
|
||||
}
|
||||
}}
|
||||
ondragleave={() => (overGroup = null)}
|
||||
ondrop={(e) => onDropInGroup(e, '')}
|
||||
>
|
||||
{#if renamingGroup === group.id}
|
||||
<!-- Renaming is where the colour is chosen too.
|
||||
The dot used to be a button in the row, which made the heading
|
||||
carry four controls — dot, name, Omdøb, Slet — on a line narrow
|
||||
enough that they crowded the name. Both are edits to the same
|
||||
thing, so both belong in the same moment: you open the group to
|
||||
change it, and change what you came to change.
|
||||
|
||||
`focusout` rather than `blur` closes it, and only when focus has
|
||||
left the form altogether — clicking a swatch is still inside it.
|
||||
The swatches also refuse focus on mousedown, because Safari and
|
||||
Firefox do not focus a button on click, so `relatedTarget` would
|
||||
be null and the form would shut under the pointer. -->
|
||||
<form
|
||||
class="renaming"
|
||||
onsubmit={(e) => renameGroup(e, group)}
|
||||
onfocusout={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:value={groupName}
|
||||
use:focusOnMount
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
||||
/>
|
||||
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
|
||||
{#each COLORS as color (color.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||
style="background: {colorVar(color.id)}"
|
||||
title={t(color.name)}
|
||||
aria-label={t(color.name)}
|
||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => app.setGroupColor(group.id, color.id)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
project's title does: a group *is* a heading in the sidebar, and
|
||||
wrapping an h2 in a button both invalidates the markup and takes
|
||||
the heading away from a screen reader. The whole heading folds —
|
||||
at this size a lone chevron is a target you miss, and the name is
|
||||
where the eye already is. -->
|
||||
<!-- The chevron folds; the name opens the group's own page. Two
|
||||
targets in one heading, because they are two different intentions
|
||||
and the old single one could only ever serve the smaller. -->
|
||||
<button
|
||||
class="chevron-button"
|
||||
onclick={() => app.toggleGroup(group.id)}
|
||||
aria-expanded={!group.collapsed}
|
||||
aria-label={group.collapsed
|
||||
? t('sidebar.unfold', { name: group.name })
|
||||
: t('sidebar.fold', { name: group.name })}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<!-- Navigation waits two tenths of a second for a second click. A
|
||||
link cannot know a double is coming without pausing, and the
|
||||
alternative was worse than the pause: the first click opened the
|
||||
page while the rename form was appearing, and the colour swatches
|
||||
moved out from under the pointer. Two tenths is under the double
|
||||
click threshold and over the threshold of noticing. -->
|
||||
<a
|
||||
href="/gruppe/{group.id}"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(navTimer);
|
||||
if (e.detail > 1) return;
|
||||
navTimer = setTimeout(() => {
|
||||
onnavigate();
|
||||
goto(`/gruppe/${group.id}`);
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{group.name}
|
||||
</a>
|
||||
</h2>
|
||||
<span class="count">{inside.length}</span>
|
||||
{/if}
|
||||
{@render foldHeading('projects', t('sidebar.projects'))}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => {
|
||||
adding = !adding;
|
||||
addingGroup = false;
|
||||
}}
|
||||
aria-label={t('sidebar.newProject')}>+</button
|
||||
>
|
||||
<button
|
||||
class="icon new-group"
|
||||
onclick={() => {
|
||||
addingGroup = !addingGroup;
|
||||
adding = false;
|
||||
newGroupName = '';
|
||||
}}
|
||||
aria-label={t('sidebar.newGroup')}
|
||||
title={t('sidebar.newGroup')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !group.collapsed}
|
||||
{#each inside as project (project.id)}
|
||||
{#if adding}
|
||||
<form onsubmit={createProject}>
|
||||
<input
|
||||
bind:value={newName}
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.projectName')}
|
||||
aria-label={t('sidebar.projectName')}
|
||||
onblur={() => !newName.trim() && (adding = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if addingGroup}
|
||||
<form onsubmit={createGroup}>
|
||||
<input
|
||||
bind:value={newGroupName}
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.groupName')}
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if !app.sectionCollapsed('projects')}
|
||||
{#each ungrouped as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
{#if !inside.length}
|
||||
<p class="empty">{t('sidebar.emptyGroup')}</p>
|
||||
|
||||
{#if own.length === 0 && !adding}
|
||||
<p class="empty">{t('sidebar.noProjects')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if shared.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
|
||||
{#if !app.sectionCollapsed('shared')}
|
||||
{#each shared as project (project.id)}
|
||||
{@render projectRow(project, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each groups as group (group.id)}
|
||||
{@const inside = inGroup(group.id)}
|
||||
<div class="group folder">
|
||||
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
|
||||
the pointer from selecting text in the field it contains. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head folder-head"
|
||||
class:over={overGroup === group.id}
|
||||
class:drop-above={overId === group.id && !overBelow}
|
||||
class:drop-below={overId === group.id && overBelow}
|
||||
class:dragging={draggingGroup === group.id}
|
||||
draggable={renamingGroup !== group.id}
|
||||
ondragstart={(e) => onGroupDragStart(e, group)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => onGroupDragOver(e, group)}
|
||||
ondragleave={() => {
|
||||
overGroup = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondrop={(e) => onGroupDrop(e, group)}
|
||||
ondblclick={() => startRename(group)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(group);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if renamingGroup === group.id}
|
||||
<!-- Renaming is where the colour is chosen too.
|
||||
The dot used to be a button in the row, which made the heading
|
||||
carry four controls — dot, name, Omdøb, Slet — on a line narrow
|
||||
enough that they crowded the name. Both are edits to the same
|
||||
thing, so both belong in the same moment: you open the group to
|
||||
change it, and change what you came to change.
|
||||
|
||||
{#if filters.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
|
||||
{#if !app.sectionCollapsed('filters')}
|
||||
{#each filters as filter (filter.id)}
|
||||
<a
|
||||
href="/filter/{filter.id}"
|
||||
class:active={current === `/filter/${filter.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{filter.name}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
`focusout` rather than `blur` closes it, and only when focus has
|
||||
left the form altogether — clicking a swatch is still inside it.
|
||||
The swatches also refuse focus on mousedown, because Safari and
|
||||
Firefox do not focus a button on click, so `relatedTarget` would
|
||||
be null and the form would shut under the pointer. -->
|
||||
<form
|
||||
class="renaming"
|
||||
onsubmit={(e) => renameGroup(e, group)}
|
||||
onfocusout={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:value={groupName}
|
||||
use:focusOnMount
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
||||
/>
|
||||
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
|
||||
{#each COLORS as color (color.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||
style="background: {colorVar(color.id)}"
|
||||
title={t(color.name)}
|
||||
aria-label={t(color.name)}
|
||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => app.setGroupColor(group.id, color.id)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
project's title does: a group *is* a heading in the sidebar, and
|
||||
wrapping an h2 in a button both invalidates the markup and takes
|
||||
the heading away from a screen reader. The whole heading folds —
|
||||
at this size a lone chevron is a target you miss, and the name is
|
||||
where the eye already is. -->
|
||||
<!-- The chevron folds; the name opens the group's own page. Two
|
||||
targets in one heading, because they are two different intentions
|
||||
and the old single one could only ever serve the smaller. -->
|
||||
<button
|
||||
class="chevron-button"
|
||||
onclick={() => app.toggleGroup(group.id)}
|
||||
aria-expanded={!group.collapsed}
|
||||
aria-label={group.collapsed
|
||||
? t('sidebar.unfold', { name: group.name })
|
||||
: t('sidebar.fold', { name: group.name })}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<!-- Navigation waits two tenths of a second for a second click. A
|
||||
link cannot know a double is coming without pausing, and the
|
||||
alternative was worse than the pause: the first click opened the
|
||||
page while the rename form was appearing, and the colour swatches
|
||||
moved out from under the pointer. Two tenths is under the double
|
||||
click threshold and over the threshold of noticing. -->
|
||||
<a
|
||||
href="/gruppe/{group.id}"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(navTimer);
|
||||
if (e.detail > 1) return;
|
||||
navTimer = setTimeout(() => {
|
||||
onnavigate();
|
||||
goto(`/gruppe/${group.id}`);
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{group.name}
|
||||
</a>
|
||||
</h2>
|
||||
<span class="count">{inside.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if labels.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
|
||||
{#if !app.sectionCollapsed('labels')}
|
||||
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
||||
<a
|
||||
href="/etiket/{encodeURIComponent(label.name)}"
|
||||
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label.name}
|
||||
<span class="count">{label.task_count}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !group.collapsed}
|
||||
{#each inside as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
{#if !inside.length}
|
||||
<p class="empty">{t('sidebar.emptyGroup')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="foot">
|
||||
<a
|
||||
href="/indstillinger"
|
||||
class="settings"
|
||||
class:active={current.startsWith('/indstillinger')}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
{t('nav.settings')}
|
||||
</a>
|
||||
|
||||
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
||||
normal, and the app keeps working without it. -->
|
||||
{#if !app.connected}
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{#if shared.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
|
||||
{#if !app.sectionCollapsed('shared')}
|
||||
{#each shared as project (project.id)}
|
||||
{@render projectRow(project, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
</button>
|
||||
|
||||
{#if filters.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
|
||||
{#if !app.sectionCollapsed('filters')}
|
||||
{#each filters as filter (filter.id)}
|
||||
<a
|
||||
href="/filter/{filter.id}"
|
||||
class:active={current === `/filter/${filter.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{filter.name}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if labels.length}
|
||||
<div class="group">
|
||||
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
|
||||
{#if !app.sectionCollapsed('labels')}
|
||||
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
||||
<a
|
||||
href="/etiket/{encodeURIComponent(label.name)}"
|
||||
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label.name}
|
||||
<span class="count">{label.task_count}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<a
|
||||
href="/indstillinger"
|
||||
class="settings"
|
||||
class:active={current.startsWith('/indstillinger')}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
{t('nav.settings')}
|
||||
</a>
|
||||
|
||||
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
||||
normal, and the app keeps working without it. -->
|
||||
{#if !app.connected}
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
A drag handle with no keyboard equivalent is a setting some people simply
|
||||
@@ -955,11 +982,31 @@
|
||||
padding: var(--s4) var(--s3);
|
||||
background: var(--surface-sunken);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||
overflow: hidden;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* `min-height: 0`, ellers nægter et flex-element at blive lavere end sit
|
||||
indhold, og boksen ville vokse ned gennem bunden af skærmen i stedet for at
|
||||
rulle — samme fælde som `min-width: 0` på kontonavnet længere nede.
|
||||
|
||||
Ikke `overflow-x: hidden` her: `overflow-y: auto` gør den anden akse til
|
||||
`auto` af sig selv, og en vandret rullebjælke under menuen er en fejl i en
|
||||
række, ikke i boksen. At klippe den ville skjule fejlen; prøven måler begge
|
||||
bokse. */
|
||||
.scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||
the border; this only widens what the pointer hits.
|
||||
|
||||
@@ -1198,6 +1245,11 @@
|
||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||
stays put while the group is folded, so a folded group still shows the way
|
||||
back open. */
|
||||
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
||||
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
||||
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
||||
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
||||
skifte, uden noget at male henover den med. */
|
||||
.chevron-button {
|
||||
position: absolute;
|
||||
left: calc(var(--s2) - 5px);
|
||||
@@ -1208,7 +1260,13 @@
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
background: var(--surface);
|
||||
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
||||
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
||||
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
||||
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
||||
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
||||
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.folder-head:hover .chevron-button,
|
||||
@@ -1217,9 +1275,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
||||
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
||||
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
||||
pilen kom frem. */
|
||||
.folder-head:hover .group-dot,
|
||||
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
||||
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chevron-button:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fold a,
|
||||
|
||||
@@ -59,7 +59,17 @@
|
||||
} catch (e) {
|
||||
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
||||
// 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);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* necessarily the last one sent.
|
||||
*/
|
||||
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 { t } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
@@ -22,6 +22,7 @@
|
||||
let description = $state('');
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let dueTime = $state('');
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
@@ -55,6 +56,7 @@
|
||||
description = task.description ?? '';
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
@@ -407,14 +409,34 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="due">{t('detail.due')}</label>
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
bind:value={dueDate}
|
||||
onchange={() => save({ due_date: dueDate })}
|
||||
/>
|
||||
<div class="due">
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
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>
|
||||
|
||||
@@ -692,6 +714,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const TASK = 'application/x-verdande-task';
|
||||
export const PROJECT = 'application/x-verdande-project';
|
||||
export const GROUP = 'application/x-verdande-group';
|
||||
export const NOTE = 'application/x-verdande-note';
|
||||
export const SECTION = 'application/x-verdande-section';
|
||||
|
||||
/** Starts a drag carrying one id of the given kind. */
|
||||
export function startDrag(event, type, id) {
|
||||
|
||||
@@ -46,6 +46,19 @@ export const da = {
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.newHere': 'Ny note',
|
||||
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
||||
'notes.clearSearch': 'Ryd søgningen',
|
||||
'notes.found': '{count} noter',
|
||||
'notes.picked': '{count} markeret',
|
||||
'notes.archive': 'Arkivér',
|
||||
'notes.unarchive': 'Tag frem igen',
|
||||
'notes.clearPick': 'Ryd markering',
|
||||
'notes.showArchive': 'Vis arkivet',
|
||||
'notes.deleteMany': 'Slet {count} noter?',
|
||||
'notes.archiveOne': 'Arkivér noten',
|
||||
'notes.emptyArchive': 'Arkivet er tomt.',
|
||||
'notes.copyCode': 'Kopiér',
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
@@ -62,6 +75,11 @@ export const da = {
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'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.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
@@ -229,6 +247,13 @@ export const da = {
|
||||
'push.turnOff': 'Slå fra',
|
||||
'push.turnOn': 'Slå til på denne enhed',
|
||||
'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.none': 'Ingenting endnu.',
|
||||
'push.open': 'Åbn',
|
||||
@@ -538,6 +563,7 @@ export const da = {
|
||||
'detail.p1': 'P1 — haster',
|
||||
'detail.none': 'Ingen',
|
||||
'detail.due': 'Forfalder',
|
||||
'detail.dueTime': 'Klokkeslæt',
|
||||
'detail.labels': 'Etiketter',
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
@@ -645,6 +671,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.',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'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.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
@@ -801,6 +834,8 @@ export const da = {
|
||||
'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.',
|
||||
'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.name': 'Navn på nøglen',
|
||||
'passkey.namePlaceholder': 'fx min bærbare',
|
||||
|
||||
@@ -42,6 +42,19 @@ export const en = {
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.newHere': 'New note',
|
||||
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
|
||||
'notes.clearSearch': 'Clear the search',
|
||||
'notes.found': '{count} notes',
|
||||
'notes.picked': '{count} selected',
|
||||
'notes.archive': 'Archive',
|
||||
'notes.unarchive': 'Bring back',
|
||||
'notes.clearPick': 'Clear selection',
|
||||
'notes.showArchive': 'Show the archive',
|
||||
'notes.deleteMany': 'Delete {count} notes?',
|
||||
'notes.archiveOne': 'Archive this note',
|
||||
'notes.emptyArchive': 'The archive is empty.',
|
||||
'notes.copyCode': 'Copy',
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
@@ -58,6 +71,11 @@ export const en = {
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.yesterday': 'yesterday',
|
||||
'notes.groupFavourites': 'Favourites',
|
||||
'notes.groupToday': 'Today',
|
||||
'notes.groupYesterday': 'Yesterday',
|
||||
'notes.groupWeek': 'This week',
|
||||
'notes.groupUnknown': 'No date',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
@@ -225,6 +243,13 @@ export const en = {
|
||||
'push.turnOff': 'Turn off',
|
||||
'push.turnOn': 'Turn on for this device',
|
||||
'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.none': 'Nothing yet.',
|
||||
'push.open': 'Open',
|
||||
@@ -534,6 +559,7 @@ export const en = {
|
||||
'detail.p1': 'P1 — urgent',
|
||||
'detail.none': 'None',
|
||||
'detail.due': 'Due',
|
||||
'detail.dueTime': 'Time',
|
||||
'detail.labels': 'Labels',
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
@@ -641,6 +667,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.',
|
||||
'int.whichCalendars': 'Which calendars are shown',
|
||||
'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.calendarNeverSynced': 'Not fetched yet.',
|
||||
'int.calendarFetchedOne': '{n} event fetched.',
|
||||
@@ -794,6 +827,8 @@ export const en = {
|
||||
'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.',
|
||||
'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.name': 'Name for the key',
|
||||
'passkey.namePlaceholder': 'e.g. my laptop',
|
||||
|
||||
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
emit('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
||||
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
||||
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
||||
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
||||
const code = [...child.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
emit('```');
|
||||
|
||||
@@ -372,10 +372,35 @@ class AppState {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!previous || previous.due_date === date) return;
|
||||
await this.update(id, { due_date: date });
|
||||
if (!previous) return;
|
||||
|
||||
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) {
|
||||
@@ -729,6 +754,20 @@ class AppState {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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']);
|
||||
|
||||
export function projectName(project) {
|
||||
|
||||
@@ -437,7 +437,6 @@
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-right: var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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) {
|
||||
event.preventDefault();
|
||||
savingCalendars = true;
|
||||
@@ -548,6 +583,48 @@
|
||||
</div>
|
||||
</form>
|
||||
{/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 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() {
|
||||
busy = true;
|
||||
try {
|
||||
@@ -165,7 +202,18 @@
|
||||
<p class="hint">{t('push.on')}</p>
|
||||
<div class="row">
|
||||
<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>
|
||||
{#if testResult}
|
||||
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={enablePush} disabled={busy}>
|
||||
@@ -261,6 +309,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
|
||||
let notes = $state([]);
|
||||
@@ -53,7 +54,16 @@
|
||||
|
||||
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
||||
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
||||
let ordered = $derived(
|
||||
let ordered = $derived.by(() => {
|
||||
// En søgning har allerede en rækkefølge, og den er bedre end nogen af de
|
||||
// tre herunder: serveren har sorteret efter hvor godt hver note matchede.
|
||||
// Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en
|
||||
// søgning i tolv hundrede noter brugbar.
|
||||
if (query.trim()) return notes;
|
||||
return sortedByChoice();
|
||||
});
|
||||
|
||||
const sortedByChoice = () =>
|
||||
[...notes].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
if (order === 'title') {
|
||||
@@ -61,10 +71,200 @@
|
||||
}
|
||||
const field = order === 'created' ? 'created_at' : 'updated_at';
|
||||
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);
|
||||
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||
let selectedId = $state(null);
|
||||
|
||||
/**
|
||||
* Flere ad gangen.
|
||||
*
|
||||
* Et sæt frem for et flag på noten, fordi markeringen hører til skærmen og ikke
|
||||
* til noten: den forsvinder, når man går et andet sted hen, og den skal ikke
|
||||
* gemmes nogen steder.
|
||||
*
|
||||
* `anchor` er den, et skift-klik måler fra. Uden den ville et interval skulle
|
||||
* gættes ud fra "den valgte", og den flytter sig, hver gang man klikker.
|
||||
*/
|
||||
let picked = $state(new Set());
|
||||
let anchor = $state(null);
|
||||
let showArchive = $state(false);
|
||||
|
||||
function rowClick(event, note) {
|
||||
const many = event.metaKey || event.ctrlKey;
|
||||
const range = event.shiftKey;
|
||||
|
||||
if (!many && !range) {
|
||||
picked = new Set();
|
||||
anchor = note.id;
|
||||
open(note);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(picked);
|
||||
if (range && anchor) {
|
||||
// Fra ankeret til her, i den rækkefølge listen faktisk står i — ikke i
|
||||
// notens egen, som er en anden, når der er sorteret eller søgt.
|
||||
const ids = ordered.map((n) => n.id);
|
||||
const from = ids.indexOf(anchor);
|
||||
const to = ids.indexOf(note.id);
|
||||
if (from !== -1 && to !== -1) {
|
||||
for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
|
||||
}
|
||||
} else {
|
||||
// Den, der lige er åbnet, hører med i markeringen: ellers ville et
|
||||
// ⌘-klik nummer to markere to noter og glemme den, man stod på.
|
||||
if (!next.size && selectedId) next.add(selectedId);
|
||||
next.has(note.id) ? next.delete(note.id) : next.add(note.id);
|
||||
anchor = note.id;
|
||||
}
|
||||
picked = next;
|
||||
}
|
||||
|
||||
/** Arkivér, tag frem igen, eller slet — for det, der er markeret. */
|
||||
async function bulk(change) {
|
||||
const ids = [...picked];
|
||||
if (!ids.length) return;
|
||||
if (change.delete && !confirm(t('notes.deleteMany', { count: ids.length }))) return;
|
||||
try {
|
||||
await api.noteBulk({ ids, ...change });
|
||||
picked = new Set();
|
||||
if (ids.includes(selectedId)) open(null);
|
||||
await load(query);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Én note lagt væk, uden at markere den først. */
|
||||
async function archiveOne(note) {
|
||||
try {
|
||||
await api.noteBulk({ ids: [note.id], archived: !note.archived_at });
|
||||
if (selectedId === note.id) open(null);
|
||||
await load(query);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
let query = $state('');
|
||||
|
||||
/**
|
||||
* Det, der står i feltet, og det, der er søgt efter.
|
||||
*
|
||||
* To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert
|
||||
* tastetryk,
|
||||
* så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi
|
||||
* det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger
|
||||
* gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom
|
||||
* tilbage — og svarene kunne lande i den forkerte rækkefølge.
|
||||
*/
|
||||
let draftQuery = $state('');
|
||||
let typing = $state(false);
|
||||
let searchTimer;
|
||||
|
||||
function search(next) {
|
||||
draftQuery = next;
|
||||
typing = next.trim() !== '' && next !== query;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
query = draftQuery;
|
||||
typing = false;
|
||||
}, 250);
|
||||
}
|
||||
let status = $state('loading');
|
||||
let saving = $state(false);
|
||||
|
||||
@@ -74,6 +274,7 @@
|
||||
let timer;
|
||||
|
||||
$effect(() => {
|
||||
showArchive;
|
||||
load(query);
|
||||
});
|
||||
|
||||
@@ -81,7 +282,7 @@
|
||||
// opens it rather than dropping the person on a list to find it again.
|
||||
let asked = $derived($page.url.searchParams.get('note'));
|
||||
$effect(() => {
|
||||
if (!asked || selected?.id === asked) return;
|
||||
if (!asked || selectedId === asked) return;
|
||||
const found = notes.find((n) => n.id === asked);
|
||||
if (found) open(found);
|
||||
else api.note(asked).then(open).catch(() => {});
|
||||
@@ -89,19 +290,60 @@
|
||||
|
||||
async function load(q) {
|
||||
try {
|
||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
||||
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||
notes = (await api.notes(params)).notes ?? [];
|
||||
status = 'ready';
|
||||
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
||||
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||
} catch (e) {
|
||||
status = 'failed';
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function open(note) {
|
||||
/**
|
||||
* Åbner en note — og henter den hel først, hvis listen kun har uddraget.
|
||||
*
|
||||
* Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter
|
||||
* ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville
|
||||
* gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag
|
||||
* af ingenting andet end at være blevet kigget på.
|
||||
*
|
||||
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
||||
* at vente; teksten kommer, når den er der.
|
||||
*/
|
||||
async function open(note) {
|
||||
clearTimeout(timer);
|
||||
selected = note;
|
||||
draft = note?.body ?? '';
|
||||
if (!note) {
|
||||
selectedId = null;
|
||||
selected = null;
|
||||
draft = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Rækken fremhæves med det samme; editoren får først noten, når den er hel.
|
||||
//
|
||||
// De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id*
|
||||
// skifter — og hvis den fik uddraget først og hele noten bagefter, ville
|
||||
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
||||
// sidde med en tom note, der lige havde haft en titel.
|
||||
selectedId = note.id;
|
||||
if (note.body) {
|
||||
selected = note;
|
||||
draft = note.body;
|
||||
return;
|
||||
}
|
||||
selected = null;
|
||||
|
||||
try {
|
||||
const full = await api.note(note.id);
|
||||
// Kun hvis den stadig er den, der er valgt: skifter man note, mens
|
||||
// hentningen er undervejs, må den forrige ikke lande oven i den nye.
|
||||
if (selectedId !== note.id) return;
|
||||
selected = full;
|
||||
draft = full.body ?? '';
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +353,25 @@
|
||||
* which is exactly the thing the editor was built to stop showing. What is
|
||||
* 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) {
|
||||
return (body ?? '')
|
||||
// Et billede har ingen ord i sig. Uden det her stod der
|
||||
@@ -151,6 +412,14 @@
|
||||
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Hvilken dato rækken viser: den, der sorteres efter. */
|
||||
const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
|
||||
|
||||
/** Begge datoer, til den, der holder musen stille. */
|
||||
const stamp = (note) =>
|
||||
`${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` +
|
||||
`${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`;
|
||||
|
||||
function projectName(id) {
|
||||
return app.projects.find((p) => p.id === id)?.name ?? '';
|
||||
}
|
||||
@@ -161,7 +430,11 @@
|
||||
|
||||
function onDragStart(event, note) {
|
||||
dragging = note.id;
|
||||
startDrag(event, NOTE, note.id);
|
||||
// Trækker man en note, der er markeret, følger hele markeringen med. Trækker
|
||||
// man en, der ikke er, er det den ene — ellers ville et træk i en tilfældig
|
||||
// række flytte halvtreds noter, man havde glemt var markeret.
|
||||
const ids = picked.has(note.id) ? [...picked] : [note.id];
|
||||
startDrag(event, NOTE, ids.join(' '));
|
||||
}
|
||||
|
||||
async function create() {
|
||||
@@ -194,7 +467,10 @@
|
||||
saving = true;
|
||||
try {
|
||||
const saved = await api.updateNote(id, { body });
|
||||
notes = notes.map((n) => (n.id === id ? saved : n));
|
||||
// Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers
|
||||
// står linjen tom, indtil siden hentes igen.
|
||||
const forList = { ...saved, preview: saved.body ?? '' };
|
||||
notes = notes.map((n) => (n.id === id ? forList : n));
|
||||
// Only if it is still the one on screen: switching notes mid-save must not
|
||||
// drag the previous one's title back onto this one.
|
||||
if (selected?.id === id) selected = saved;
|
||||
@@ -295,61 +571,157 @@
|
||||
<option value={o.key}>{t(o.label)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="archive-toggle"
|
||||
class:on={showArchive}
|
||||
onclick={() => { showArchive = !showArchive; picked = new Set(); }}
|
||||
aria-pressed={showArchive}
|
||||
aria-label={t('notes.showArchive')}
|
||||
title={t('notes.showArchive')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="search"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('notes.search')}
|
||||
aria-label={t('notes.search')}
|
||||
/>
|
||||
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
|
||||
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
|
||||
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
|
||||
programmet, der ikke fulgte temaet, og det kunne ses. -->
|
||||
<div class="searchbox" class:busy={typing}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M20 20l-3.5-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={draftQuery}
|
||||
oninput={(e) => search(e.currentTarget.value)}
|
||||
placeholder={t('notes.search')}
|
||||
aria-label={t('notes.search')}
|
||||
/>
|
||||
{#if draftQuery}
|
||||
<button class="clear" onclick={() => search('')} aria-label={t('notes.clearSearch')}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if picked.size}
|
||||
<!-- Kun mens der er markeret noget. En bjælke, der altid står der, er en
|
||||
bjælke, der tager plads fra listen hver dag for at gøre gavn sjældent. -->
|
||||
<div class="picked-bar">
|
||||
<span>{t('notes.picked', { count: picked.size })}</span>
|
||||
<button onclick={() => bulk({ archived: !showArchive })}>
|
||||
{showArchive ? t('notes.unarchive') : t('notes.archive')}
|
||||
</button>
|
||||
<button class="danger" onclick={() => bulk({ delete: true })}>{t('notes.delete')}</button>
|
||||
<button onclick={() => (picked = new Set())}>{t('notes.clearPick')}</button>
|
||||
</div>
|
||||
{:else if query && status === 'ready'}
|
||||
<p class="found">{t('notes.found', { count: notes.length })}</p>
|
||||
{/if}
|
||||
|
||||
{#if status === 'loading'}
|
||||
<p class="hint">{t('history.loading')}</p>
|
||||
{:else if !notes.length}
|
||||
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
||||
<p class="hint">
|
||||
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each ordered as note (note.id)}
|
||||
<li>
|
||||
<div class="rowline">
|
||||
<button
|
||||
class="row"
|
||||
class:on={selected?.id === note.id}
|
||||
onclick={() => open(note)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
|
||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
||||
<span class="under">
|
||||
<span class="when">{when(note.updated_at)}</span>
|
||||
<span class="preview">{plain(note.body)}</span>
|
||||
</span>
|
||||
{#if note.project_id}
|
||||
<span class="filed">{projectName(note.project_id)}</span>
|
||||
{/if}
|
||||
</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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#each groups as group (group.key)}
|
||||
<!-- Overskriften klæber, mens man ruller. 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. -->
|
||||
{#if !group.plain}
|
||||
<h3 class="group">
|
||||
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
|
||||
navn, og knappen *er* overskriften — så overskriften ville hedde
|
||||
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
|
||||
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
|
||||
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
||||
{#if group.star}
|
||||
<span class="mark favmark" aria-hidden="true">★</span>
|
||||
{:else}
|
||||
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
||||
{/if}
|
||||
{group.label}
|
||||
<span class="count">{group.notes.length}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{/if}
|
||||
{#if !folded.has(group.key)}
|
||||
<ul>
|
||||
{#each group.notes as note (note.id)}
|
||||
<li>
|
||||
<div class="rowline">
|
||||
<button
|
||||
class="row"
|
||||
class:on={selectedId === note.id}
|
||||
class:picked={picked.has(note.id)}
|
||||
onclick={(e) => rowClick(e, note)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
||||
den det eneste tilbage, der gør. -->
|
||||
<span class="head">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
{#if order === 'title'}
|
||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||
{/if}
|
||||
{#if note.project_id}
|
||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
||||
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. Nu kan Hjem kendes fra
|
||||
Claude uden at læse ordet. -->
|
||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{projectName(note.project_id)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="preview">{preview(note)}</span>
|
||||
</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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -474,21 +846,164 @@
|
||||
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
||||
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
||||
udgave, og den ser ens ud hele tiden. */
|
||||
.search {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-left: 30px;
|
||||
border-radius: 999px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 9px center;
|
||||
background-size: 15px 15px;
|
||||
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
|
||||
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
|
||||
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
|
||||
farve skrevet ind: den gamle var #8b918d uanset tema. */
|
||||
.searchbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s2) var(--s3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
|
||||
.search::-webkit-search-decoration,
|
||||
.search::-webkit-search-cancel-button {
|
||||
.searchbox:focus-within {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* Mens det tastede endnu ikke er søgt efter. Et hint om at der kommer noget,
|
||||
frem for en liste, der ser forkert ud i en kvart sekund. */
|
||||
.searchbox.busy {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.searchbox svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.searchbox input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clear {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--ink-faint);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.clear:hover {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Bjælken over listen, mens der er markeret noget. Den erstatter antallet af
|
||||
fundne frem for at lægge sig oven i det: kun ét af de to er relevant ad
|
||||
gangen. */
|
||||
.picked-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--s2) var(--s1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.picked-bar button {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink);
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.picked-bar button:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.picked-bar button.danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger-sunken);
|
||||
}
|
||||
|
||||
/* En markeret række.
|
||||
*
|
||||
* En kant og en tone, ikke en tone alene: `--accent-sunken` er meget lys i lyst
|
||||
* tema, og tre markerede rækker var næsten ikke til at skelne fra tre, der ikke
|
||||
* var — hvilket er en dårlig ting at være i tvivl om, lige inden man trykker
|
||||
* Slet. Kanten kan ses uanset tema og uanset hvor lys tonen er. */
|
||||
.row.picked {
|
||||
background: var(--accent-sunken);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Vises som stjernen: usynlig, indtil rækken er under musen, så listen er
|
||||
rolig at skanne. */
|
||||
.archive-one {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--ink-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.archive-one svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.rowline:hover .archive-one,
|
||||
.archive-one:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.archive-one:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.archive-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--ink-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.archive-toggle svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.archive-toggle:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.archive-toggle.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-sunken);
|
||||
}
|
||||
|
||||
.found {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
}
|
||||
|
||||
/* Safaris egen ryd-knap ville sidde oven i vores egen. */
|
||||
.searchbox input::-webkit-search-decoration,
|
||||
.searchbox input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@@ -500,15 +1015,45 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Rækker uden for skærmen koster ingenting.
|
||||
*
|
||||
* Listen tegner alle noterne. Med ti var det ligegyldigt; med tolv hundrede tog
|
||||
* første tegning to sekunder, og browseren gik i knæ — for hver eneste række
|
||||
* blev der lavet layout og malet, også de tusind, ingen kan se.
|
||||
*
|
||||
* `content-visibility: auto` lader browseren springe netop det over for det,
|
||||
* der er uden for billedet, og tage det, når man ruller derhen. Ingen JavaScript,
|
||||
* ingen rullematematik, ingen liste, der kan komme ud af trit med sig selv —
|
||||
* hvilket er hele grunden til at prøve det her før en vinduesvisning.
|
||||
*
|
||||
* `contain-intrinsic-size` er gættet på, hvor høj en uset række er. Uden det
|
||||
* ville rullebjælken tro, at listen er tom, og hoppe, mens man ruller. Tallet er
|
||||
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||
li {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 54px;
|
||||
}
|
||||
|
||||
.rowline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
||||
hinanden er toogfyrre pixels taget fra en liste på tre hundrede — og de er
|
||||
det, man rører en gang imellem, mens titlen er det, man læser hele tiden. */
|
||||
.rowactions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.star {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
font-size: var(--text-sm);
|
||||
@@ -524,19 +1069,99 @@
|
||||
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 {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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);
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
li:last-child .row {
|
||||
border-bottom: 0;
|
||||
/* Titel, dato og projekt på én linje. Projektet ude til højre, hvor øjet kan
|
||||
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 {
|
||||
@@ -563,33 +1188,56 @@
|
||||
|
||||
/* 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. */
|
||||
.under {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.when {
|
||||
flex: none;
|
||||
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 {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
|
||||
og den eneste linje her, der ikke er notens eget indhold. */
|
||||
/* Projektets egen farve, den samme som prikken i sidebjælken.
|
||||
*
|
||||
* 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 {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
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);
|
||||
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 {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
@@ -386,6 +386,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- moving a section ---------------------------------------------------------
|
||||
//
|
||||
// The order of the sections is the order of the plan — "Planlægning" stands
|
||||
// before "I gang" because that is when the work happens — and until this it
|
||||
// could only be changed by deleting a section and making it again, which drops
|
||||
// its tasks into the unsectioned area on the way past.
|
||||
//
|
||||
// The whole heading is the handle, not a grip beside it. A section heading
|
||||
// holds nothing that is itself dragged, so there is no gesture to steal — which
|
||||
// is the reason the sidebar's rows needed one, and the reason these do not.
|
||||
|
||||
let draggingSection = $state(null);
|
||||
/** The heading whose gap is lit up, and which side of it. */
|
||||
let overSectionEdge = $state(null);
|
||||
let overSectionBelow = $state(false);
|
||||
|
||||
function clearSectionDrag() {
|
||||
draggingSection = null;
|
||||
overSectionEdge = null;
|
||||
}
|
||||
|
||||
function onSectionDragStart(event, section) {
|
||||
draggingSection = section.id;
|
||||
startDrag(event, SECTION, section.id);
|
||||
}
|
||||
|
||||
function onHeadDragOver(event, section) {
|
||||
if (!canEdit || !carries(event, SECTION) || draggingSection === section.id) return;
|
||||
accept(event);
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overSectionEdge = section.id;
|
||||
overSectionBelow = event.clientY > box.top + box.height / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic, and the whole list at once.
|
||||
*
|
||||
* The array is what the page renders from, so moving the entry moves the
|
||||
* section; `sort_order` is set alongside it only so a later read of this list
|
||||
* agrees with what is on screen. If the write fails the previous array goes
|
||||
* back — a half-moved section is worse than one that did not move.
|
||||
*/
|
||||
async function onHeadDrop(event, target) {
|
||||
if (!carries(event, SECTION)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const id = dragged(event, SECTION) || draggingSection;
|
||||
const below = overSectionBelow;
|
||||
clearSectionDrag();
|
||||
if (!id || id === target.id || !canEdit) return;
|
||||
|
||||
const moved = sections.find((s) => s.id === id);
|
||||
if (!moved) return;
|
||||
|
||||
const without = sections.filter((s) => s.id !== id);
|
||||
const at = without.findIndex((s) => s.id === target.id);
|
||||
if (at < 0) return;
|
||||
|
||||
const ordered = [...without];
|
||||
ordered.splice(below ? at + 1 : at, 0, moved);
|
||||
|
||||
const previous = sections;
|
||||
sections = ordered.map((s, i) => ({ ...s, sort_order: i }));
|
||||
try {
|
||||
await api.reorderSections(
|
||||
project.id,
|
||||
ordered.map((s) => s.id)
|
||||
);
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSection(section) {
|
||||
const count = open.filter((t) => t.section_id === section.id).length;
|
||||
const warning = count
|
||||
@@ -739,7 +813,22 @@
|
||||
ondragleave={() => (overSection = null)}
|
||||
ondrop={(e) => onSectionDrop(e, section.id)}
|
||||
>
|
||||
<div class="section-head">
|
||||
<!-- Draggable, except while it is being renamed: a draggable element
|
||||
will not let the pointer select text in the field it contains.
|
||||
The sidebar's groups learned this first. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="section-head"
|
||||
class:dragging={draggingSection === section.id}
|
||||
class:drop-above={overSectionEdge === section.id && !overSectionBelow}
|
||||
class:drop-below={overSectionEdge === section.id && overSectionBelow}
|
||||
draggable={canEdit && renamingSection !== section.id}
|
||||
ondragstart={(e) => onSectionDragStart(e, section)}
|
||||
ondragend={clearSectionDrag}
|
||||
ondragover={(e) => onHeadDragOver(e, section)}
|
||||
ondragleave={() => (overSectionEdge = null)}
|
||||
ondrop={(e) => onHeadDrop(e, section)}
|
||||
>
|
||||
{#if renamingSection === section.id}
|
||||
<form onsubmit={(e) => renameSection(e, section)}>
|
||||
<input
|
||||
@@ -868,7 +957,9 @@
|
||||
<li>
|
||||
<a href="/noter?note={note.id}">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<span>{note.body.slice(0, 90)}</span>
|
||||
<!-- `preview` når listen bærer det, `body` når noten kom
|
||||
fra et sted, der sender den hel. -->
|
||||
<span>{(note.preview ?? note.body ?? '').slice(0, 90)}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -1439,12 +1530,61 @@
|
||||
gap: var(--s3);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s2) var(--s3);
|
||||
margin-bottom: var(--s1);
|
||||
/* Pulled out to the padding of the rows below, so the band lines up with the
|
||||
text it heads rather than with the rows' own inset. */
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
margin-right: calc(var(--s2) * -1);
|
||||
/* The same inset as the rows, and the same width.
|
||||
*
|
||||
* It used to be pulled out by `--s2` and padded by `--s3`, which is the same
|
||||
* idea done with two different numbers: the band ended up eight pixels wider
|
||||
* than the list on each side, and its text four pixels *outside* the column
|
||||
* the rows keep. On the left that was invisible. On the right it was Omdøb
|
||||
* and Slet standing four pixels past everything above and below them, which
|
||||
* is exactly far enough to look like a mistake and not far enough to see why.
|
||||
*
|
||||
* A row is `.row` with `padding: var(--s2)` inside a box with none, so the
|
||||
* column the eye follows starts one `--s2` in. The band matches that by
|
||||
* having the same padding and no margin at all — no arithmetic, and nothing
|
||||
* to keep in step if the rows are ever re-inset.
|
||||
*/
|
||||
padding: var(--s2);
|
||||
/* For the drop line, which is drawn inside the band's own box. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Grabbable, and only where it can actually be grabbed: the rename field is
|
||||
not draggable, and a heading somebody may only read is not either. */
|
||||
.section-head[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.section-head.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a box around the heading. The gap is where it
|
||||
will land, and a lit heading says "into this one" — which is what a task
|
||||
dragged onto a section means, and this is not that. */
|
||||
.section-head::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.section-head.drop-above::after {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-head.drop-below::after {
|
||||
bottom: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
|
||||
Reference in New Issue
Block a user