Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d67dd07d | ||
|
|
23bd784b07 | ||
|
|
264f48ae11 | ||
|
|
2219f6af13 | ||
|
|
f64b7c1f01 | ||
|
|
868588de10 |
@@ -104,15 +104,21 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||
| Notes: archive, grouped list, images, source view | Done, tested |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||
API calls are implemented against their specifications and have only ever been
|
||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Gmail is no longer in that list — it runs in production,
|
||||
including the flagging and de-duplication that took a day to get right. Calendar
|
||||
subscriptions over ICS and webcal are proven; the Google half of the calendar is
|
||||
not. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
|
||||
+23
-3
@@ -39,8 +39,28 @@ reply within a week.
|
||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||
page, rather than allowing `unsafe-inline`.
|
||||
|
||||
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||||
only the body: a note's title *is* its first line, so a readable title beside a
|
||||
sealed body would leave the beginning of every note in the clear.
|
||||
|
||||
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||||
generated `fold` column held a transliterated copy of it — sealing the body while
|
||||
leaving those would have been theatre, since a stolen file could be read straight
|
||||
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||||
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||||
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||||
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||||
projects and tasks a note points at, though not what it says.
|
||||
|
||||
## What it does not do
|
||||
|
||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
||||
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||
else is limited** — an authenticated caller can ask as often as they like, and a
|
||||
signed-in account that wanted to be a nuisance can be one.
|
||||
|
||||
There is no audit log of reads. Who *changed* something is recorded per project;
|
||||
who looked at it is not.
|
||||
|
||||
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||||
VPN — by somebody who knows what they exposed.
|
||||
|
||||
@@ -64,6 +64,22 @@ func run() error {
|
||||
}
|
||||
db.UseSecrets(box)
|
||||
|
||||
// Noter, der stadig ligger åbne, lukkes nu.
|
||||
//
|
||||
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
||||
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
||||
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
||||
// så den koster et gennemløb af id'er hver anden gang.
|
||||
//
|
||||
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
||||
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
||||
// er værre end en, der siger fra.
|
||||
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
||||
return fmt.Errorf("seal notes: %w", err)
|
||||
} else if sealed > 0 {
|
||||
log.Info("notes sealed", "count", sealed)
|
||||
}
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
|
||||
+7
-35
@@ -1,9 +1,10 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||
*into* verdande is the other direction and has its own page — see
|
||||
[Calendar](calendar.md).
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
@@ -42,39 +43,10 @@ most people actually keep.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||
does with either now live on their own page — see [Calendar](calendar.md).
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
This page is the other direction: verdande's own tasks, read by something else.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Calendar
|
||||
|
||||
Your dated tasks as a grid, with other people's calendars laid under them.
|
||||
|
||||
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
|
||||
side, and everything with a time on it in its place. Two things at ten o'clock sit
|
||||
beside each other, because that is a clash you should be able to see rather than
|
||||
read.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Your tasks**, on the day they are due — and at the time, if they have one.
|
||||
- **Events from calendars you have connected**, read-only.
|
||||
|
||||
Drag a task to another day to move it. Drop it on the hour column and it takes that
|
||||
time; drop it in the band across the top and it keeps the day and loses the time.
|
||||
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
|
||||
chip that lets itself be moved is a promise the server cannot keep.
|
||||
|
||||
A project's own calendar is under **Calendar** on the project page, with the same
|
||||
week and month.
|
||||
|
||||
## Subscribing to a calendar
|
||||
|
||||
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
|
||||
once.
|
||||
|
||||
This works with anything that publishes an `.ics` file: public holidays, a club's
|
||||
fixtures, a shared calendar from work, your own calendar's secret address from
|
||||
Google, Apple or Fastmail.
|
||||
|
||||
- **`webcal://` addresses work.** They are the same URL over https with a scheme
|
||||
that makes a desktop hand it to the calendar app; it is rewritten for you.
|
||||
- **Several at a time.** Unlike the Google connection, which is one account, a
|
||||
subscription is one address and you can have as many as you like.
|
||||
- **Read-only, refreshed every fifteen minutes.**
|
||||
- The address is fetched once before it is stored, so a typo tells you now rather
|
||||
than by silently never filling.
|
||||
|
||||
!!! note "This is the way in for a private account"
|
||||
If the instance's Google registration is *Internal*, Google refuses accounts
|
||||
from outside the organisation with `org_internal` — no setting on your side
|
||||
changes that. Your calendar's own secret ICS address goes through this panel
|
||||
and works regardless.
|
||||
|
||||
## Connecting Google Calendar
|
||||
|
||||
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
|
||||
uses, so the instance needs that registration first — see
|
||||
[Configuration](configuration.md#google).
|
||||
|
||||
Two things have to be true in Google Cloud, and only the owner of the registration
|
||||
can do them:
|
||||
|
||||
1. **Google Calendar API** is enabled for the project.
|
||||
2. The calendar callback is listed as an authorised redirect URI. The exact string
|
||||
is printed in the panel — copy it from there rather than typing it. Google
|
||||
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
|
||||
almost always means one of those four differs.
|
||||
|
||||
Once connected, tick the calendars you want to see. The list is refreshed on every
|
||||
sync, so a calendar added or renamed since last time appears under its new name.
|
||||
|
||||
!!! warning "Written to the specification, not yet proven against Google"
|
||||
The Google Calendar half is implemented from Google's documentation and tested
|
||||
against a stand-in server. It has not been exercised against Google itself.
|
||||
The subscription route above and the outgoing feed below have.
|
||||
|
||||
## Your own calendar, elsewhere
|
||||
|
||||
Two ways out, for two different jobs:
|
||||
|
||||
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
|
||||
Google or Thunderbird. Your tasks appear as dated entries.
|
||||
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
|
||||
is ticked off here.
|
||||
|
||||
The feed address is a credential in itself: anybody holding it can read your tasks
|
||||
without logging in, because a calendar client cannot sign in. Rotate it from
|
||||
**Settings → Integrations** if it gets out.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **It does not write to a connected calendar.** Events are a copy.
|
||||
- **It does not expand repeating events** from a subscribed file. A weekly meeting
|
||||
in somebody else's calendar is stored as its first occurrence.
|
||||
- **It does not resolve named time zones** from a subscribed file. An event carrying
|
||||
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
|
||||
server has moved.
|
||||
- **Only Google, and only reading**, for account connections. Everything else goes
|
||||
through a subscription.
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Notes
|
||||
|
||||
Markdown in, Markdown out. Nothing in between.
|
||||
|
||||
A note is written as rich text — headings, lists, quotations, code — and stored as
|
||||
the Markdown it looks like. That is not a detail about the database: it is what
|
||||
makes a note yours. Export gives you the same text you were looking at, and on the
|
||||
day this program is gone, every note opens in any editor.
|
||||
|
||||
## Writing
|
||||
|
||||
Start typing. **The first line is the title** — always, and re-derived every time
|
||||
you save, so a note you rewrite the top of is called by what it now says rather
|
||||
than by what it once did.
|
||||
|
||||
| Style | Written as | Shortcut |
|
||||
|---|---|---|
|
||||
| Title | `# ` at the start of the line | pick it in **Aa** |
|
||||
| Heading | `## ` | |
|
||||
| Subheading | `### ` | |
|
||||
| Quote | `> ` | |
|
||||
| Code block | three backticks, with an optional language | |
|
||||
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
|
||||
| Bold | two asterisks around the words | ⌘B |
|
||||
| Italic | one asterisk around the words | ⌘I |
|
||||
| Underline | a `u` tag around the words | ⌘U |
|
||||
| Strikethrough | two tildes around the words | |
|
||||
| Code | one backtick around the words | |
|
||||
|
||||
Every line becomes exactly one block. Markdown would normally join consecutive
|
||||
lines into a paragraph; here it does not, because you typed a line and expect to
|
||||
get it back.
|
||||
|
||||
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
|
||||
note.
|
||||
|
||||
### Pictures
|
||||
|
||||
Paste one, or drag it onto the note. It is stored with the note and travels with
|
||||
it in an export. Every raster format works; SVG is stored but not shown inline —
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
Each one carries a **Copy** button in its corner.
|
||||
|
||||
## Linking
|
||||
|
||||
Three kinds of reference, parsed out of the text as you write it:
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
|
||||
| `[[Møde med Anders]]` | Another note, by its title |
|
||||
| A pasted task link | That task |
|
||||
|
||||
Links are an index over the text, rebuilt every time the note is saved. The text
|
||||
stays the truth.
|
||||
|
||||
**The interesting direction is backwards.** A task shows what has been written
|
||||
about it, and a project shows the notes filed in it — without anybody having to
|
||||
remember to attach anything. Write `#Firma` in a note and it appears on that
|
||||
project's page.
|
||||
|
||||
A link to something that has been deleted reads as a dead link. It is a fact about
|
||||
the note, and it should not vanish because something it mentioned did.
|
||||
|
||||
## Filing and sharing
|
||||
|
||||
A note belongs to a project, or to nobody.
|
||||
|
||||
- **In a project:** everybody who can read the project can read the note, and an
|
||||
editor can change it. Choose it under **Share in** at the foot of the note.
|
||||
- **In no project:** yours, and nobody else's.
|
||||
|
||||
There is no folder tree. Projects, groups and labels already exist, are already
|
||||
shared and already have roles — a second hierarchy to file things in is one too
|
||||
many, and a note in a project inherits every one of those answers.
|
||||
|
||||
## Finding
|
||||
|
||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||
week**, and then by month. Sort by created, last touched or name, and the grouping
|
||||
follows — sorted by name it groups by first letter instead.
|
||||
|
||||
Search finds a word wherever it is:
|
||||
|
||||
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
|
||||
stripping diacritics is not enough for Danish. A folded copy of every note is
|
||||
indexed beside the original.
|
||||
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
|
||||
decade of notes is full of commands and identifiers that are several words in
|
||||
one. When the index has nothing, the text itself is read.
|
||||
- Results are ranked by how well they match, with the title weighted ten times the
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
- **Archive** — this is finished and I do not want to read past it. It stays
|
||||
findable and comes back whenever you ask.
|
||||
- **Trash** — this was a mistake. It is gone in thirty days.
|
||||
|
||||
Select several with ⌘-click and shift-click to archive or delete them together.
|
||||
|
||||
## In and out
|
||||
|
||||
**Export** gives you a zip of Markdown files, one per note, with the dates in a
|
||||
YAML front matter block that Obsidian and Bear also read:
|
||||
|
||||
```markdown
|
||||
---
|
||||
created: 2019-04-02T10:15:00Z
|
||||
modified: 2024-11-30T21:04:00Z
|
||||
---
|
||||
|
||||
# Møde med Anders
|
||||
|
||||
Han vil gerne have kaffe hver uge.
|
||||
```
|
||||
|
||||
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
|
||||
and the links in the text are relative — so a note and its pictures must be in the
|
||||
same archive, which matters when a large export is split into parts.
|
||||
|
||||
Two things to know before importing:
|
||||
|
||||
- **Nothing is merged.** Every run creates new notes. Importing the same archive
|
||||
twice gives you two of everything.
|
||||
- **The dates are read from the front matter.** Without them, a move of a thousand
|
||||
notes is a pile that was all written the same evening — and the order they were
|
||||
written in is half of what an archive is.
|
||||
|
||||
### From Apple Notes
|
||||
|
||||
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
|
||||
exactly this shape, front matter and pictures included. It is macOS-only, because
|
||||
Apple Notes is.
|
||||
|
||||
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
||||
uploadable parts, keeping each note with its own pictures.
|
||||
|
||||
## What is encrypted, and what is not
|
||||
|
||||
**A note's title and body are sealed** in the database, under a key kept in a file
|
||||
beside it rather than inside it. A backup you download does not carry the key, so
|
||||
it does not carry your notes.
|
||||
|
||||
Both halves, not only the body: a note's title is its first line, so a readable
|
||||
title beside a sealed body would leave the beginning of every note in the open.
|
||||
|
||||
**What is still in the clear:** task titles, descriptions and comments — and the
|
||||
list of what a note links to, so a stolen file shows which projects and tasks a
|
||||
note points at, though not what it says.
|
||||
|
||||
Sealing the notes cost the search its index — a full-text index over a note's body
|
||||
is another copy of that body — so search reads the notes themselves now. At twelve
|
||||
hundred notes that is about forty milliseconds, and the box waits a quarter of a
|
||||
second after you stop typing, so you will not see it.
|
||||
@@ -650,8 +650,18 @@ func pad2(n int) string {
|
||||
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
||||
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
||||
// all of it and leave the task not repeating at all.
|
||||
//
|
||||
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
|
||||
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
|
||||
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
|
||||
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
|
||||
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
|
||||
// enden; den kan ikke hente et, mønsteret aldrig tog med.
|
||||
//
|
||||
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||
// ikke var det.
|
||||
var reRepeat = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
||||
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
||||
|
||||
|
||||
@@ -603,3 +603,36 @@ func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "hver anden tirsdag" er den måde, folk siger det på.
|
||||
//
|
||||
// recurrence-pakken har oversat "hver anden" til "hver 2." siden den blev skrevet,
|
||||
// men sætningen nåede aldrig frem til den: mønsteret her tog kun ét ord efter
|
||||
// "hver", så det, der blev givet videre, var "hver anden" — ikke en regel. Så blev
|
||||
// de to ord stående i titlen, og "tirsdag" blev til en forfaldsdato. Opgaven
|
||||
// gentog sig ikke, og der stod ikke noget om det.
|
||||
//
|
||||
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||
// ikke var det.
|
||||
func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
rule string
|
||||
left string
|
||||
}{
|
||||
{"vande planterne hver anden tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||
{"vande planterne hver 2. tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||
{"water the plants every other tuesday", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "water the plants"},
|
||||
{"gå tur hver tredje søndag", "FREQ=WEEKLY;BYDAY=SU;INTERVAL=3", "gå tur"},
|
||||
// Den, der virkede i forvejen, skal blive ved med at gøre det.
|
||||
{"tømme opvasker hver mandag", "FREQ=WEEKLY;BYDAY=MO", "tømme opvasker"},
|
||||
} {
|
||||
got := Parse(tc.text, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "da")
|
||||
if got.Recurrence != tc.rule {
|
||||
t.Errorf("%q gav reglen %q, want %q", tc.text, got.Recurrence, tc.rule)
|
||||
}
|
||||
if got.Content != tc.left {
|
||||
t.Errorf("%q efterlod titlen %q, want %q", tc.text, got.Content, tc.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- verdande:rebuild-tables
|
||||
--
|
||||
-- Notekroppe forsegles.
|
||||
--
|
||||
-- Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
|
||||
-- sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem
|
||||
-- hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler
|
||||
-- ned — det ved vi, fordi der ligger sådan nogle i denne installation.
|
||||
--
|
||||
-- # Hvorfor det ikke var gjort for længe siden
|
||||
--
|
||||
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
|
||||
--
|
||||
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
|
||||
-- "grøn" kan findes ved at skrive "gron".
|
||||
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
|
||||
--
|
||||
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
|
||||
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
|
||||
-- skal skrives om til at åbne teksten i Go.
|
||||
--
|
||||
-- # Hvad det koster, og hvornår formen er forkert
|
||||
--
|
||||
-- Søgningen læser nu hver note. Målt: tolv hundrede noter à 3,3 kB koster 33–43 ms
|
||||
-- pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så
|
||||
-- det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne, men med at
|
||||
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
|
||||
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
|
||||
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
|
||||
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
|
||||
-- delordssøgningen, der findes i dag, ville falde bort.
|
||||
--
|
||||
-- # Hvad der stadig ligger i klartekst
|
||||
--
|
||||
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
|
||||
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
|
||||
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
|
||||
-- ikke løst her.
|
||||
--
|
||||
-- # Selve ombygningen
|
||||
--
|
||||
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
|
||||
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
|
||||
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
|
||||
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
|
||||
-- at kunne læses uden at gætte.
|
||||
CREATE TABLE notes_sealed (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
archived_at INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO notes_sealed
|
||||
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
|
||||
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
|
||||
FROM notes;
|
||||
|
||||
DROP TRIGGER notes_fts_insert;
|
||||
DROP TRIGGER notes_fts_delete;
|
||||
DROP TRIGGER notes_fts_update;
|
||||
DROP TABLE notes_fts;
|
||||
DROP TABLE notes;
|
||||
|
||||
ALTER TABLE notes_sealed RENAME TO notes;
|
||||
|
||||
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
|
||||
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
+125
-83
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -89,6 +91,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
||||
// it, and every caller gets the same answer because none of them can set it.
|
||||
n.Title = firstLine(n.Body)
|
||||
|
||||
// Forseglet på vej ned, åbnet på vej op.
|
||||
//
|
||||
// Både titel og krop: titlen *er* første linje, så en forseglet krop ved siden
|
||||
// af en læselig titel ville efterlade begyndelsen af hver eneste note i
|
||||
// klartekst — og det er dér, folk skriver hvad noten handler om.
|
||||
//
|
||||
// `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den
|
||||
// bagefter; at forsegle den på plads ville give en note, hvis krop er
|
||||
// ciffertekst, til den, der lige har gemt den.
|
||||
title, err := db.sealValue(n.Title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal note title: %w", err)
|
||||
}
|
||||
body, err := db.sealValue(n.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal note body: %w", err)
|
||||
}
|
||||
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var projectID any
|
||||
if n.ProjectID != "" {
|
||||
@@ -108,7 +128,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
||||
body = excluded.body,
|
||||
pinned = excluded.pinned,
|
||||
updated_at = excluded.updated_at`,
|
||||
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
|
||||
n.ID, projectID, title, body, createdBy, n.Pinned,
|
||||
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,7 +202,7 @@ func firstLine(body string) string {
|
||||
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
||||
row := db.QueryRowContext(ctx,
|
||||
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
||||
n, err := scanNote(row)
|
||||
n, err := db.scanNote(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -263,99 +283,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note,
|
||||
|
||||
// SearchNotes matches title and body, in either spelling of a Danish word.
|
||||
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
terms := SearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
expr := MatchExprOver(query, "title", "body")
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
|
||||
// Hver note læses og åbnes.
|
||||
//
|
||||
// Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts`
|
||||
// og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en
|
||||
// forseglet krop ved siden af dem ville have været teater — en fil ville stadig
|
||||
// røbe hver note. Se 0027.
|
||||
//
|
||||
// Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr.
|
||||
// søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det
|
||||
// ligger inde i pausen og ses ikke.
|
||||
//
|
||||
// Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få
|
||||
// millisekunder — men med at folde og småskrive hver krop for hver søgning.
|
||||
// Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det
|
||||
// den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under
|
||||
// samme nøgle. Bemærk hvad det koster, når det bliver aktuelt — et nøglet
|
||||
// indeks kan slå et helt ord op og ikke andet, så præfiks- og delordssøgningen,
|
||||
// der findes i dag, ville falde bort.
|
||||
//
|
||||
// Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning,
|
||||
// der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne
|
||||
// det.
|
||||
notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ordered by how well it matched, not by when it was last touched.
|
||||
//
|
||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||
// useless at twelve hundred: every one of them was imported the same evening,
|
||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
||||
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||
// that does not work, because from where the person is sitting it is one.
|
||||
//
|
||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||
// carries the same text transliterated, so it must weigh the same as the body
|
||||
// or a Danish match would outrank an exact one.
|
||||
//
|
||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||
// subquery throws it away.
|
||||
found, err := db.notesJoin(ctx, noteColumnsQualified, `
|
||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||
WHERE notes.deleted_at IS NULL
|
||||
AND notes_fts MATCH ?
|
||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||
LIMIT ?`, expr, limit)
|
||||
if err != nil || len(found) > 0 {
|
||||
return found, err
|
||||
|
||||
type scored struct {
|
||||
note Note
|
||||
score int
|
||||
}
|
||||
return db.searchNotesInside(ctx, query, limit)
|
||||
found := make([]scored, 0, limit)
|
||||
|
||||
for _, note := range notes {
|
||||
score := scoreNote(note, terms)
|
||||
if score == 0 {
|
||||
continue
|
||||
}
|
||||
found = append(found, scored{note: note, score: score})
|
||||
}
|
||||
|
||||
// Bedste først, og ved lige stand den senest rørte. Uden det andet led er
|
||||
// rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den
|
||||
// skifter mellem to søgninger på det samme.
|
||||
sort.SliceStable(found, func(i, j int) bool {
|
||||
if found[i].score != found[j].score {
|
||||
return found[i].score > found[j].score
|
||||
}
|
||||
return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt)
|
||||
})
|
||||
|
||||
out := make([]Note, 0, min(limit, len(found)))
|
||||
for i, f := range found {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, f.note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchNotesInside finds a word that lives *inside* another word.
|
||||
// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke.
|
||||
//
|
||||
// An index is built of tokens, and a token is what a tokenizer decided was a
|
||||
// word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
|
||||
// gets nothing. Reported as "søgning har ikke det store held", which from where
|
||||
// the person is sitting is exactly what it is: the note is right there, and the
|
||||
// search says there is nothing.
|
||||
// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at
|
||||
// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og
|
||||
// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den
|
||||
// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er
|
||||
// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale.
|
||||
//
|
||||
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
|
||||
// cannot, because it is not a prefix of anything — and the two are joined with AND,
|
||||
// so the half that works is thrown away by the half that cannot.
|
||||
//
|
||||
// This is not an exotic case in a notes app. A decade of notes is full of
|
||||
// commands, identifiers and file names, and every one of them is several words the
|
||||
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
|
||||
//
|
||||
// So: the index answers first, and when it has nothing, the text is read. Every
|
||||
// term has to appear somewhere in the note, anywhere, in any word.
|
||||
//
|
||||
// # What it costs, and when to stop doing it this way
|
||||
//
|
||||
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
|
||||
// hundred notes that is four megabytes and a millisecond or two, and it only runs
|
||||
// when the fast path already came back empty — which is the search that was about
|
||||
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
|
||||
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
|
||||
//
|
||||
// It does not rank. bm25 needs a match to rank, and there is no match here — that
|
||||
// is the whole situation. The rows are the most recently touched first, and the
|
||||
// set is small, because the index already said there was nothing in it.
|
||||
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||
terms := SearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet
|
||||
// med AND.
|
||||
func scoreNote(note Note, terms []string) int {
|
||||
title := FoldDanish(strings.ToLower(note.Title))
|
||||
body := FoldDanish(strings.ToLower(note.Body))
|
||||
|
||||
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
|
||||
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
|
||||
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
|
||||
// answer to "inventory".
|
||||
//
|
||||
// The needle is safe to interpolate into a pattern because `tokenize` splits on
|
||||
// everything that is not a letter or a digit: a term cannot contain % or _, so
|
||||
// there is no wildcard for somebody to smuggle in. It is still a bound
|
||||
// parameter — that is about SQL, and this is about LIKE.
|
||||
where := "WHERE deleted_at IS NULL"
|
||||
args := make([]any, 0, len(terms)+1)
|
||||
score := 0
|
||||
for _, term := range terms {
|
||||
where += " AND fold LIKE ?"
|
||||
args = append(args, "%"+FoldDanish(term)+"%")
|
||||
needle := FoldDanish(strings.ToLower(term))
|
||||
inTitle := strings.Count(title, needle)
|
||||
inBody := strings.Count(body, needle)
|
||||
if inTitle == 0 && inBody == 0 {
|
||||
// Ét ord, der ikke findes, er et nej for hele noten.
|
||||
return 0
|
||||
}
|
||||
// Loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der
|
||||
// nævner alle ordene én gang. Det er den fejl, en ren optælling laver, og
|
||||
// den er let at lave og svær at få øje på.
|
||||
score += 10*min(inTitle, 3) + min(inBody, 5)
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
|
||||
return score
|
||||
}
|
||||
|
||||
// SetNoteTimes writes the dates a note had somewhere else.
|
||||
@@ -441,7 +466,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any)
|
||||
|
||||
var out []Note
|
||||
for rows.Next() {
|
||||
n, err := scanNote(rows)
|
||||
n, err := db.scanNote(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -469,7 +494,11 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanNote(row scanner) (Note, error) {
|
||||
// scanNote læser en række og åbner det, der er forseglet.
|
||||
//
|
||||
// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste
|
||||
// grund; alt andet i den er den samme aflæsning som før.
|
||||
func (db *DB) scanNote(row scanner) (Note, error) {
|
||||
var n Note
|
||||
var projectID, createdBy sql.NullString
|
||||
var created, updated int64
|
||||
@@ -491,5 +520,18 @@ func scanNote(row scanner) (Note, error) {
|
||||
t := time.Unix(archived.Int64, 0)
|
||||
n.ArchivedAt = &t
|
||||
}
|
||||
|
||||
// Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst.
|
||||
//
|
||||
// En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:`
|
||||
// findes netop for at kunne læse en blandet søjle — så en installation, der
|
||||
// endnu ikke har fået sin bagudfyldning, virker imens.
|
||||
for _, field := range []*string{&n.Title, &n.Body} {
|
||||
plain, err := db.unsealValue(*field)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("open note %s: %w", n.ID, err)
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -277,3 +277,119 @@ func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
||||
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
|
||||
//
|
||||
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
|
||||
// gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi,
|
||||
// fordi der ligger sådan nogle i den installation, det her blev bygget til.
|
||||
func TestANoteIsNotStoredInTheClear(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const secretLine = "gamerule keepInventory true"
|
||||
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var title, body string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(body, secretLine) {
|
||||
t.Errorf("kroppen står i klartekst i rækken: %s", body)
|
||||
}
|
||||
// Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en
|
||||
// læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.
|
||||
if strings.Contains(title, "Minecraft") {
|
||||
t.Errorf("titlen står i klartekst i rækken: %s", title)
|
||||
}
|
||||
|
||||
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
|
||||
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
|
||||
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
|
||||
}
|
||||
|
||||
// Læst tilbage er den sig selv igen.
|
||||
back, err := db.Note(ctx, n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
|
||||
t.Errorf("noten kom anderledes tilbage: %+v", back)
|
||||
}
|
||||
|
||||
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
|
||||
// indeks, der var en klartekstkopi af den.
|
||||
found, err := db.SearchNotes(ctx, "keep inventory", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
|
||||
}
|
||||
}
|
||||
|
||||
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
|
||||
//
|
||||
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
|
||||
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
|
||||
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
|
||||
func TestOldNotesAreSealedOnStartup(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
|
||||
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
|
||||
userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sealed, err := db.SealNotes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sealed != 1 {
|
||||
t.Fatalf("%d noter blev lukket, want 1", sealed)
|
||||
}
|
||||
|
||||
var body string
|
||||
var updated int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(body, "hemmeligt") {
|
||||
t.Errorf("den gamle note ligger stadig åben: %s", body)
|
||||
}
|
||||
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
|
||||
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
|
||||
// rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||
if updated != 200 {
|
||||
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
|
||||
}
|
||||
|
||||
// Og den kan læses.
|
||||
back, err := db.Note(ctx, "n-gammel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
|
||||
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
|
||||
}
|
||||
|
||||
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
|
||||
// præfiks.
|
||||
again, err := db.SealNotes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != 0 {
|
||||
t.Errorf("anden kørsel lukkede %d noter om igen", again)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,81 @@ type DB struct {
|
||||
// misplaced key look like a corrupt disk.
|
||||
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
||||
|
||||
// SealNotes lukker de noter, der stadig ligger åbne.
|
||||
//
|
||||
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
|
||||
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
|
||||
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
|
||||
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
|
||||
// den her ændring rent faktisk beskytter noget på.
|
||||
//
|
||||
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
|
||||
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
|
||||
// id'er og en længde.
|
||||
//
|
||||
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
|
||||
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
|
||||
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
|
||||
func (db *DB) SealNotes(ctx context.Context) (int, error) {
|
||||
if db.box == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type open struct{ id, title, body string }
|
||||
var pending []open
|
||||
for rows.Next() {
|
||||
var o open
|
||||
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, o)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
|
||||
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
|
||||
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
|
||||
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, o := range pending {
|
||||
title, err := db.sealValue(o.title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := db.sealValue(o.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
|
||||
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
|
||||
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(pending), nil
|
||||
}
|
||||
|
||||
// dsn is the connection string.
|
||||
//
|
||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||
|
||||
+3
-1
@@ -50,10 +50,12 @@ nav:
|
||||
- Using it:
|
||||
- Signing in: signing-in.md
|
||||
- Quick add: quick-add.md
|
||||
- Notes: notes.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- Connecting things:
|
||||
- Calendar and CalDAV: caldav.md
|
||||
- Calendar: calendar.md
|
||||
- Feeds and CalDAV: caldav.md
|
||||
- Mail to task: mail.md
|
||||
- Mailboxes: mailboxes.md
|
||||
- Claude (MCP): mcp.md
|
||||
|
||||
@@ -3378,3 +3378,95 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et projekts kalender kan vises som en uge.
|
||||
*
|
||||
* Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
|
||||
* det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
|
||||
*
|
||||
* Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
|
||||
* og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
|
||||
* bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
|
||||
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
|
||||
*/
|
||||
test('et projekts kalender kan vises som en uge', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('male døren i dag kl 10');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('male døren')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
|
||||
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
|
||||
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||
await expect(placed).toContainText('10:00');
|
||||
|
||||
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
|
||||
// afgør det, og projektet er delt med andre skærme.
|
||||
await page.reload();
|
||||
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await expect(page.locator('.weekgrid')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk.
|
||||
*
|
||||
* `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med:
|
||||
* `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én
|
||||
* nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive
|
||||
* den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører
|
||||
* hjemme dér, hvor læseren er.
|
||||
*
|
||||
* De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som
|
||||
* fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens
|
||||
* tekst — og den er dansk, hvad end kontoen er sat til.
|
||||
*/
|
||||
test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
for (const [text, says] of [
|
||||
['vande planterne hver mandag', 'hver mandag'],
|
||||
['tømme opvasker hverdage', 'hverdage']
|
||||
]) {
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(text);
|
||||
await box.press('Enter');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// I indbakken og ikke på I dag: en ugentlig regel sætter den første
|
||||
// forfaldsdag til den næste sådan dag, som sjældent er i dag.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Hovedmenu' })
|
||||
.getByRole('link', { name: /^Indbakke/ })
|
||||
.click();
|
||||
|
||||
const name = text.split(' ').slice(0, 2).join(' ');
|
||||
const row = page.locator('.row', { hasText: name }).first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.locator('.repeat'),
|
||||
`"${text}" blev ikke læst som en gentagelse`
|
||||
).toContainText(says);
|
||||
|
||||
await page.goto('/');
|
||||
}
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,14 @@
|
||||
events = []
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
// Sat til i dag og lagt til rette af effekten nedenfor.
|
||||
//
|
||||
// Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
|
||||
// øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
|
||||
// byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
|
||||
// komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
|
||||
// projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
|
||||
let cursor = $state(new Date());
|
||||
|
||||
/**
|
||||
* The weekday names, from Intl rather than written out.
|
||||
@@ -101,13 +108,26 @@
|
||||
// week with a cursor on the 1st would otherwise show the week containing the
|
||||
// 1st rather than the week you were looking at, and switching back would land
|
||||
// on whichever month that week happened to start in.
|
||||
//
|
||||
// Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
|
||||
// en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
|
||||
// til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
|
||||
// ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
|
||||
// projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
|
||||
$effect(() => {
|
||||
const aligned = startOfSpan(cursor);
|
||||
const anchor =
|
||||
span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
|
||||
? new Date()
|
||||
: cursor;
|
||||
const aligned = startOfSpan(anchor);
|
||||
// Compared by value, not identity: assigning a fresh Date on every run would
|
||||
// re-trigger this effect forever, since the effect reads what it writes.
|
||||
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
|
||||
});
|
||||
|
||||
const isMonthStart = (d) => d.getDate() === 1;
|
||||
const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
||||
|
||||
function iso(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
|
||||
@@ -152,6 +152,25 @@ export const da = {
|
||||
'task.yesterday': 'I går',
|
||||
'task.overdue': '{n} dage forsinket',
|
||||
'task.repeats': 'Gentages {rule}',
|
||||
'repeat.weekdays': 'hverdage',
|
||||
'repeat.weekends': 'hver weekend',
|
||||
'repeat.and': 'og',
|
||||
'repeat.everyDay': 'hver {day}',
|
||||
'repeat.everyNthWeekOn': 'hver {n}. uge {day}',
|
||||
'repeat.everyOtherWeekOn': 'hver anden uge {day}',
|
||||
'repeat.monthlyOn': 'den {day}. hver måned',
|
||||
'repeat.every.day': 'hver dag',
|
||||
'repeat.every.week': 'hver uge',
|
||||
'repeat.every.month': 'hver måned',
|
||||
'repeat.every.year': 'hvert år',
|
||||
'repeat.everyOther.day': 'hver anden dag',
|
||||
'repeat.everyOther.week': 'hver anden uge',
|
||||
'repeat.everyOther.month': 'hver anden måned',
|
||||
'repeat.everyOther.year': 'hvert andet år',
|
||||
'repeat.everyN.day': 'hver {n}. dag',
|
||||
'repeat.everyN.week': 'hver {n}. uge',
|
||||
'repeat.everyN.month': 'hver {n}. måned',
|
||||
'repeat.everyN.year': 'hvert {n}. år',
|
||||
'task.assignee': 'Ansvarlig: {name}',
|
||||
'task.subtasks': '{done} af {total} undertasks er lukket',
|
||||
'task.attachments': '{n} vedhæftet',
|
||||
|
||||
@@ -148,6 +148,25 @@ export const en = {
|
||||
'task.yesterday': 'Yesterday',
|
||||
'task.overdue': '{n} days overdue',
|
||||
'task.repeats': 'Repeats {rule}',
|
||||
'repeat.weekdays': 'weekdays',
|
||||
'repeat.weekends': 'every weekend',
|
||||
'repeat.and': 'and',
|
||||
'repeat.everyDay': 'every {day}',
|
||||
'repeat.everyNthWeekOn': 'every {n} weeks on {day}',
|
||||
'repeat.everyOtherWeekOn': 'every other week on {day}',
|
||||
'repeat.monthlyOn': 'the {day}th of every month',
|
||||
'repeat.every.day': 'every day',
|
||||
'repeat.every.week': 'every week',
|
||||
'repeat.every.month': 'every month',
|
||||
'repeat.every.year': 'every year',
|
||||
'repeat.everyOther.day': 'every other day',
|
||||
'repeat.everyOther.week': 'every other week',
|
||||
'repeat.everyOther.month': 'every other month',
|
||||
'repeat.everyOther.year': 'every other year',
|
||||
'repeat.everyN.day': 'every {n} days',
|
||||
'repeat.everyN.week': 'every {n} weeks',
|
||||
'repeat.everyN.month': 'every {n} months',
|
||||
'repeat.everyN.year': 'every {n} years',
|
||||
'task.assignee': 'Assigned to {name}',
|
||||
'task.subtasks': '{done} of {total} sub-tasks are done',
|
||||
'task.attachments': '{n} attached',
|
||||
|
||||
@@ -890,27 +890,55 @@ export const upcomingView = new UpcomingView();
|
||||
const CALENDAR_MODES = ['week', 'month'];
|
||||
|
||||
class CalendarViewMode {
|
||||
mode = $state(readCalendarMode());
|
||||
mode = $state('month');
|
||||
|
||||
// Nøglen som argument frem for som felt på en underklasse.
|
||||
//
|
||||
// Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
|
||||
// basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
|
||||
// og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
|
||||
// andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
|
||||
// Et argument har ingen rækkefølge at tage fejl af.
|
||||
constructor(storageKey = 'verdande:calendar') {
|
||||
this.storageKey = storageKey;
|
||||
this.mode = readCalendarMode(storageKey);
|
||||
}
|
||||
|
||||
set(next) {
|
||||
if (!CALENDAR_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:calendar', next);
|
||||
localStorage.setItem(this.storageKey, next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCalendarMode() {
|
||||
function readCalendarMode(key) {
|
||||
if (typeof localStorage === 'undefined') return 'month';
|
||||
const stored = localStorage.getItem('verdande:calendar');
|
||||
const stored = localStorage.getItem(key);
|
||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||
}
|
||||
|
||||
export const calendarView = new CalendarViewMode();
|
||||
|
||||
/**
|
||||
* Måned eller uge på et projekts kalender.
|
||||
*
|
||||
* Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
|
||||
* hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
|
||||
* *er* gitteret, og et projekts kalender er en del af en side, der også kan være
|
||||
* en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
|
||||
* og fandt et andet ændret.
|
||||
*
|
||||
* Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
|
||||
* se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
|
||||
* hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
|
||||
* ligger i localStorage af.
|
||||
*/
|
||||
export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
|
||||
|
||||
/**
|
||||
* Whether the lists show what has already been finished.
|
||||
*
|
||||
|
||||
+108
-2
@@ -73,8 +73,114 @@ export const REPEATS = [
|
||||
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||
];
|
||||
|
||||
/** What a repeating task says on its row. */
|
||||
/**
|
||||
* What a repeating task says on its row.
|
||||
*
|
||||
* Beskrevet her og ikke på serveren, og det er ikke en smagssag.
|
||||
*
|
||||
* `recurrence.Describe` skriver dansk. Den kan ikke bare tage en locale med:
|
||||
* `toTaskJSON` har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser,
|
||||
* hvor én nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at
|
||||
* skrive den i. Reglen ligger allerede i nyttelasten, og den er den samme uanset
|
||||
* hvem der læser den. Så beskrivelsen hører hjemme dér, hvor læseren er.
|
||||
*
|
||||
* Serverens tekst bliver som reserve. Den er stadig den, et API-kald og en
|
||||
* MCP-forbindelse får, og de har ingen flade at oversætte i.
|
||||
*/
|
||||
export function repeatLabel(rule, fallback = '') {
|
||||
if (!rule) return '';
|
||||
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||
return known ? t(known.label) : fallback;
|
||||
if (known) return t(known.label);
|
||||
return describeRule(rule) || fallback;
|
||||
}
|
||||
|
||||
/** Ugedagene i den rækkefølge RRULE navngiver dem. */
|
||||
const RRULE_DAYS = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||
|
||||
/**
|
||||
* Ugedagens navn på læserens sprog, fra Intl frem for fra en tabel.
|
||||
*
|
||||
* Ikke for at spare syv strenge pr. sprog — for at få de navne, sproget faktisk
|
||||
* bruger. Den 1. januar 2024 var en mandag; enhver mandag ville gøre det, og en
|
||||
* fast en holder det ude af tegningen.
|
||||
*/
|
||||
function dayName(code) {
|
||||
const index = RRULE_DAYS[code];
|
||||
if (index === undefined) return code;
|
||||
return new Intl.DateTimeFormat(tag(), { weekday: 'long' }).format(
|
||||
new Date(Date.UTC(2024, 0, 1 + index))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* En RRULE sagt med ord.
|
||||
*
|
||||
* Dækker det, verdandes egen hurtig tilføjelse kan lave, og falder tilbage til
|
||||
* reglen selv for alt andet — en kalenderklient kan sende ting, ingen her har
|
||||
* skrevet, og en halv oversættelse af dem ville være værre end den rå regel.
|
||||
*/
|
||||
export function describeRule(rule) {
|
||||
if (!rule) return '';
|
||||
const parts = {};
|
||||
for (const pair of rule.replace(/^RRULE:/i, '').split(';')) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key && value !== undefined) parts[key.toUpperCase()] = value;
|
||||
}
|
||||
|
||||
const interval = Number(parts.INTERVAL) > 0 ? Number(parts.INTERVAL) : 1;
|
||||
|
||||
if (parts.BYDAY) {
|
||||
if (parts.BYDAY === 'MO,TU,WE,TH,FR') return t('repeat.weekdays');
|
||||
if (parts.BYDAY === 'SA,SU') return t('repeat.weekends');
|
||||
|
||||
// Sorteret som ugen går, ikke som reglen tilfældigvis skrev dem: "mandag og
|
||||
// torsdag" og "torsdag og mandag" er den samme regel, og den ene af dem er
|
||||
// den, folk siger.
|
||||
const names = parts.BYDAY.split(',')
|
||||
.filter((code) => code in RRULE_DAYS)
|
||||
.sort((a, b) => RRULE_DAYS[a] - RRULE_DAYS[b])
|
||||
.map(dayName);
|
||||
if (!names.length) return '';
|
||||
|
||||
const joined =
|
||||
names.length === 1
|
||||
? names[0]
|
||||
: names.slice(0, -1).join(', ') + ' ' + t('repeat.and') + ' ' + names.at(-1);
|
||||
|
||||
// "hver mandag", ikke "hver uge mandag". En ugentlig regel, der navngiver
|
||||
// sine dage, har ikke også brug for ordet for intervallet.
|
||||
if (interval <= 1) return t('repeat.everyDay', { day: joined });
|
||||
// To har sit eget ord på begge sprog — "hver anden", "every other" — og det
|
||||
// er ikke tallet to sat ind i den samme sætning. Et sprog, der bøjer, kan
|
||||
// ikke sættes sammen af stumper.
|
||||
if (interval === 2) return t('repeat.everyOtherWeekOn', { day: joined });
|
||||
return t('repeat.everyNthWeekOn', { n: interval, day: joined });
|
||||
}
|
||||
|
||||
switch (parts.FREQ?.toUpperCase()) {
|
||||
case 'DAILY':
|
||||
return every(interval, 'day');
|
||||
case 'WEEKLY':
|
||||
return every(interval, 'week');
|
||||
case 'MONTHLY':
|
||||
if (parts.BYMONTHDAY) return t('repeat.monthlyOn', { day: parts.BYMONTHDAY });
|
||||
return every(interval, 'month');
|
||||
case 'YEARLY':
|
||||
return every(interval, 'year');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "hver dag", "hver anden uge", "hver tredje måned".
|
||||
*
|
||||
* Tallet står i teksten frem for at blive sat sammen af stumper: "every other
|
||||
* week" og "hver anden uge" er ikke to ord byttet om, og et sprog, der bøjer
|
||||
* enheden efter tallet, kan ikke sættes sammen af to nøgler.
|
||||
*/
|
||||
function every(interval, unit) {
|
||||
if (interval <= 1) return t(`repeat.every.${unit}`);
|
||||
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||
import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
@@ -786,7 +786,26 @@
|
||||
onsectionadded={(section) => (sections = [...sections, section])}
|
||||
/>
|
||||
{:else if mode === 'calendar'}
|
||||
<CalendarView projectId={project.id} />
|
||||
<!-- Uge eller måned inde i kalendervisningen frem for som en fjerde
|
||||
`view_mode`.
|
||||
`view_mode` er projektets og delt med alle, der kan se det; uge eller
|
||||
måned er et spørgsmål om, hvor bred skærmen er, foran hvilken der
|
||||
sidder én person. Og en fjerde værdi ville kræve en tabelombygning:
|
||||
`CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
|
||||
ændre på plads. -->
|
||||
<div class="span" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
class:active={projectCalendarView.mode === value}
|
||||
onclick={() => projectCalendarView.set(value)}
|
||||
aria-pressed={projectCalendarView.mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<CalendarView
|
||||
projectId={project.id}
|
||||
span={projectCalendarView.mode === 'week' ? 'week' : 'month'}
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
@@ -1263,6 +1282,32 @@
|
||||
0 0 0 3px var(--ink);
|
||||
}
|
||||
|
||||
/* Den samme lille vælger som visningerne ovenfor, men ude til højre: den hører
|
||||
til kalenderen, ikke til siden, og at stille den ved siden af Liste/Board/
|
||||
Kalender ville lade som om der er fem visninger. */
|
||||
.span {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s1);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.span button {
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.span button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.span button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
|
||||
Reference in New Issue
Block a user