Compare commits

..
5 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 dcb71f4c14 feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` 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.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** 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.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:26:13 +02:00
Kristian WindandClaude Opus 5 968a29ca67 feat(notes): listen står i grupper, og projektet har sin egen farve
Release / Build and publish image (push) Failing after 9m56s
Meldt ind som "den er for tæt på en eller anden måde". 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 er flyttet derhen, hvor de siger noget.

**Datoen er blevet til overskriften.** I dag · I går · Denne uge · August 2026 …
og så er den væk fra rækken. 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.

**Favoritterne har fået deres egen gruppe.** De lå allerede først; der stod bare
ikke hvorfor.

**Grupperingen følger sorteringen.** Sorteret på "Oprettet" grupperes der på
oprettelsesdatoen, på "Senest rørt" på den — ellers ville listen stå i én
rækkefølge og være grupperet efter en anden, og så er overskrifterne løgn.
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 det eneste tilbage, der siger noget.

**Arkivet grupperes med.** Det er dér, de tolv hundrede importerede ligger, og
dér, det betyder mest.

**Projektet har sin egen farve** — den samme prik som 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. Prikken bærer farven
og ikke teksten: fem projektfarver som skriftfarve er fem forskellige grader af
læsbarhed, og den lyseste er ikke læsbar.

Fire ting, der blev fundet ved at se på det:

- Uddraget gentog titlen, fordi titlen *er* første linje. Det var skjult, så længe
  en dato stod imellem dem.
- Uddraget klippes ikke af sig selv. Det lå før i en flex-boks, der ejede
  afkortningen; da datoen flyttede op, forsvandt boksen, og sætningen løb videre
  ind under favoritstjernen.
- Overskriftens stjerne hed `.star` ligesom rækkens favoritknap og arvede dens
  gennemsigtighed. Den stod der aldrig. To ting, der ligner hinanden, er ikke det
  samme.
- En `aria-label` på foldeknappen erstatter knappens indhold som dens navn — og
  knappen *er* overskriften, så overskriften kom til at hedde "Fold I dag sammen".
  Sidebjælkens gruppehoveder gør det rigtigt, og gør det nu begge steder.

Sammenfoldning huskes ikke på tværs af besøg: en gruppe, der åbner foldet uden at
nogen bad om det, er en liste, der ser tom ud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:01:40 +02:00
Kristian WindandClaude Opus 5 fcd6dca852 fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart
der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist
push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på
standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig
sker, så ens ud, og forskellen er hele diagnosen.

Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig,
tjenesten afviste den, eller der var ikke noget forfaldent endnu.

**En prøveknap** under Indstillinger → Notifikationer sender én notifikation
gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der
faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem,
og afviste ingen den, står der, at det så er browseren eller styresystemet, der
holder den tilbage. Nu peger svaret på ét af de tre steder.

Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt
hvorfor.

Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen
kopi af "det her er en prøve", bærer den på ét sprog.

**Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og
dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` —
hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed.
Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem.
Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den
blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste
fejltype, der bærer den med, kommer ikke med en advarsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:41:33 +02:00
Kristian WindandClaude Opus 5 ae673086bc feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Release / Build and publish image (push) Failing after 9m58s
Bedt om: "jeg vil have tid på opgaver, og jeg vil kunne justere dem i kalenderen."

Serveren kunne det hele i forvejen — `due_date` og `due_time` læses sammen og
bliver til et `due_datetime` i kontoens tidszone. Det var fladen, der ikke havde
nogen vej til at sige det.

**Et felt i opgaveruden.** Tomt, indtil nogen sætter det: en opgave uden
klokkeslæt er det almindelige, og et felt, der åbner på 00:00, påstår, at noget
skal ske ved midnat.

**Og en fejl, der lå og ventede.** Serveren læser en dato uden en tid som "ryd
tiden". Så et gem af datoen alene tog klokkeslættet med sig — man rettede dagen og
mistede timen — og det samme gjorde hvert eneste træk i kalenderen. Ingen af
delene sagde noget.

`reschedule` siger nu tiden højt, tre måder, fordi de to første ligner hinanden i
JavaScript og derfor skal skilles ad ét sted frem for at blive forvekslet fire:

    undefined   behold den, opgaven har   — månedens ruder, hvor der ingen tid er at læse
    ''          ryd den                   — heldagsbåndet
    'HH:MM'     sæt den                   — et sted på døgnet

**Hvor på søjlen man slipper, er klokkeslættet.** Målt mod søjlens egen kasse frem
for mod siden, så det bliver rigtigt uanset hvor gitteret sidder og 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.

Prøven går hele vejen: sæt tiden, genindlæs og se at serveren tog den, ret datoen
og se at timen bliver, find opgaven på sit klokkeslæt i ugegitteret, og træk den op
i båndet igen. To ting, den lærte undervejs, står skrevet ved den: et træk over
fire hundrede pixels skal gå i skridt, ellers har browseren for få mellemstationer
til at holde gesten i live — og klokken skal være tidlig, for klokken fjorten
ligger under skærmkanten, og at måle noget dernede ruller siden væk under målet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:29:03 +02:00
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:02:59 +02:00
22 changed files with 2386 additions and 114 deletions
+437
View File
@@ -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 `![alt](url)`.
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. `![" onerror="alert(1)](…)` 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.
+89
View File
@@ -2079,6 +2079,95 @@ paths:
"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 } }
+6 -1
View File
@@ -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,
}
}
+263 -3
View File
@@ -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)
}
+174
View File
@@ -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 -----------------------------------------------------------------------------
@@ -1468,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")
}
}
+120 -4
View File
@@ -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) {
+11
View File
@@ -63,6 +63,8 @@ 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
@@ -295,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) {
@@ -389,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) {
+177
View File
@@ -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")
}
+98
View File
@@ -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))
}
}
}
+159 -8
View File
@@ -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
}
@@ -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 <> '';
+5
View File
@@ -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 {
+176 -8
View File
@@ -983,11 +983,11 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
// slipper der, hvor man kan se, man slipper.
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
// Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted.
// Et træk hen på en dag flytter datoen og sætter ikke et klokkeslæt, så opgaven
// har ingen tid at stå ved. `data-allday` er den anden halvdel af dagen: siden
// ugen blev et døgn, er en dag to kasser, og en prøve skal sige hvilken.
await expect(page.locator(`[data-allday="${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([]);
@@ -1178,9 +1178,8 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
// 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 bånd: et træk hen på en dag flytter datoen og sætter ikke
// et klokkeslæt, så opgaven har ingen tid at stå ved.
await expect(page.locator(`[data-allday="${next}"]`).getByText('vande blomster')).toBeVisible();
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
await expect(band.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
await expect(cell.locator('.tevent')).toHaveCount(1);
@@ -3210,3 +3209,172 @@ test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
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([]);
});
+6
View File
@@ -366,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'),
@@ -381,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'),
+49 -2
View File
@@ -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);
@@ -280,6 +289,44 @@
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
@@ -525,7 +572,7 @@
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
ondrop={(e) => onDropAllDay(e, date)}
>
{#each untimedOn(date) as item (item.kind + item.ref.id)}
{#if item.kind === 'event'}
@@ -556,7 +603,7 @@
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
ondrop={(e) => onDropAt(e, date)}
>
{#each Array(hours.count) as _, i}
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
+50 -7
View File
@@ -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;
+20
View File
@@ -75,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',
@@ -242,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',
@@ -551,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',
@@ -658,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.',
+20
View File
@@ -71,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',
@@ -238,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',
@@ -547,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',
@@ -654,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.',
+42 -3
View File
@@ -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) {
@@ -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;
+325 -78
View File
@@ -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([]);
@@ -71,6 +72,100 @@
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);
@@ -258,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
@@ -515,69 +629,99 @@
{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={selectedId === note.id}
class:picked={picked.has(note.id)}
onclick={(e) => rowClick(e, 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">
<!-- Datoen, der svarer til den rækkefølge, listen står i.
Den viste altid `updated_at`, og for tolv hundrede
importerede noter er det i aftes — så en note fra 2017
stod som "i går", og den dato, man sorterede efter, var
ingen steder at se. -->
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
<span class="preview">{plain(note.preview ?? note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</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>
{#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>
</div>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
{/each}
{/if}
</aside>
@@ -925,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 {
@@ -964,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 {