Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159029675c | ||
|
|
b18d9a26e6 | ||
|
|
60795126a5 | ||
|
|
664614b924 | ||
|
|
807aa83e58 | ||
|
|
9199709aa5 | ||
|
|
1d15cb18c0 | ||
|
|
ff988d26bb | ||
|
|
b451fd585d | ||
|
|
8c3aa33f13 | ||
|
|
c6c7979d1a | ||
|
|
6154e6b247 | ||
|
|
7e3d300183 | ||
|
|
3cee9bfb6e | ||
|
|
9b067f7057 | ||
|
|
0c45080dc2 | ||
|
|
bba2dab209 | ||
|
|
438095e252 | ||
|
|
3132bba7e5 | ||
|
|
44b28f1cd9 |
+21
-1
@@ -97,7 +97,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -271,3 +277,17 @@ jobs:
|
||||
set -eu
|
||||
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
||||
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
|
||||
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
|
||||
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
|
||||
|
||||
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
|
||||
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
|
||||
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
|
||||
# Det kræver `--load` to gange, og det er et halvt minut per image på en
|
||||
# runner, der deles med Yggdrasils produktionscontainere og allerede har
|
||||
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
|
||||
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
|
||||
# autoritativ for de offentlige projekter.
|
||||
#
|
||||
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
|
||||
|
||||
@@ -40,7 +40,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -117,9 +123,59 @@ jobs:
|
||||
# Build only — publishing happens on a tag, in release.yml. This job exists
|
||||
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||
# than at release time.
|
||||
#
|
||||
# Loaded into the local daemon rather than discarded, because the step below
|
||||
# has to inspect what came out.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: verdande:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: urd:ci
|
||||
build-args: EDITION=notes
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# The claim this repository makes about urd is that it is the same program,
|
||||
# not a fork — and that claim is checkable rather than a sentence in a README.
|
||||
#
|
||||
# Stages 1 and 2 are identical for both builds, and the edition is set with
|
||||
# ENV, which is image *config* rather than a layer. So the two images must
|
||||
# have the same layer digests and differ only in that one variable. A change
|
||||
# that made them diverge — a second COPY, a conditional RUN, a separate build
|
||||
# of the binary — would quietly turn "one source" into two, and the only
|
||||
# symptom would be a bug fixed in one product and not the other.
|
||||
#
|
||||
# This was impossible until the frontend build became deterministic: SvelteKit
|
||||
# put Date.now() in version.json, so no two builds ever matched. See the note
|
||||
# in web/svelte.config.js.
|
||||
- name: The two images are one build
|
||||
run: |
|
||||
set -eu
|
||||
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
|
||||
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
|
||||
echo "The verdande and urd images do not share their layers:"
|
||||
layers verdande:ci
|
||||
layers urd:ci
|
||||
echo "They are built from one source, so this is either a Dockerfile that"
|
||||
echo "now builds the binary twice, or a stage that is no longer shared."
|
||||
exit 1
|
||||
fi
|
||||
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
|
||||
|
||||
# And the half that stops the check above from passing over two identical
|
||||
# images: they must actually differ in the one way they are supposed to.
|
||||
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
|
||||
echo "verdande: $(edition verdande:ci)"
|
||||
echo "urd: $(edition urd:ci)"
|
||||
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
|
||||
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
|
||||
|
||||
@@ -44,6 +44,11 @@ node_modules.nosync/
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||
# af det samme.
|
||||
/web/.playwright-data-notes/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
+35
-1
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
|
||||
# Passed into the frontend build, because SvelteKit's default build version is
|
||||
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
|
||||
# the service worker, changed the content hash of every chunk referencing them, and
|
||||
# so changed those chunks' filenames. Every build of one commit produced different
|
||||
# bytes. See the long note in web/svelte.config.js.
|
||||
ARG VERSION=dev
|
||||
ENV VERDANDE_VERSION=${VERSION}
|
||||
|
||||
# The frontend is built when it is present. During backend-only development it is
|
||||
# not, and the build must still produce a working image rather than failing on a
|
||||
# missing directory — so this stage falls back to a placeholder page. The Go stage
|
||||
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
#
|
||||
# "Reproducible" was false for a year and nobody read the sentence that said it:
|
||||
# the flags above do their job, and the frontend embedded below carried a
|
||||
# millisecond timestamp, so every build of one commit produced a different binary.
|
||||
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
|
||||
# match rather than trusting this comment.
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-o /verdande ./cmd/verdande
|
||||
|
||||
# --- 3. what actually ships --------------------------------------------------
|
||||
#
|
||||
# Two images come out of this stage, and the only difference between them is one
|
||||
# environment variable:
|
||||
#
|
||||
# docker build . → verdande
|
||||
# docker build --build-arg EDITION=notes → urd
|
||||
#
|
||||
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
|
||||
# the same build rather than a rebuild that happens to match. That is the point:
|
||||
# urd is not a fork, and "built from the same source" is a property of the layers
|
||||
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
|
||||
# the two images share every layer digest and differ only in that config — which CI
|
||||
# asserts on every pull request.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
|
||||
# so one above the FROM above would be invisible down here. `full` by default,
|
||||
# because that is what this program has always been and an unqualified build must
|
||||
# not quietly produce the other product.
|
||||
ARG EDITION=full
|
||||
|
||||
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
|
||||
# so the container needs a tz database and CA certificates for outbound SMTP.
|
||||
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
|
||||
# nightly backups.
|
||||
VOLUME ["/data"]
|
||||
ENV VERDANDE_DATA_DIR=/data \
|
||||
VERDANDE_ADDR=:8080
|
||||
VERDANDE_ADDR=:8080 \
|
||||
VERDANDE_EDITION=${EDITION}
|
||||
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
||||
Which is what a to-do list is.
|
||||
|
||||
## Two editions: verdande and urd
|
||||
|
||||
This repository builds one program with two faces. Start it with
|
||||
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
|
||||
routes are not mounted, the sidebar has no way to them, and the program calls
|
||||
itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
|
||||
wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||
five of nine MCP tools. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
|
||||
@@ -26,6 +26,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
// Not the edition's name: this fires before the configuration is read, so
|
||||
// the edition is not known yet — and one of the things it reports is a
|
||||
// VERDANDE_EDITION the program refused.
|
||||
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +40,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := httpapi.NewLogger(cfg.Dev)
|
||||
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
||||
// The edition's own name, so an urd instance's log says urd. The full edition
|
||||
// still logs "starting verdande", which is what the release check reads.
|
||||
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
||||
|
||||
// The data volume is the only state that matters; create the whole layout up
|
||||
// front so a failure here is a startup error rather than a surprise at the
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
# The install count
|
||||
|
||||
verdande reports that it exists. Two values, once a day:
|
||||
verdande reports that it exists — and so does urd, which is the same program in
|
||||
its [notes edition](editions.md). Two values, once a day:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day:
|
||||
|
||||
That is the whole message. No IP is stored by the collector, no hostname, no
|
||||
domain, no account, no counts of anything you have made, nothing about what you
|
||||
use it for. The payload is small enough to be printed in full on the settings page,
|
||||
use it for — and not which of the two editions is running, so the count does not
|
||||
tell the two apart either. The payload is small enough to be printed in full on the settings page,
|
||||
and it is printed there — a promise about telemetry is worth exactly as much as
|
||||
your ability to check it.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Two editions: verdande and urd
|
||||
|
||||
One program, built once, with two faces.
|
||||
|
||||
| | **verdande** | **urd** |
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 137 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
mounted, the sidebar has no way to them, search answers with notes only, and the
|
||||
program calls itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
## Running the notes edition
|
||||
|
||||
There is one image. The edition is an environment variable on the container:
|
||||
|
||||
```
|
||||
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
|
||||
ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
No second image and no rune of its own, deliberately.
|
||||
|
||||
The edition is for an operator who wants an instance that genuinely has no task
|
||||
routes — the routes are absent from the router, not hidden in the interface. It is
|
||||
**not** how urd is normally reached. That is the second door, below.
|
||||
|
||||
Two details that surprise people who look inside:
|
||||
|
||||
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
|
||||
both editions. Not an oversight: it is the same binary, and renaming the file per
|
||||
edition would be a migration hazard in exchange for nothing a user sees.
|
||||
- **The icons for both editions ship in the one image.** The server decides which
|
||||
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
|
||||
and never reads the manifest, so renaming paths would have left iOS with the
|
||||
wrong mark.
|
||||
|
||||
## The second door: two apps, one instance
|
||||
|
||||
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
|
||||
database, one login, one set of links between notes and tasks — and two things you
|
||||
can install.
|
||||
|
||||
| | verdande | urd |
|
||||
|---|---|---|
|
||||
| entry | `/` | `/urd` |
|
||||
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
|
||||
| manifest `id` | `/` | `/urd` |
|
||||
| tab and mark | verdande, ᚹ | urd, ð |
|
||||
| database, account, login | the same | the same |
|
||||
|
||||
An operating system does not install a URL, it installs a **manifest**, and the
|
||||
identity of an installed app is that manifest's `id`. Two manifests on one origin
|
||||
with different ids are two apps; two URLs sharing one manifest are one app with a
|
||||
bookmark. That is the whole mechanism.
|
||||
|
||||
The shell is served per door rather than patched by JavaScript after the page
|
||||
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
|
||||
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
|
||||
the manifest's icons altogether.
|
||||
|
||||
urd's manifest is built from verdande's rather than written beside it, so the
|
||||
fields the two genuinely share — the colours, the display mode, the language —
|
||||
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
|
||||
`scope`, the two names and the icons. An icon in the built manifest that urd has no
|
||||
file for stops the server rather than being passed through, because the symptom
|
||||
would otherwise be urd's install prompt quietly showing Verdande's mark.
|
||||
|
||||
### The face is a preference, not a gate
|
||||
|
||||
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
|
||||
tab. It is kept strictly apart from the notes edition in the code, and the
|
||||
distinction is the one thing worth reading twice:
|
||||
|
||||
| | what it means | what follows |
|
||||
|---|---|---|
|
||||
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
|
||||
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
|
||||
|
||||
Conflate them and the urd face starts hiding data rather than chrome: a notebook
|
||||
would stop showing its tasks because somebody chose a different entrance. A test
|
||||
asserts the opposite directly — the urd window still gets 200 from
|
||||
`/api/v1/tasks`.
|
||||
|
||||
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
|
||||
two apps be two apps: they share an origin, so they share localStorage, cookies and
|
||||
the service worker. `sessionStorage` is per window, and an installed PWA is its own
|
||||
window. It survives a reload, which it must — after the first navigation the
|
||||
address is `/noter` and nobody passes through `/urd` again.
|
||||
|
||||
!!! note "One thing the two doors cannot have separately"
|
||||
Theme, typeface and text size are shared, because they are in the localStorage
|
||||
the origin shares. That was a deliberate choice: splitting them per face would
|
||||
mean migrating everybody's stored settings for a difference few people want.
|
||||
|
||||
## Moving between the two, measured
|
||||
|
||||
A `/data` directory moves between the editions in **both directions, losslessly**.
|
||||
Same binary, same schema, same migrations — so this is not a feature that was built,
|
||||
it is a property that was checked:
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
|
||||
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
|
||||
| the same directory, back to `full` | the task is still there; so is the note |
|
||||
|
||||
The ownership stayed at the panel's service account through all three.
|
||||
|
||||
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
|
||||
getting notes **into other programs** — it writes one `.md` per note and carries the
|
||||
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
|
||||
is the right division of labour, because the database already holds those.
|
||||
|
||||
!!! note "The task rows stay"
|
||||
An instance run as `notes` still has its tasks in the database; they are simply
|
||||
not served. Harmless on a fresh instance, dead weight on a converted one — and
|
||||
worth knowing before handing a converted instance to somebody else.
|
||||
|
||||
## Why one codebase and not a fork
|
||||
|
||||
The two share everything that is hard: accounts, sessions, passkeys, sharing,
|
||||
invitations, the realtime socket, search, the backup format, the migrations. Notes
|
||||
are about a twentieth of the Go in this repository and reference no task type;
|
||||
tasks reference no note type. A fork would have duplicated the other nineteen
|
||||
twentieths in order to separate the one — and then every security fix would have
|
||||
needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 63 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
## An unknown value refuses to start
|
||||
|
||||
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
|
||||
rather than being treated as "not notes".
|
||||
|
||||
The silent direction is the dangerous one here. An operator who sets up an instance
|
||||
for notes and mistypes the value would otherwise get every task route served on it,
|
||||
with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 63 withheld routes:
|
||||
|
||||
| | what |
|
||||
|---|---|
|
||||
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||
| 2 | the Todoist and CSV imports, which create tasks |
|
||||
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||
|
||||
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||
answer, and tells the person there is nothing to do.
|
||||
|
||||
Everything else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||
- AI settings, `ask`, and the note actions
|
||||
|
||||
!!! warning "Still being decided"
|
||||
Four connectors are reachable in urd today and have not been settled either
|
||||
way, because each has a plausible notes reading and withholding them would be
|
||||
deciding what this product is rather than closing a hole: the mail-in address,
|
||||
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||
anything. The same applies to one handler rather than a route:
|
||||
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||
|
||||
## The name
|
||||
|
||||
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
|
||||
cutting marks into wood — the act of writing something down. Verdande is what is
|
||||
becoming; Urd is what has become and was kept.
|
||||
|
||||
An instance started without the task routes is a different product to the person
|
||||
using it, so it says a different name. That name is read from the configuration in
|
||||
one place (`Config.ProductName`), which is why there is no build-time flag and no
|
||||
second frontend: the frontend is byte-identical in both editions, and the server
|
||||
renames the two files a browser reads before any JavaScript runs — the HTML shell's
|
||||
title and the web manifest.
|
||||
|
||||
!!! note "The beacon counts them separately"
|
||||
Both editions report the same two values once a day — instance id and version
|
||||
— and nothing else; see [The install count](beacon.md). The edition is not in
|
||||
the payload, so the count does not distinguish them.
|
||||
+59
-1
@@ -53,7 +53,7 @@ paths:
|
||||
/api/v1/auth/setup:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance still needs its first account.
|
||||
summary: What this instance is, and whether it still needs its first account.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -64,6 +64,16 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
needs_setup: { type: boolean }
|
||||
edition:
|
||||
type: string
|
||||
enum: [full, notes]
|
||||
description: >
|
||||
`full` is tasks, projects and notes together. `notes` is
|
||||
the notes half: the task routes are not mounted at all, so
|
||||
an instance that is not a task manager cannot be asked to
|
||||
be one through its API. The interface reads this before
|
||||
anybody is signed in, because it decides what there is to
|
||||
show rather than what this person may see.
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Create the first account, which becomes the administrator.
|
||||
@@ -848,6 +858,20 @@ paths:
|
||||
project_id:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
section_id:
|
||||
type: string
|
||||
description: >
|
||||
The section the box was opened in. A "/section" in the text
|
||||
wins, because typing it is a choice and standing there is a
|
||||
circumstance.
|
||||
parent_id:
|
||||
type: string
|
||||
description: >
|
||||
Makes it a sub-task. A sub-task lives in its parent's
|
||||
project, so a "#project" on the same line is not honoured —
|
||||
it comes back as `unknown_project` rather than being
|
||||
silently dropped. Dates, labels and priority are read the
|
||||
same way as for any other line.
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
@@ -3143,6 +3167,40 @@ paths:
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/projects.zip:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every project's tasks as a CSV, in a zip.
|
||||
description: >
|
||||
One CSV per project, in the column layout Todoist writes — so the files
|
||||
import into Todoist and into anything else that reads that shape. A zip
|
||||
rather than one wide file because that is the shape on the other side,
|
||||
and because a single project can be lifted out of it without opening a
|
||||
spreadsheet. The per-project link does the same for one project; this is
|
||||
the whole account in one download.
|
||||
responses:
|
||||
"200":
|
||||
description: A zip archive.
|
||||
content:
|
||||
application/zip:
|
||||
schema: { type: string, format: binary }
|
||||
|
||||
/api/v1/export/tasks.ics:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every dated task as a calendar of VTODOs.
|
||||
description: >
|
||||
One file, not a zip: a calendar is made to hold many things, and each
|
||||
task carries its project with it, so nothing is lost by putting them
|
||||
together. Tasks without a due date are left out — a VTODO with no date
|
||||
is a line most readers drop on the floor.
|
||||
responses:
|
||||
"200":
|
||||
description: An iCalendar file.
|
||||
content:
|
||||
text/calendar:
|
||||
schema: { type: string }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
|
||||
@@ -38,6 +38,28 @@ type Config struct {
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// Edition decides which half of the program this instance serves.
|
||||
//
|
||||
// "full" is tasks, projects and notes together — what Verdande has always
|
||||
// been. "notes" mounts the notes half and the base underneath it, and leaves
|
||||
// the task routes unmounted: not hidden in the interface, absent from the
|
||||
// router, so an instance that is not a task manager cannot be asked to be one
|
||||
// through its API.
|
||||
//
|
||||
// Notes already stand nearly alone — they reference no task type anywhere in
|
||||
// the store, and the one link between the halves is a `note_links` row whose
|
||||
// kind happens to be "task". What is NOT separable is everything underneath
|
||||
// both: accounts, sessions, invites, sharing, notifications, attachments,
|
||||
// search. Hence one program with two faces rather than two programs: the
|
||||
// alternative is two copies of the base, and two copies of a thing is how the
|
||||
// copies start to disagree.
|
||||
//
|
||||
// Projects stay mounted in both. In the notes edition a project is a
|
||||
// notebook — the same column, the same sharing, a different word — because a
|
||||
// second grouping concept beside `note_shares` would be a second thing to keep
|
||||
// in step for a difference nobody can see.
|
||||
Edition string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
|
||||
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
|
||||
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
|
||||
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
|
||||
// som ikke burde.
|
||||
switch c.Edition {
|
||||
case EditionFull, EditionNotes:
|
||||
case "":
|
||||
c.Edition = EditionFull
|
||||
default:
|
||||
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
// The two faces of the program. Strings rather than a bool, because a bool named
|
||||
// `NotesOnly` answers one question and this one will be asked again — and a
|
||||
// third edition would mean changing every reading of it rather than adding a case.
|
||||
const (
|
||||
EditionFull = "full"
|
||||
EditionNotes = "notes"
|
||||
)
|
||||
|
||||
// NotesOnly is the one question the router asks. Written as a method so the
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
|
||||
// ProductName and ProductSlug are the two forms of the name the running instance
|
||||
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
|
||||
// manifest.
|
||||
//
|
||||
// Two editions of one program, two names — which is the whole reason the name is
|
||||
// read from the configuration rather than written into the templates. Verdande is
|
||||
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
|
||||
// and kept. An instance started without the task routes is a different product to
|
||||
// whoever uses it, and a product that calls itself by its sibling's name is not a
|
||||
// product, it is a flag somebody set.
|
||||
//
|
||||
// The lowercase form is deliberate and not a styling accident: the wordmark, the
|
||||
// repository and the window title are all lowercase, so the slug is the name and
|
||||
// ProductName is the one that gets a capital because it stands in prose.
|
||||
func (c *Config) ProductName() string {
|
||||
if c.NotesOnly() {
|
||||
return "Urd"
|
||||
}
|
||||
return "Verdande"
|
||||
}
|
||||
|
||||
// ProductSlug is ProductName lowercased, kept as its own method rather than as
|
||||
// strings.ToLower at the call sites: the two are the same transformation today and
|
||||
// a name that does not lowercase cleanly would otherwise be a bug in every caller
|
||||
// at once.
|
||||
func (c *Config) ProductSlug() string {
|
||||
if c.NotesOnly() {
|
||||
return "urd"
|
||||
}
|
||||
return "verdande"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
|
||||
// edition. That is the silent direction of this fault: an instance set up to be a
|
||||
// notes program would serve every task route, and the only way to notice would be
|
||||
// to spot that something works which should not.
|
||||
func TestUnknownEditionIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string // the edition we expect, or "" when Load must fail
|
||||
}{
|
||||
{"", EditionFull}, // unset is the full program, as it has always been
|
||||
{EditionFull, EditionFull},
|
||||
{EditionNotes, EditionNotes},
|
||||
{"note", ""}, // a letter short
|
||||
{"Notes", ""}, // the right word, the wrong case
|
||||
{"none", ""}, // plausible and meaning nothing
|
||||
} {
|
||||
t.Setenv("VERDANDE_EDITION", tc.value)
|
||||
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
|
||||
|
||||
got, err := Load()
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
|
||||
continue
|
||||
}
|
||||
if got.Edition != tc.want {
|
||||
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
|
||||
}
|
||||
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
|
||||
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||
// instans", så den følger med uden en ekstra tur.
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Edition string `json:"edition"`
|
||||
}{n == 0, s.edition()})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
|
||||
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "github.com/kristianwind/verdande/internal/config"
|
||||
|
||||
// edition is what this instance tells the world it is.
|
||||
//
|
||||
// Never the empty string, even when the config was built by hand in a test and
|
||||
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||
// full program" from "this build forgot to say", and the first of those is the
|
||||
// answer that has always been true.
|
||||
func (s *Server) edition() string {
|
||||
if s.cfg.NotesOnly() {
|
||||
return config.EditionNotes
|
||||
}
|
||||
return config.EditionFull
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The notes edition is a different program, and this is what makes that true.
|
||||
//
|
||||
// A gate in a route tree is the easiest thing in the world to believe and the
|
||||
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||||
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||||
// So this does not check that a gate is *present*. It walks both routers and
|
||||
// compares the two sets of routes they actually serve.
|
||||
//
|
||||
// The difference is written down below. A route added later that differs between
|
||||
// the editions — in either direction — fails here until somebody says which half
|
||||
// it belongs to. That is the point: the list is the decision, and the walk is
|
||||
// what keeps the decision honest.
|
||||
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
full := routesOf(t, config.EditionFull)
|
||||
notes := routesOf(t, config.EditionNotes)
|
||||
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
//
|
||||
// And note what this list could NOT catch, because it is the shape of mistake
|
||||
// that outlived the first version of this test. The list went green at 29
|
||||
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||
// exports, both task imports, the project templates, the sections and six AI
|
||||
// task endpoints were all still served by the notes edition — because the test
|
||||
// compares the router against THIS LIST, and the list was written from the
|
||||
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||
// is-it-complete check.
|
||||
//
|
||||
// So when a route is added that one edition should not have, the question to
|
||||
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||
// all without the half it is being withheld from". Three of the ones above were
|
||||
// settled by reading rather than by the name: a template turned out to create
|
||||
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/sections/{sectionID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"DELETE /api/v1/templates/{templateID}",
|
||||
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /.well-known/caldav",
|
||||
"GET /api/v1/ai/plan",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/export/projects.zip",
|
||||
"GET /api/v1/export/projects/{projectID}.csv",
|
||||
"GET /api/v1/export/projects/{projectID}.ics",
|
||||
"GET /api/v1/export/tasks.ics",
|
||||
"GET /api/v1/feed",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/projects/{projectID}/sections",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/templates",
|
||||
"GET /api/v1/upcoming",
|
||||
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /ics/{token}",
|
||||
"OPTIONS /caldav/*",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/sections/{sectionID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/ai/inbox/tidy",
|
||||
"POST /api/v1/ai/inbox/tidy/apply",
|
||||
"POST /api/v1/ai/plan/now",
|
||||
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||
"POST /api/v1/feed/rotate",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/import/csv",
|
||||
"POST /api/v1/import/todoist",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/projects/{projectID}/sections",
|
||||
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
"POST /api/v1/tasks/{taskID}/comments",
|
||||
"POST /api/v1/tasks/{taskID}/complete",
|
||||
"POST /api/v1/tasks/{taskID}/move",
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
"POST /api/v1/templates",
|
||||
"POST /api/v1/templates/{templateID}/use",
|
||||
"POST /inbound/hook/{token}",
|
||||
"PROPFIND /caldav",
|
||||
"PROPFIND /caldav/{userID}",
|
||||
"PROPFIND /caldav/{userID}/{projectID}",
|
||||
"PUT /api/v1/ai/plan",
|
||||
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"REPORT /caldav/{userID}/{projectID}",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
|
||||
var unexpected, stillThere []string
|
||||
for r := range full {
|
||||
if notes[r] {
|
||||
if withheld[r] {
|
||||
stillThere = append(stillThere, r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !withheld[r] {
|
||||
unexpected = append(unexpected, r)
|
||||
}
|
||||
}
|
||||
// And the other direction: the notes edition must not grow a route the full
|
||||
// one lacks. A gate that adds is not a gate either.
|
||||
var onlyInNotes []string
|
||||
for r := range notes {
|
||||
if !full[r] {
|
||||
onlyInNotes = append(onlyInNotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unexpected)
|
||||
sort.Strings(stillThere)
|
||||
sort.Strings(onlyInNotes)
|
||||
for _, r := range unexpected {
|
||||
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||||
}
|
||||
for _, r := range stillThere {
|
||||
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||||
}
|
||||
for _, r := range onlyInNotes {
|
||||
t.Errorf("served only by the notes edition: %s", r)
|
||||
}
|
||||
|
||||
// Printed on the green as well as the red. "The editions differ correctly" over
|
||||
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||||
// them means anything — so the number is on screen either way.
|
||||
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||||
|
||||
// The half that stops this being a deletion rather than a gate: what the notes
|
||||
// edition is FOR has to still be there.
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"PATCH /api/v1/notes/{noteID}",
|
||||
"DELETE /api/v1/notes/{noteID}",
|
||||
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||
"POST /api/v1/notes/{noteID}/shares",
|
||||
"GET /api/v1/notes/{noteID}/shares",
|
||||
"POST /api/v1/notes/{noteID}/attachments",
|
||||
"POST /api/v1/notes/import",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/export/account",
|
||||
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routesOf builds a server at one edition and returns every route it serves.
|
||||
func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
out := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
out[method+" "+route] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(out) < 50 {
|
||||
// A walk that found almost nothing would make every comparison below pass.
|
||||
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The interface cannot gate itself on something it is not told, and it has to be
|
||||
// told before anybody is signed in — an instance's shape is not a permission.
|
||||
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||
for _, tc := range []struct{ edition, want string }{
|
||||
{config.EditionFull, "full"},
|
||||
{config.EditionNotes, "notes"},
|
||||
{"", "full"}, // a config built by hand is the program as it has always been
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if got := body["edition"]; got != tc.want {
|
||||
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
// And the field it has always had is still there: adding one must not
|
||||
// quietly replace the answer the sign-in screen depends on.
|
||||
if _, ok := body["needs_setup"]; !ok {
|
||||
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||
// so it is the one door that could still open onto the half the router just shut.
|
||||
//
|
||||
// Not a theoretical case. An instance converted from a full one still has the
|
||||
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want int
|
||||
}{
|
||||
{config.EditionFull, 1},
|
||||
{config.EditionNotes, 0},
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
userID, _ := me["id"].(string)
|
||||
if userID == "" {
|
||||
t.Fatalf("no user: %v", me)
|
||||
}
|
||||
|
||||
// Created through the store rather than the API: in the notes edition there
|
||||
// is no route to create one, and the row this is about is the row that is
|
||||
// already there.
|
||||
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||
found, _ := body["tasks"].([]any)
|
||||
if len(found) != tc.want {
|
||||
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||
}
|
||||
// The full edition's number is the control: a search that finds nothing at
|
||||
// all would satisfy the notes case for the wrong reason.
|
||||
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||
//
|
||||
// The same hole as the routes, through a different door — and it was still open
|
||||
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||
// to a notes instance would be told it can create_task, call it, get something
|
||||
// back, and tell the person their task was filed.
|
||||
//
|
||||
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||
// what a client actually sees, and the slice is unexported for the same reason.
|
||||
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||
|
||||
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
names := mcpToolNames(t, edition)
|
||||
|
||||
// Both editions: the shared tool, and the notes half.
|
||||
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||
if !names[want] {
|
||||
t.Errorf("%s edition does not offer %s", edition, want)
|
||||
}
|
||||
}
|
||||
for _, task := range taskTools {
|
||||
switch edition {
|
||||
case config.EditionFull:
|
||||
if !names[task] {
|
||||
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||
}
|
||||
default:
|
||||
if names[task] {
|
||||
t.Errorf("the notes edition offers %s over MCP", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d tools", edition, len(names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if resp == nil {
|
||||
t.Fatal("tools/list got no response")
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct{ Name string } `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||
}
|
||||
if len(parsed.Result.Tools) == 0 {
|
||||
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllCSV is every project at once, one CSV per project in a zip.
|
||||
//
|
||||
// A zip rather than one wide CSV with a project column: Todoist's own export is a
|
||||
// file per project, so that is the shape an importer on the other side expects —
|
||||
// and the shape somebody can pick a single project out of without opening a
|
||||
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
|
||||
// rather than one long file.
|
||||
//
|
||||
// It exists because the per-project links are per project: fifteen projects meant
|
||||
// fifteen downloads to move your own tasks somewhere else, which is an export that
|
||||
// technically works and nobody uses.
|
||||
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Two projects can share a name — they are still two projects, and a zip with
|
||||
// one entry written twice loses one of them. Same guard as the notes export.
|
||||
used := map[string]int{}
|
||||
for _, project := range projects {
|
||||
exported, err := s.buildExport(r, user, &project)
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
|
||||
// better than a complete-looking file with a project missing from it.
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
|
||||
if err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllICS is every dated task in one calendar.
|
||||
//
|
||||
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
|
||||
// many things and every reader takes a file with hundreds of VTODOs. The project
|
||||
// travels with each task as its own field, so nothing is lost by merging them.
|
||||
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range projects {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
|
||||
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||
// takes, rebuilding the task tree from parent ids.
|
||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||
|
||||
@@ -23,7 +23,9 @@ import (
|
||||
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
||||
// and a description that only names the endpoint gives it nothing to decide with.
|
||||
func (s *Server) buildMCP() *mcp.Server {
|
||||
m := mcp.NewServer("verdande", "1")
|
||||
// Udgavens eget navn: det er sådan, instansen står i klientens
|
||||
// forbindelsesliste.
|
||||
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "list_projects",
|
||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}),
|
||||
}, s.mcpListProjects)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||
if !s.cfg.NotesOnly() {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
}
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
|
||||
+250
-79
@@ -3,6 +3,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -77,6 +78,20 @@ type Server struct {
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
// The shell and the manifest with this edition's name in them, or nil to serve
|
||||
// the built files unchanged. Only the notes edition needs them; see shell.go.
|
||||
shell []byte
|
||||
manifest []byte
|
||||
|
||||
// Icon paths this edition serves from another file. Empty in the full edition.
|
||||
assets map[string]string
|
||||
|
||||
// The second door: /urd and its own web manifest. Built on every instance,
|
||||
// whatever the edition, because it is how the notes face is installed as its
|
||||
// own app rather than a property of the instance. See shell.go.
|
||||
urdShell []byte
|
||||
urdManifest []byte
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -86,7 +101,7 @@ type Server struct {
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
@@ -101,8 +116,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
//
|
||||
// A failure here is logged rather than fatal, and that is a judgement about
|
||||
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
|
||||
// refuses to start, because serving task routes on an instance set up without
|
||||
// them is a different kind of wrong. A shell whose title says the sibling's
|
||||
// name is cosmetic — it must be loud, and it must not take the instance down.
|
||||
// What makes it loud is not this line: the end-to-end suite reads the served
|
||||
// title, so an anchor that falls out of app.html goes red there.
|
||||
if web != nil && cfg.NotesOnly() {
|
||||
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
|
||||
if err != nil {
|
||||
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
|
||||
"edition", cfg.Edition, "err", err)
|
||||
} else {
|
||||
s.shell, s.manifest = shell, manifest
|
||||
}
|
||||
|
||||
s.assets = resolveNotesAssets(web, func(name, alias string) {
|
||||
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
|
||||
"path", name, "wanted", alias)
|
||||
})
|
||||
}
|
||||
|
||||
// The second door, on every edition. A notes instance already calls itself urd
|
||||
// at the root; the door is still built there so the two addresses behave the
|
||||
// same, and so a single test does not have to know which edition it is in.
|
||||
if web != nil {
|
||||
urdShell, urdManifest, err := urdDoorAssets(web)
|
||||
if err != nil {
|
||||
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
|
||||
} else {
|
||||
s.urdShell, s.urdManifest = urdShell, urdManifest
|
||||
}
|
||||
}
|
||||
|
||||
if s.shell != nil {
|
||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||
} else {
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
}
|
||||
// The second argument is WebAuthn's display name, not its relying party id —
|
||||
// the id comes from BaseURL. So this is what the operating system's passkey
|
||||
// prompt calls the site, and the slug keeps it identical to what every existing
|
||||
// verdande instance already registered.
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
@@ -125,13 +185,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The same question as inside /api/v1, asked again here because four of the
|
||||
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
|
||||
// hook, and the well-known path CalDAV clients discover through. They are out
|
||||
// here because their callers cannot hold a session, not because they are any
|
||||
// less about tasks — and the first gate missed all four for exactly that
|
||||
// reason. It read as complete because the /tasks tree was covered.
|
||||
full := !s.cfg.NotesOnly()
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
//
|
||||
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
|
||||
if full {
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
//
|
||||
// Withheld rather than left to answer an empty collection: the well-known
|
||||
// path is a claim that a CalDAV server is here, and a client that believes
|
||||
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
|
||||
// broken calendar server on an instance that has none and needs none. Same
|
||||
// reasoning as the /.well-known/ 404 in serveWeb.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
}
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
@@ -156,8 +234,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
//
|
||||
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
|
||||
// i indbakken — så den findes ikke i noter-udgaven.
|
||||
if full {
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
}
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
@@ -184,7 +267,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
|
||||
if full {
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
@@ -285,6 +371,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Everything past this point needs a complete login.
|
||||
r.Group(func(r chi.Router) {
|
||||
// Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i
|
||||
// fladen — fraværende fra routeren, så en instans, der ikke er et
|
||||
// opgaveprogram, heller ikke kan bedes om at være det gennem sit API.
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
@@ -294,17 +384,26 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/mcp", s.handleMCP)
|
||||
|
||||
r.Get("/today", s.handleToday)
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
if full {
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
}
|
||||
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
if full {
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
|
||||
// findes, er en indstillingsside, der tilbyder en adresse, intet
|
||||
// svarer på.
|
||||
if full {
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
}
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotifications)
|
||||
@@ -330,7 +429,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateComment)
|
||||
r.Delete("/", s.handleDeleteComment)
|
||||
})
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
if full {
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
}
|
||||
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
@@ -351,20 +452,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
if full {
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
}
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
if full {
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
}
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
@@ -449,33 +554,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
|
||||
// frem for at fejle, hvilket er den stille version af samme fejl.
|
||||
if full {
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
if full {
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
}
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||
// overskrifter i.
|
||||
if full {
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
}
|
||||
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
}
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
if full {
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
}
|
||||
r.Get("/people", s.handlePeople)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
@@ -491,7 +616,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireProject(store.RoleViewer))
|
||||
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
// Sektioner grupperer opgaver inde i et projekt, og det er
|
||||
// afgjort af skemaet frem for af navnet: `section_id` står på
|
||||
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
|
||||
// kan ikke indeholde noget.
|
||||
if full {
|
||||
r.Get("/sections", s.handleListSections)
|
||||
}
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
@@ -500,8 +631,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
if full {
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
}
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -576,32 +709,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
if full {
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -669,7 +806,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
|
||||
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -678,6 +815,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
name = "index.html"
|
||||
}
|
||||
|
||||
// The second door, before anything else touches the filesystem: /urd is not a
|
||||
// file, so the fallback below would hand back the other door's shell.
|
||||
if b := s.door(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
// This edition's own icon, where it has one. Before the Open rather than after,
|
||||
// unlike the shell below: these are real files, so there is no fallback to get
|
||||
// in the way, and resolveNotesAssets has already checked each one exists.
|
||||
if alias, ok := s.assets[name]; ok {
|
||||
name = alias
|
||||
}
|
||||
|
||||
f, err := s.web.Open(name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// A client-side route: hand back the shell and let the app route it.
|
||||
@@ -690,6 +843,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Only here is `name` settled, and that matters: every client-side route —
|
||||
// /noter and all the rest — arrives as a path with no file behind it and is
|
||||
// resolved to index.html by the fallback above. Asking before the fallback
|
||||
// would have renamed the shell for a request to "/" and handed back the built
|
||||
// one for every other route in the app.
|
||||
//
|
||||
// These two assets never reach the filesystem: they are the built files with
|
||||
// this edition's name in them, held in memory since startup. See shell.go.
|
||||
if b := s.renamed(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
// A zero modification time tells ServeContent to send no Last-Modified and
|
||||
// to answer no conditional request — right for a shell that must never be
|
||||
// cached, and the same thing the built shell gets below.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The product name is in the built frontend as a literal in four places, because
|
||||
// all four are read before any JavaScript runs: the window title and the meta
|
||||
// description in the HTML shell, and the two names in the web manifest that decide
|
||||
// what an installed PWA is called on a home screen.
|
||||
//
|
||||
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
||||
// first script executes, the manifest is fetched by the browser rather than by us,
|
||||
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
||||
// that renamed itself after loading would show the sibling's name first and correct
|
||||
// itself a moment later, on every load, in the tab and on the install prompt.
|
||||
//
|
||||
// So the server does it, once at startup, on the way out. One frontend build, two
|
||||
// names: the build is identical in both editions and the bytes served differ.
|
||||
//
|
||||
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
||||
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody files it and
|
||||
// nothing else notices.
|
||||
type rebrand struct {
|
||||
old, new string
|
||||
}
|
||||
|
||||
// shellRebrands returns the replacements for index.html.
|
||||
//
|
||||
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
||||
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
||||
// rest of the app reads the same three, so renaming them here and nowhere else
|
||||
// would set the theme from one key and read it from another, and the only symptom
|
||||
// is the white flash that script exists to prevent. This is why the replacements
|
||||
// are written out as whole anchored strings rather than as a replace of the word
|
||||
// "verdande".
|
||||
func shellRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + description + `" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
||||
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
||||
// form, so it is passed separately rather than derived.
|
||||
func manifestRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
||||
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
||||
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
||||
}
|
||||
}
|
||||
|
||||
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
||||
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
||||
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
||||
// about which program this is.
|
||||
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
||||
out := raw
|
||||
for _, p := range pairs {
|
||||
old := []byte(p.old)
|
||||
if bytes.Count(out, old) != 1 {
|
||||
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
||||
}
|
||||
out = bytes.Replace(out, old, []byte(p.new), 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
||||
// frontend and returns them renamed for this edition.
|
||||
//
|
||||
// Both or neither: an instance serving a renamed shell beside the sibling's
|
||||
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
||||
// at until it is on their home screen.
|
||||
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// The descriptions for the notes edition. Kept beside the replacements they feed
|
||||
// rather than in the locale files, because these two strings are read by a browser
|
||||
// and a home screen before any locale has been chosen — the shell is in Danish for
|
||||
// the same reason the built app.html is.
|
||||
const (
|
||||
notesShellDescription = "urd — noter, delt."
|
||||
notesManifestDescription = "Noter, delt."
|
||||
)
|
||||
|
||||
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
||||
//
|
||||
// Nil for everything in the full edition, which is what keeps that path exactly as
|
||||
// it was: one comparison per request against a nil slice.
|
||||
func (s *Server) renamed(name string) []byte {
|
||||
switch name {
|
||||
case "index.html":
|
||||
return s.shell
|
||||
case "manifest.webmanifest":
|
||||
return s.manifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFor names the type for the two assets served from memory.
|
||||
//
|
||||
// http.ServeContent sniffs from the extension, and it gets .html right and
|
||||
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
||||
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
||||
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||
// does not appear.
|
||||
func contentTypeFor(name string) string {
|
||||
// Any .webmanifest, not the one name — there are two of them now, and the
|
||||
// second one was served as HTML for about four minutes because this read
|
||||
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
|
||||
// with the wrong type is ignored silently, and the only symptom is an install
|
||||
// prompt that does not appear.
|
||||
if strings.HasSuffix(name, ".webmanifest") {
|
||||
return "application/manifest+json"
|
||||
}
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
// notesAssetAliases maps the icon paths the shell and the manifest already point
|
||||
// at to urd's own files. Both sets are in the binary; the server decides.
|
||||
//
|
||||
// Aliased rather than renamed in the manifest, and the reason is the third entry.
|
||||
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
|
||||
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
|
||||
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
|
||||
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
|
||||
// fixes all four at once, including the favicon in `app.html`, which is not in the
|
||||
// manifest either.
|
||||
var notesAssetAliases = map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}
|
||||
|
||||
// resolveNotesAssets returns the aliases that have a file behind them, and reports
|
||||
// the ones that do not.
|
||||
//
|
||||
// Resolved once at startup rather than per request, so a missing file is one loud
|
||||
// line in the log instead of a silent fallback on every page load. The fallback is
|
||||
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
|
||||
// absence turns into a broken-image placeholder on the Updates screen — which is
|
||||
// exactly how four releases of nolimit-views shipped with no icon — so serving the
|
||||
// wrong mark beats serving none.
|
||||
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, alias := range notesAssetAliases {
|
||||
if _, err := fs.Stat(web, alias); err != nil {
|
||||
missing(name, alias)
|
||||
continue
|
||||
}
|
||||
out[name] = alias
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The second door.
|
||||
//
|
||||
// One instance, one database, one login — and two things you can install. urd is
|
||||
// reached at /urd, which serves the same single-page app under its own identity:
|
||||
// its own title, its own mark, and its own web manifest.
|
||||
//
|
||||
// The manifest is the part that matters and the part that is easy to get wrong.
|
||||
// An operating system does not install a URL, it installs a *manifest*, and the
|
||||
// identity of an installed app is the manifest's `id`. Two manifests on one origin
|
||||
// with different ids are two installable apps; two URLs sharing one manifest are
|
||||
// one app with a bookmark.
|
||||
//
|
||||
// And the shell has to be served per door rather than patched by JavaScript,
|
||||
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
|
||||
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
|
||||
// manifest's icons entirely. A head rewritten after load is a head that may or may
|
||||
// not be the one iOS read.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
|
||||
// because those two being the same string is the whole mechanism.
|
||||
const urdDoorPath = "/urd"
|
||||
|
||||
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
|
||||
// this door is: what the tab says, what a crawler reads, which manifest the browser
|
||||
// fetches, and which icons it finds without the manifest.
|
||||
//
|
||||
// Note what is NOT in here, for the same reason as in shellRebrands above: the
|
||||
// three `verdande:` localStorage keys in the inline theme script. They are storage
|
||||
// rather than a name, the rest of the app reads them, and — now more than before —
|
||||
// the two doors share an origin and therefore share that storage. Renaming them
|
||||
// here would give the two apps different themes by accident and lose everybody's
|
||||
// setting on the way.
|
||||
func urdShellRebrands() []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>urd</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + notesShellDescription + `" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="manifest" href="/manifest.webmanifest" />`,
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// urdManifest builds the second door's manifest from the one the frontend build
|
||||
// produced, rather than writing a second one by hand.
|
||||
//
|
||||
// Transformed as JSON rather than by string replacement, which the shell above
|
||||
// does: a manifest needs keys the built one does not have — `id` and `start_url`
|
||||
// — so there is nothing to anchor a replacement on, and a hand-written copy would
|
||||
// drift on the fields the two genuinely share (the colours, the display mode, the
|
||||
// language).
|
||||
//
|
||||
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
|
||||
// out even though "/" is what the spec would default to from a start_url of
|
||||
// "/urd": an app whose scope did not cover the whole origin would open its own
|
||||
// links in a browser tab the first time somebody navigated out of /urd, and that
|
||||
// is a rule nobody should have to remember while reading this.
|
||||
func urdManifest(raw []byte) ([]byte, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
|
||||
}
|
||||
|
||||
m["id"] = urdDoorPath
|
||||
m["start_url"] = urdDoorPath
|
||||
m["scope"] = "/"
|
||||
m["name"] = "urd"
|
||||
m["short_name"] = "urd"
|
||||
m["description"] = notesManifestDescription
|
||||
|
||||
icons, ok := m["icons"].([]any)
|
||||
if !ok || len(icons) == 0 {
|
||||
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
icon, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is not an object", i)
|
||||
}
|
||||
src, _ := icon["src"].(string)
|
||||
// Every icon must be one this door has a file for. Refusing an unknown one
|
||||
// is the point: an icon added to the manifest and not to this map would
|
||||
// otherwise leave urd's install prompt showing Verdande's mark, which is
|
||||
// cosmetic enough that nobody files it.
|
||||
swapped, ok := map[string]string{
|
||||
"/icon.svg": "/urd-icon.svg",
|
||||
"/icon-192.png": "/urd-icon-192.png",
|
||||
"/icon-512.png": "/urd-icon-512.png",
|
||||
}[src]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
|
||||
}
|
||||
icon["src"] = swapped
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(out, '\n'), nil
|
||||
}
|
||||
|
||||
// urdDoorAssets builds the second door from the files the frontend build produced.
|
||||
//
|
||||
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
|
||||
// that manifest is not being served is an install prompt that silently falls back
|
||||
// to the other app's identity.
|
||||
func urdDoorAssets(web fs.FS) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, urdShellRebrands())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = urdManifest(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// door returns the bytes for the second door's two paths, or nil.
|
||||
//
|
||||
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
|
||||
// below: /urd is not a file and never will be, so it has to be answered before the
|
||||
// fallback to index.html gets to it — which would serve the other door's shell
|
||||
// under this door's address, with no error anywhere.
|
||||
func (s *Server) door(name string) []byte {
|
||||
switch name {
|
||||
case "urd":
|
||||
return s.urdShell
|
||||
case "urd.webmanifest":
|
||||
return s.urdManifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// serverWithWeb builds the real router around a frontend, which newTestServerWith
|
||||
// cannot do — it passes nil for the web filesystem, because every other test here
|
||||
// is about the API. The renaming only happens when there is something to rename.
|
||||
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
Edition: edition,
|
||||
}
|
||||
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
|
||||
}
|
||||
|
||||
// The shell as the frontend build produces it, cut down to the parts that matter
|
||||
// here: the two anchors that get renamed, and the inline theme script that must
|
||||
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
|
||||
// honest against the real file.
|
||||
const builtShell = `<!doctype html>
|
||||
<html lang="da" data-theme="dark">
|
||||
<head>
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('verdande:theme');
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const builtManifest = `{
|
||||
"name": "verdande",
|
||||
"short_name": "verdande",
|
||||
"description": "Opgaver og projekter, delt.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111312",
|
||||
"theme_color": "#111312",
|
||||
"lang": "da",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
func builtWeb() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
"_app/immutable/app.js": {Data: []byte("// app")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
|
||||
//
|
||||
// The product name and the localStorage prefix are the same word in app.html, and
|
||||
// they are not the same thing: the prefix is read back by the app itself, so
|
||||
// renaming it here and nowhere else sets the theme from one key and reads it from
|
||||
// another. The symptom is the white flash on load that the inline script exists to
|
||||
// prevent — which is to say, no error, nothing in a log, and nothing a test that
|
||||
// merely checked the title would see.
|
||||
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
|
||||
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
got := string(shell)
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`content="urd — noter, delt."`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// Named values rather than "no occurrences of verdande": the three keys are
|
||||
// what has to survive, so the assertion says the three keys.
|
||||
for _, want := range []string{
|
||||
`localStorage.getItem('verdande:theme')`,
|
||||
`localStorage.getItem('verdande:look')`,
|
||||
`look !== 'verdande'`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
|
||||
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
|
||||
}
|
||||
|
||||
gotManifest := string(manifest)
|
||||
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
|
||||
if !strings.Contains(gotManifest, want) {
|
||||
t.Errorf("the manifest does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
|
||||
// files that is easy to break and impossible to notice: the policy names a hash of
|
||||
// the inline script, and the renamed shell is what gets served. If a replacement
|
||||
// ever reached inside the <script>, the hash would change, the browser would refuse
|
||||
// to run it, and the page would be blank — in production only, because the policy
|
||||
// is not enforced for a page opened from a file.
|
||||
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
|
||||
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
before := scriptHashesIn([]byte(builtShell))
|
||||
after := scriptHashesIn(shell)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
|
||||
}
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
|
||||
// happy path. A replacement that silently matched nothing leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
|
||||
// rewrite has to say so rather than return what it managed.
|
||||
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
|
||||
cases := map[string]fstest.MapFS{
|
||||
"no title": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no description": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest name": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
|
||||
},
|
||||
"no index.html at all": {
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest at all": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
},
|
||||
}
|
||||
for name, web := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
|
||||
t.Error("renaming reported success over a shell it could not rename")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
|
||||
// frozen copy of a file that has moved on.
|
||||
//
|
||||
// The anchors are exact strings in web/src/app.html, and that file is edited for
|
||||
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
|
||||
// title line or rewrites the description breaks the rename and nothing in Go would
|
||||
// know, because the Go build graph has never heard of app.html.
|
||||
//
|
||||
// Note the caching trap that comes with reading a non-Go file from a test: `go
|
||||
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
|
||||
// both workflows; a local run needs it typed.
|
||||
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../web/src/app.html")
|
||||
if err != nil {
|
||||
t.Skipf("app.html is not reachable from here: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
|
||||
// Both sets: the edition's rename and the second door's. The door was added
|
||||
// after this test and its anchors were NOT covered, which is how its fixture
|
||||
// came to be missing two <link> lines the real file has — caught only because
|
||||
// applyRebrands refuses an anchor it cannot find.
|
||||
for what, pairs := range map[string][]rebrand{
|
||||
"the notes edition's rename": shellRebrands("urd", "d"),
|
||||
"the urd door": urdShellRebrands(),
|
||||
} {
|
||||
for _, pair := range pairs {
|
||||
if n := strings.Count(src, pair.old); n != 1 {
|
||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
|
||||
n, pair.old, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||
// somebody would rename them, and if they are renamed there they must be
|
||||
// renamed in stores.svelte.js in the same commit.
|
||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||
}
|
||||
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
|
||||
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
|
||||
}
|
||||
|
||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||
// is about the renamed path; this says the other edition does not go through it.
|
||||
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
|
||||
for _, edition := range []string{"full", "notes"} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, edition, builtWeb())
|
||||
if edition == "full" {
|
||||
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
|
||||
t.Error("the full edition renamed the shell")
|
||||
}
|
||||
return
|
||||
}
|
||||
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
|
||||
t.Fatal("the notes edition did not rename the shell")
|
||||
}
|
||||
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
|
||||
t.Error("the notes edition's shell does not say urd")
|
||||
}
|
||||
if srv.renamed("_app/immutable/app.js") != nil {
|
||||
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
|
||||
func webWithIcons() fstest.MapFS {
|
||||
web := builtWeb()
|
||||
for _, name := range []string{
|
||||
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
|
||||
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
|
||||
} {
|
||||
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
|
||||
}
|
||||
return web
|
||||
}
|
||||
|
||||
// TestEachEditionServesItsOwnMark.
|
||||
//
|
||||
// Four paths, named one by one rather than counted: the favicon in app.html, the
|
||||
// manifest's two, and the one Safari looks for by name. The last is the reason
|
||||
// this is an alias rather than a rewritten manifest — Safari does not read the
|
||||
// manifest at all.
|
||||
func TestEachEditionServesItsOwnMark(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want map[string]string
|
||||
}{
|
||||
{"notes", map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}},
|
||||
// The control, and it is the half that stops this being a swap rather than
|
||||
// a choice: the full edition must resolve nothing.
|
||||
{"full", map[string]string{}},
|
||||
} {
|
||||
t.Run(tc.edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, tc.edition, webWithIcons())
|
||||
if len(srv.assets) != len(tc.want) {
|
||||
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
|
||||
}
|
||||
for path, alias := range tc.want {
|
||||
if srv.assets[path] != alias {
|
||||
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
|
||||
}
|
||||
}
|
||||
// And nothing else: an alias on a build asset would serve one file's
|
||||
// bytes under another's hashed, immutable name.
|
||||
for path := range srv.assets {
|
||||
if _, ok := tc.want[path]; !ok {
|
||||
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMissingMarkIsReportedAndFallsBack.
|
||||
//
|
||||
// The fallback is deliberate and it is the only place in this file that prefers
|
||||
// carrying on to failing: an icon's absence is a broken-image placeholder on the
|
||||
// one screen somebody reads before deciding whether to press update, so the wrong
|
||||
// mark beats no mark. What must not happen is it being quiet.
|
||||
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
|
||||
web := webWithIcons()
|
||||
delete(web, "urd-icon-512.png")
|
||||
|
||||
var reported []string
|
||||
got := resolveNotesAssets(web, func(name, alias string) {
|
||||
reported = append(reported, name+" → "+alias)
|
||||
})
|
||||
|
||||
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
|
||||
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
|
||||
}
|
||||
if _, ok := got["icon-512.png"]; ok {
|
||||
t.Error("a path with no file behind it was aliased anyway")
|
||||
}
|
||||
if got["icon.svg"] != "urd-icon.svg" {
|
||||
t.Error("one missing icon took the other three with it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheUrdDoorIsItsOwnApp.
|
||||
//
|
||||
// An operating system does not install a URL, it installs a manifest, and the
|
||||
// identity of an installed app is that manifest's `id`. So this asserts the two
|
||||
// things that decide whether /urd becomes a second icon on a home screen or just a
|
||||
// bookmark into the first one — and it asserts them as named values, because an
|
||||
// empty manifest, verdande's manifest and urd's all parse as JSON.
|
||||
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
|
||||
srv := serverWithWeb(t, "full", webWithIcons())
|
||||
|
||||
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
|
||||
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
|
||||
}
|
||||
|
||||
shell := string(srv.door("urd"))
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
// iOS reads this one by name and has historically ignored the manifest's
|
||||
// icons entirely, which is why the shell is served per door rather than
|
||||
// patched by script after load.
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
} {
|
||||
if !strings.Contains(shell, want) {
|
||||
t.Errorf("the urd door's shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// The same trap as the edition shell: the two doors share an origin and
|
||||
// therefore share localStorage, so renaming a storage key here would give the
|
||||
// two apps different themes by accident.
|
||||
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
|
||||
t.Fatalf("the urd manifest is not JSON: %v", err)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
// id is the install identity. Without it, two manifests on one origin are
|
||||
// one app with two start urls.
|
||||
"id": "/urd",
|
||||
"start_url": "/urd",
|
||||
"scope": "/",
|
||||
"name": "urd",
|
||||
"short_name": "urd",
|
||||
} {
|
||||
if m[key] != want {
|
||||
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
|
||||
}
|
||||
}
|
||||
// And the shared half, to prove it was transformed rather than written out: a
|
||||
// hand-written copy would drift on exactly these.
|
||||
var built map[string]any
|
||||
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
|
||||
_ = json.Unmarshal(raw, &built)
|
||||
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
|
||||
if _, ok := built[key]; ok && m[key] != built[key] {
|
||||
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
|
||||
}
|
||||
}
|
||||
|
||||
icons, _ := m["icons"].([]any)
|
||||
if len(icons) == 0 {
|
||||
t.Fatal("the urd manifest has no icons")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
src, _ := entry.(map[string]any)["src"].(string)
|
||||
if !strings.HasPrefix(src, "/urd-") {
|
||||
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
|
||||
}
|
||||
}
|
||||
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
|
||||
}
|
||||
|
||||
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
|
||||
// added to the built manifest and not to urd's map would otherwise leave urd's
|
||||
// install prompt showing Verdande's mark, which nobody reports.
|
||||
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
|
||||
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
|
||||
if _, err := urdManifest(raw); err == nil {
|
||||
t.Error("urdManifest accepted an icon it has no file for")
|
||||
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
|
||||
t.Errorf("the error does not name the icon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
|
||||
// by the browser with no error anywhere; the only symptom is that the install
|
||||
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
|
||||
// second door was added.
|
||||
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
|
||||
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
|
||||
if got := contentTypeFor(name); got != "application/manifest+json" {
|
||||
t.Errorf("%s is served as %q", name, got)
|
||||
}
|
||||
}
|
||||
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
|
||||
t.Errorf("index.html is served as %q", got)
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,18 @@ type quickAddRequest struct {
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
|
||||
// ParentID makes the new task a sub-task of an existing one. It is what a
|
||||
// pasted list uses for its indented lines, and what the sub-task field at the
|
||||
// foot of a task uses — both want the same parsing of dates, #project and
|
||||
// @labels that a top-level task gets, so they come through here rather than
|
||||
// through a second, dumber path.
|
||||
//
|
||||
// A sub-task lives in its parent's project. Saying otherwise would describe a
|
||||
// task whose parent is somewhere else, which the list views cannot draw and
|
||||
// nobody means; a "#project" on an indented line is therefore not honoured,
|
||||
// and the caller is told so the word does not just vanish.
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -517,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A parent decides the project before anything else is read: a sub-task
|
||||
// somewhere other than its parent is not a shape this program has.
|
||||
parent := (*store.Task)(nil)
|
||||
if req.ParentID != "" {
|
||||
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
parent = found
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
@@ -532,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
// The parent has the last word, and the word it overrules is reported the same
|
||||
// way an unknown project is — the task is created either way.
|
||||
if parent != nil {
|
||||
if parsed.Project != "" && projectID != parent.ProjectID {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
projectID = parent.ProjectID
|
||||
}
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
@@ -570,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parent != nil {
|
||||
t.ParentID = parent.ID
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
if err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
|
||||
}
|
||||
|
||||
// The whole point of import and export together: what goes in comes back out.
|
||||
|
||||
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
|
||||
//
|
||||
// The line still goes through the same parser as a top-level one — a sub-task
|
||||
// with a date is an ordinary thing to paste, and a second, dumber creation path
|
||||
// for indented lines would be a second place for "i morgen" to mean something
|
||||
// different.
|
||||
func TestQuickAddSubTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Flytte garagen",
|
||||
})
|
||||
parentID, _ := parent["id"].(string)
|
||||
if parentID == "" {
|
||||
t.Fatalf("no parent: %v", parent)
|
||||
}
|
||||
|
||||
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Rydde hylderne p1", "parent_id": parentID,
|
||||
})
|
||||
if got := child["parent_id"]; got != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", got, parentID)
|
||||
}
|
||||
// The line was parsed, not stored raw: the priority marker is gone from the
|
||||
// text and present as a priority.
|
||||
if got := child["content"]; got != "Rydde hylderne" {
|
||||
t.Errorf("content = %v, want the line without its marker", got)
|
||||
}
|
||||
if got := child["priority"]; got != float64(1) {
|
||||
t.Errorf("priority = %v, want 1", got)
|
||||
}
|
||||
|
||||
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
|
||||
otherID, _ := other["id"].(string)
|
||||
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
|
||||
})
|
||||
if got := moved["project_id"]; got == otherID {
|
||||
t.Errorf("the sub-task left its parent's project")
|
||||
}
|
||||
// And it is said out loud rather than the word just disappearing from the
|
||||
// title — the failure this reports is exactly the one that used to be silent.
|
||||
if got := moved["unknown_project"]; got != "Andetsteds" {
|
||||
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// raw fetches a download — a zip or a calendar — as bytes.
|
||||
//
|
||||
// `do` decodes JSON, which these two are not: it would report an empty body and a
|
||||
// parse error where the interesting thing is the bytes themselves.
|
||||
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body, resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
// TestExportEverythingAtOnce covers the two whole-account exports.
|
||||
//
|
||||
// The per-project links worked and were unusable at any size: fifteen projects
|
||||
// meant fifteen downloads to move your own tasks somewhere else. These two are
|
||||
// the same data without the fifteen clicks.
|
||||
//
|
||||
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
|
||||
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
|
||||
// exactly the shape an export bug takes.
|
||||
func TestExportEverythingAtOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Two projects, so the zip has to hold more than the one that happens to be
|
||||
// first — a loop that exports only the last project passes a one-project test.
|
||||
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
|
||||
firstID, _ := first["id"].(string)
|
||||
secondID, _ := second["id"].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Skifte daek", "project_id": secondID,
|
||||
})
|
||||
|
||||
t.Run("csv zip holds one file per project", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
|
||||
if ct != "application/zip" {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a zip: %v", err)
|
||||
}
|
||||
|
||||
found := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
found[f.Name] = string(text)
|
||||
}
|
||||
if len(found) < 2 {
|
||||
t.Fatalf("want a file per project, got %d: %v", len(found), found)
|
||||
}
|
||||
|
||||
// The task has to be IN the file named after its own project. A zip with the
|
||||
// right filenames and every task in the wrong one is the failure this is for.
|
||||
var sawMoms, sawDaek bool
|
||||
for name, text := range found {
|
||||
// Filenames are lower-cased by ics.Filename, so the comparison is too —
|
||||
// asserting on the stored capitalisation would be asserting on a detail
|
||||
// the exporter is free to change.
|
||||
lower := strings.ToLower(name)
|
||||
if strings.Contains(text, "Betal moms") {
|
||||
sawMoms = true
|
||||
if !strings.Contains(lower, "firma") {
|
||||
t.Errorf("Betal moms landed in %q, not in Firma", name)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
sawDaek = true
|
||||
if !strings.Contains(lower, "hjem") {
|
||||
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawMoms || !sawDaek {
|
||||
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ics holds every dated task across projects", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
|
||||
if !strings.HasPrefix(ct, "text/calendar") {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
text := string(body)
|
||||
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
|
||||
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
|
||||
}
|
||||
if !strings.Contains(text, "Betal moms") {
|
||||
t.Errorf("the dated task is missing:\n%s", text)
|
||||
}
|
||||
// Undated tasks are deliberately left out — a VTODO with no date is a line
|
||||
// most readers drop. Asserted so the omission stays a decision.
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
t.Errorf("an undated task was exported:\n%s", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||
// ind til den halvdel, resten af programmet lige har lukket.
|
||||
var tasks []store.Task
|
||||
var err error
|
||||
if !s.cfg.NotesOnly() {
|
||||
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
|
||||
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *store.DB) *store.User {
|
||||
|
||||
+19
-13
@@ -24,13 +24,19 @@ import (
|
||||
type Sender struct {
|
||||
cfg config.SMTP
|
||||
log *slog.Logger
|
||||
// appName and baseURL appear in the body of every message.
|
||||
// appName and baseURL appear in every message. appName is the edition's own
|
||||
// name — an invite from an urd instance that said "verdande" would be a letter
|
||||
// to somebody else's inbox about a program they are not being invited to.
|
||||
//
|
||||
// It was a field with a value and no reader until 2026-10-07: the three places
|
||||
// that needed it had the word written out instead, and the comment above said
|
||||
// it appeared in every message. It does now.
|
||||
appName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
|
||||
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
|
||||
}
|
||||
|
||||
// Configured reports whether mail can actually be sent. Handlers use it to decide
|
||||
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||
what := "verdande"
|
||||
what := s.appName
|
||||
if projectName != "" {
|
||||
what = "projektet “" + projectName + "”"
|
||||
}
|
||||
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
|
||||
}
|
||||
|
||||
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
|
||||
body := fmt.Sprintf(`Hej
|
||||
|
||||
%s har inviteret dig til %s.
|
||||
@@ -71,9 +77,9 @@ Opret din konto her:
|
||||
|
||||
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
|
||||
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
|
||||
|
||||
%s
|
||||
|
||||
— verdande
|
||||
`, name, task, link)
|
||||
— %s
|
||||
`, name, task, link, s.appName)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
||||
subject := "Nulstil din adgangskode i verdande"
|
||||
subject := "Nulstil din adgangskode i " + s.appName
|
||||
body := fmt.Sprintf(`Hej %s
|
||||
|
||||
Der er bedt om at nulstille adgangskoden til din verdande-konto.
|
||||
Der er bedt om at nulstille adgangskoden til din %s-konto.
|
||||
|
||||
Vælg en ny adgangskode her:
|
||||
%s
|
||||
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
|
||||
|
||||
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, name, link, humanDuration(ttl), s.baseURL)
|
||||
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// TestEveryMessageCarriesTheEditionsOwnName.
|
||||
//
|
||||
// These three letters are the only thing this program sends to an address its
|
||||
// operator does not control, and an invitation from an urd instance that says
|
||||
// "verdande" is a letter to a stranger about a program they are not being invited
|
||||
// to. The name was a field with a value and no reader until 2026-10-07 — the three
|
||||
// places that needed it had the word written out instead — so the assertion that
|
||||
// matters is not "it says urd" but "it does not say the other one".
|
||||
//
|
||||
// Measured through the unconfigured path on purpose: with no SMTP host, send()
|
||||
// logs the whole message at warn level and returns, so the subject and the body
|
||||
// can be read as the recipient would get them without a mail server in the test.
|
||||
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
|
||||
for _, tc := range []struct{ name, other string }{
|
||||
{"verdande", "urd"},
|
||||
{"urd", "verdande"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", tc.name,
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendPasswordReset: %v", err)
|
||||
}
|
||||
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
|
||||
t.Fatalf("SendReminder: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
|
||||
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
|
||||
}
|
||||
if !strings.Contains(got, tc.name) {
|
||||
t.Errorf("no message mentions %q", tc.name)
|
||||
}
|
||||
if strings.Contains(got, tc.other) {
|
||||
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
|
||||
}
|
||||
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
|
||||
// *subject* of the sentence rather than a signature: an invitation to the instance
|
||||
// itself, with no project, reads "has invited you to <program>".
|
||||
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", "urd",
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "inviteret dig til urd") {
|
||||
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Two editions: editions.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* Noter-udgaven, kørt som sit eget program.
|
||||
*
|
||||
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
|
||||
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
|
||||
* opsætning lavede, så den laver sin egen.
|
||||
*
|
||||
* Og det er hele grunden til at prøven findes som en separat server frem for som
|
||||
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
|
||||
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
|
||||
* bruger møder det.
|
||||
*/
|
||||
|
||||
const KONTO = {
|
||||
email: 'noter@example.dk',
|
||||
name: 'Noteskriver',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
|
||||
// så den anden gang er det en almindelig login.
|
||||
const opret = page.getByRole('button', { name: 'Opret konto' });
|
||||
const login = page.getByRole('button', { name: 'Log ind' });
|
||||
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
|
||||
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
|
||||
// tredive sekunder på en knap, der aldrig kom.
|
||||
await expect(opret.or(login)).toBeVisible();
|
||||
|
||||
if (await opret.isVisible()) {
|
||||
await page.getByLabel(/Navn/).fill(KONTO.name);
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await opret.click();
|
||||
// Beaconens besked står øverst første gang; den er ikke det her prøvens
|
||||
// ærinde, men den dækker for det, der er.
|
||||
const besked = page.getByRole('region', { name: /melder, at den findes/ });
|
||||
if (await besked.isVisible().catch(() => false)) {
|
||||
await besked.getByRole('button', { name: 'Behold den' }).click();
|
||||
}
|
||||
} else {
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await page.getByRole('button', { name: 'Log ind' }).click();
|
||||
}
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
|
||||
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
|
||||
// det at vide, før nogen er logget ind.
|
||||
const svar = await page.request.get('/api/v1/auth/setup');
|
||||
expect((await svar.json()).edition).toBe('notes');
|
||||
});
|
||||
|
||||
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
|
||||
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
|
||||
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
|
||||
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
|
||||
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
|
||||
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
});
|
||||
|
||||
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
|
||||
page
|
||||
}) => {
|
||||
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
|
||||
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
|
||||
// andet program end det, der kører her.
|
||||
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
|
||||
await page.goto(sti);
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
await page.keyboard.type('Kvartalsregnskabet');
|
||||
await felt.blur();
|
||||
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
|
||||
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
|
||||
|
||||
// Noten findes — ellers beviser det næste ingenting.
|
||||
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
|
||||
// Og søgningen på serveren svarer uden opgaver overhovedet.
|
||||
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
|
||||
expect((await svar.json()).tasks ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
|
||||
// Det her er forskellen på skjult og fraværende, målt udefra.
|
||||
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
|
||||
const svar = await page.request.get(sti);
|
||||
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
|
||||
}
|
||||
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
|
||||
// ovenstående bestå på en server, der var helt væk.
|
||||
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
|
||||
});
|
||||
|
||||
/**
|
||||
* Navnet.
|
||||
*
|
||||
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
|
||||
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
|
||||
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
|
||||
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
|
||||
*/
|
||||
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
|
||||
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
|
||||
// som browseren læser før noget JavaScript kører og som serveren skriver om,
|
||||
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
|
||||
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
|
||||
// over en fane, der sagde "Noter · verdande".
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
|
||||
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
|
||||
|
||||
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
|
||||
await expect(mærke.locator('.name')).toHaveText('urd');
|
||||
// ð, ikke ᚹ wunjo og ikke ᚢ uruz. Begge de to er navngivne værdier frem for
|
||||
// "der står noget": en tom span, Verdandes mærke og uruz-appens ser ens ud for
|
||||
// en prøve, der kun spørger, om feltet er udfyldt.
|
||||
await expect(mærke.locator('.rune')).toHaveText('ð');
|
||||
await expect(mærke.locator('.rune')).not.toHaveText('ᚢ');
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('urd');
|
||||
expect(manifest.short_name).toBe('urd');
|
||||
});
|
||||
|
||||
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
|
||||
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
|
||||
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
|
||||
// Symptomet er at installationsknappen bare ikke er der.
|
||||
const svar = await page.request.get('/manifest.webmanifest');
|
||||
expect(svar.headers()['content-type']).toContain('application/manifest+json');
|
||||
});
|
||||
|
||||
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
|
||||
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
|
||||
// i app.html, og de er ikke samme ting. Omdøbes præfikset her og ikke i
|
||||
// resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det
|
||||
// eneste symptom er det hvide blink, det indlejrede script findes for at
|
||||
// undgå. Målt på de bytes der blev sendt, ikke på kilden.
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal).toContain("localStorage.getItem('verdande:theme')");
|
||||
expect(skal).toContain("localStorage.getItem('verdande:look')");
|
||||
expect(skal).not.toContain('urd:theme');
|
||||
});
|
||||
|
||||
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
|
||||
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
|
||||
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
|
||||
// skifter navn med udgaven.
|
||||
await page.goto('/indstillinger');
|
||||
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
|
||||
*
|
||||
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
|
||||
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
|
||||
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
|
||||
* manglede. Der er ingen fejlskærm at se.
|
||||
*
|
||||
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
|
||||
* sektion, der ikke var noget i.
|
||||
*
|
||||
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
|
||||
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
|
||||
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
|
||||
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
|
||||
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
|
||||
* notesbogens noter.
|
||||
*
|
||||
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
|
||||
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
|
||||
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
|
||||
*
|
||||
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
|
||||
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
|
||||
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
|
||||
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
|
||||
* ikke "ingen flade".
|
||||
*/
|
||||
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
|
||||
// og den er den eneste side med en id i stien, der kan nås.
|
||||
await page.goto('/noter');
|
||||
const projekter = await (await page.request.get('/api/v1/projects')).json();
|
||||
const id = (projekter.projects ?? projekter)[0]?.id;
|
||||
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
|
||||
|
||||
const sider = [
|
||||
'/noter',
|
||||
'/genveje',
|
||||
'/indstillinger',
|
||||
'/indstillinger/ai',
|
||||
'/indstillinger/brugere',
|
||||
'/indstillinger/data',
|
||||
'/indstillinger/fejl',
|
||||
'/indstillinger/historik',
|
||||
'/indstillinger/integrationer',
|
||||
'/indstillinger/notifikationer',
|
||||
'/indstillinger/tokens',
|
||||
`/projekt/${id}`
|
||||
];
|
||||
|
||||
for (const side of sider) {
|
||||
await page.goto(side);
|
||||
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
|
||||
// nået at komme afsted. Uden den her går løkken videre, før svarene er
|
||||
// kommet, og prøven måler ingenting.
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
|
||||
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
|
||||
});
|
||||
|
||||
test('ikonet er urds, hele vejen ud', async ({ page }) => {
|
||||
// Målt på stregerne i SVG'en frem for på "der kom et billede": en tom
|
||||
// respons, verdandes mærke og urds ser alle ens ud for en prøve, der kun
|
||||
// spørger, om der står noget.
|
||||
const svg = await (await page.request.get('/icon.svg')).text();
|
||||
expect(svg, 'ð: skålen').toContain('cx="32" cy="42" r="12"');
|
||||
expect(svg, 'ð: ascenderen').toContain('M44 36L27 10');
|
||||
expect(svg, 'ð: tværstregen, som er det der skiller ð fra et d').toContain('M25 20h18');
|
||||
expect(svg, 'og ikke ᚹ wunjos flag').not.toContain('M26 15l17');
|
||||
|
||||
// De tre PNG'er har ingen streger at læse, så de måles på at de er urds —
|
||||
// sammenlignet med filen under sit eget navn, som ligger i samme byg.
|
||||
for (const [sti, egen] of [
|
||||
['/icon-192.png', '/urd-icon-192.png'],
|
||||
['/icon-512.png', '/urd-icon-512.png'],
|
||||
['/apple-touch-icon.png', '/urd-apple-touch-icon.png']
|
||||
]) {
|
||||
const a = await (await page.request.get(sti)).body();
|
||||
const b = await (await page.request.get(egen)).body();
|
||||
expect(a.length, `${sti} er tom`).toBeGreaterThan(1000);
|
||||
expect(a.equals(b), `${sti} er ikke urds`).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readList } from '../src/lib/pastelist.js';
|
||||
|
||||
/**
|
||||
* Læsningen af en indsat liste, prøvet for sig.
|
||||
*
|
||||
* Reglerne er små og mange, og de er billigere at stille her end gennem en
|
||||
* browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
|
||||
* gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
|
||||
* sammen, ikke at hvert tilfælde er rigtigt.
|
||||
*/
|
||||
test.describe('en indsat liste læses som opgaver og underopgaver', () => {
|
||||
test('mærker fjernes, teksten bliver', () => {
|
||||
expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
|
||||
{ depth: 0, text: 'køb mælk', done: false },
|
||||
{ depth: 0, text: 'ring til Per', done: false },
|
||||
{ depth: 0, text: 'hent pakken', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('nummererede lister er også lister', () => {
|
||||
expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
|
||||
{ depth: 0, text: 'først', done: false },
|
||||
{ depth: 0, text: 'så', done: false },
|
||||
{ depth: 0, text: 'til sidst', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('en afkrydsning er indhold, ikke notation', () => {
|
||||
// Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
|
||||
// kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
|
||||
expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
|
||||
{ depth: 0, text: 'betalt', done: true },
|
||||
{ depth: 0, text: 'ikke betalt', done: false },
|
||||
{ depth: 0, text: 'også betalt', done: true }
|
||||
]);
|
||||
});
|
||||
|
||||
test('indrykning bærer niveauet, uanset hvor bred den er', () => {
|
||||
// To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
|
||||
// alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
|
||||
const to = readList('a\n b\nc');
|
||||
const fire = readList('a\n b\nc');
|
||||
const tab = readList('a\n\tb\nc');
|
||||
const want = [
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false },
|
||||
{ depth: 0, text: 'c', done: false }
|
||||
];
|
||||
expect(to).toEqual(want);
|
||||
expect(fire).toEqual(want);
|
||||
expect(tab).toEqual(want);
|
||||
});
|
||||
|
||||
test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
|
||||
// Kopierer man fra et dokument, følger der ofte indrykning med på alle
|
||||
// linjer. Den er ikke et niveau — der er ikke noget at være under.
|
||||
expect(readList(' a\n b')).toEqual([
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('tomme linjer er luft i det kopierede, ikke punkter', () => {
|
||||
// Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
|
||||
// liste.
|
||||
expect(readList('a\n\n b\n\n c')).toEqual([
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false },
|
||||
{ depth: 1, text: 'c', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('teksten røres ikke ud over mærket', () => {
|
||||
// Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
|
||||
// linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
|
||||
// mange.
|
||||
expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
|
||||
{ depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
|
||||
{ depth: 0, text: 'andet', done: false }
|
||||
]);
|
||||
});
|
||||
});
|
||||
+484
-34
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||
@@ -11,14 +12,6 @@ import { USER } from './user.js';
|
||||
* precisely the bug this suite exists to catch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
@@ -93,32 +86,6 @@ async function nyNote(side) {
|
||||
return felt;
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
@@ -5035,6 +5002,47 @@ test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ pa
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
|
||||
*
|
||||
* Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
|
||||
* feltet". Det passer for et <input>, hvor ⌘B ikke gør noget. I en contenteditable
|
||||
* ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
|
||||
* noterne foldede menuen sig, hver gang man ville fremhæve et ord.
|
||||
*
|
||||
* Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
|
||||
* halvt færdig: ordet skal også blive fedt.
|
||||
*/
|
||||
test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const foldet = () => page.locator('.shell.sidebar-collapsed').count();
|
||||
expect(await foldet()).toBe(0);
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Fremhævet');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('ordet');
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
|
||||
// Enten <b> eller <strong>: browserens egen fed er <b>, omformeren skriver
|
||||
// <strong>, og begge læses som fed på vejen tilbage.
|
||||
await expect(ed.locator('b, strong')).toContainText('ordet');
|
||||
expect(await foldet()).toBe(0);
|
||||
|
||||
// Og uden for et skrivefelt gør den stadig det, den er sat til.
|
||||
await page.getByRole('heading', { name: 'Noter' }).click();
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
expect(await foldet()).toBe(1);
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
|
||||
*
|
||||
@@ -5094,6 +5102,211 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
|
||||
*
|
||||
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
|
||||
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
|
||||
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
|
||||
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
|
||||
* retter den den næste blok to gange og lader denne stå.
|
||||
*
|
||||
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
|
||||
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
|
||||
* en <div> og "linje to" og "linje tre" var <p>.
|
||||
*
|
||||
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
|
||||
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
|
||||
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
|
||||
* beholder, uanset hvor markøren har været i mellemtiden.
|
||||
*/
|
||||
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const ed = await nyNote(page);
|
||||
await page.keyboard.type('Overskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('første afsnit');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('andet afsnit');
|
||||
await expect(ed.locator('p')).toHaveCount(2);
|
||||
|
||||
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
|
||||
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
|
||||
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
|
||||
// når den første ombytning tager dens node ud af dokumentet.
|
||||
await ed.evaluate((el) => {
|
||||
for (const para of [...el.querySelectorAll('p')]) {
|
||||
const div = document.createElement('div');
|
||||
while (para.firstChild) div.appendChild(para.firstChild);
|
||||
para.replaceWith(div);
|
||||
}
|
||||
|
||||
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
|
||||
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
|
||||
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el.lastElementChild);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
await expect(ed.locator('p')).toHaveCount(0);
|
||||
await expect(ed.locator('> div')).toHaveCount(2);
|
||||
|
||||
// Næste retur henter dem begge ind igen, sammen med den nye linje.
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('tredje afsnit');
|
||||
|
||||
await expect(ed.locator('> div')).toHaveCount(0);
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
|
||||
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
|
||||
// rettelse. Den skal også overleve en gemning.
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Overskrift/ }).click();
|
||||
|
||||
await expect(ed.locator('> div')).toHaveCount(0);
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
|
||||
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
|
||||
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hele kontoens opgaver kan hentes i én omgang.
|
||||
*
|
||||
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
|
||||
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
|
||||
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
|
||||
*
|
||||
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
|
||||
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
|
||||
* det er præcis den form, en fejl i en eksport har.
|
||||
*/
|
||||
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
|
||||
|
||||
await page.goto('/indstillinger/data');
|
||||
|
||||
const hent = async (navn) => {
|
||||
const venter = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: navn }).click();
|
||||
const fil = await venter;
|
||||
const sti = await fil.path();
|
||||
return { sti, navn: fil.suggestedFilename() };
|
||||
};
|
||||
|
||||
const zip = await hent('Alle opgaver (CSV)');
|
||||
expect(zip.navn).toMatch(/\.zip$/);
|
||||
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
|
||||
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
|
||||
expect(size).toBeGreaterThan(100);
|
||||
|
||||
const kal = await hent('Alle opgaver (kalender)');
|
||||
expect(kal.navn).toMatch(/\.ics$/);
|
||||
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
|
||||
expect(tekst).toContain('BEGIN:VTODO');
|
||||
expect(tekst).toContain('Betal moms');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En indsat liste bliver til opgaver og underopgaver.
|
||||
*
|
||||
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
|
||||
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
|
||||
* hele mast sammen.
|
||||
*
|
||||
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
|
||||
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
|
||||
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
|
||||
* billigere at stille.
|
||||
*/
|
||||
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
|
||||
'\n'
|
||||
);
|
||||
|
||||
await page.getByLabel('Ny opgave').click();
|
||||
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
|
||||
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
|
||||
await page.evaluate((tekst) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', tekst);
|
||||
document
|
||||
.querySelector('[data-quickadd]')
|
||||
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, liste);
|
||||
|
||||
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
|
||||
// man skal opdage bagefter.
|
||||
const visning = page.getByRole('group', { name: 'Indsat liste' });
|
||||
await expect(visning.locator('li')).toHaveCount(4);
|
||||
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
|
||||
|
||||
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
|
||||
await expect(visning).toHaveCount(0);
|
||||
|
||||
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
|
||||
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
|
||||
|
||||
// Og de indrykkede hænger under den linje, de stod under — ikke under den
|
||||
// første, den bedste. Det ses i opgavens egen rude.
|
||||
await page.getByText('Flytte garagen', { exact: true }).click();
|
||||
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
|
||||
|
||||
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
|
||||
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
|
||||
// er det, serveren har gemt, stregen er, hvordan den tegnes.
|
||||
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
|
||||
|
||||
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
|
||||
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
|
||||
|
||||
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
|
||||
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
|
||||
// undertasks under den — uden at nogen skal vælge noget.
|
||||
await page.evaluate((tekst) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', tekst);
|
||||
document
|
||||
.querySelector('input[aria-label="Ny undertask"]')
|
||||
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, '- Købe maling\n- Låne stige');
|
||||
|
||||
const flere = page.getByRole('group', { name: 'Indsat liste' });
|
||||
await expect(flere.locator('li')).toHaveCount(2);
|
||||
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
|
||||
|
||||
// To mere under den samme opgave: fire undertasks, én af dem lukket.
|
||||
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En kodeblok med flere linjer kommer tilbage med dem alle.
|
||||
*
|
||||
@@ -5342,3 +5555,240 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
|
||||
await page.unroute('**/api/v1/notes?*');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The control for the notes edition's renaming, and it belongs here rather than
|
||||
* beside it: the whole point is that the full edition does NOT go through that
|
||||
* path, and that can only be measured on an instance which is not the notes one.
|
||||
*
|
||||
* A gate asserted in one direction is half a gate. edition.spec.js proves the
|
||||
* notes edition calls itself urd; without this, a rename that fired in both
|
||||
* editions would pass there and change the name of this program.
|
||||
*/
|
||||
test('the full edition still calls itself verdande', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Both titles, for the same reason edition.spec.js checks both: the shell's,
|
||||
// which the browser reads before any script runs, and the route's own, set in
|
||||
// <svelte:head> once the page draws. Today has no title of its own, so the
|
||||
// shell's is what stands here — and /noter has one, so it is asked separately.
|
||||
const shell = await (await page.request.get('/')).text();
|
||||
expect(shell).toContain('<title>verdande</title>');
|
||||
await expect(page).toHaveTitle('verdande');
|
||||
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||
// ᚹ wunjo, not ð.
|
||||
await expect(brand.locator('.rune')).toHaveText('ᚹ');
|
||||
|
||||
await page.goto('/noter');
|
||||
await expect(page).toHaveTitle(/· verdande$/);
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('verdande');
|
||||
expect(manifest.description).toBe('Opgaver og projekter, delt.');
|
||||
|
||||
// The other half of the icon alias: this edition must still get Wunjo. Both
|
||||
// sets of files are in the same binary, so a swap that fired in both editions
|
||||
// would pass in edition.spec.js and quietly change this program's mark.
|
||||
const icon = await (await page.request.get('/icon.svg')).text();
|
||||
expect(icon).toContain('M26 15l17');
|
||||
expect(icon).not.toContain('M44 36L27 10');
|
||||
});
|
||||
|
||||
/**
|
||||
* The second door.
|
||||
*
|
||||
* One instance, one database, one login — and two things that can be installed.
|
||||
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
|
||||
* verdande, not a property of an instance, so if it only worked where
|
||||
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
|
||||
*
|
||||
* Measured on the bytes the server sends. An operating system installs a manifest,
|
||||
* not a URL, so these assertions are about the manifest's `id` and the shell's
|
||||
* <head> — the two things that decide whether /urd becomes a second icon on a home
|
||||
* screen or a bookmark into the first app.
|
||||
*/
|
||||
test('urd is a second installable app on the same instance', async ({ page }) => {
|
||||
const shell = await (await page.request.get('/urd')).text();
|
||||
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
|
||||
expect(shell, 'which manifest the browser fetches').toContain(
|
||||
'<link rel="manifest" href="/urd.webmanifest" />'
|
||||
);
|
||||
// iOS looks for this one BY NAME and has historically ignored the manifest's
|
||||
// icons, which is why the shell is served per door rather than patched by
|
||||
// script after the page loads.
|
||||
expect(shell, 'what iOS puts on a home screen').toContain(
|
||||
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
|
||||
);
|
||||
// And the root is untouched: two doors, not a rename.
|
||||
const root = await (await page.request.get('/')).text();
|
||||
expect(root).toContain('<title>verdande</title>');
|
||||
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||
|
||||
const res = await page.request.get('/urd.webmanifest');
|
||||
// A manifest served as text/html is ignored with no error anywhere; the only
|
||||
// symptom is an install prompt that never appears.
|
||||
expect(res.headers()['content-type']).toContain('application/manifest+json');
|
||||
|
||||
const urd = await res.json();
|
||||
// `id` is the install identity. Without it, two manifests on one origin are one
|
||||
// app with two start urls.
|
||||
expect(urd.id).toBe('/urd');
|
||||
expect(urd.start_url).toBe('/urd');
|
||||
// Not "/urd": a scope that did not cover the origin would open the app's own
|
||||
// links in a browser tab the first time somebody navigated out of /urd.
|
||||
expect(urd.scope).toBe('/');
|
||||
expect(urd.name).toBe('urd');
|
||||
for (const icon of urd.icons) {
|
||||
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
|
||||
}
|
||||
|
||||
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(verdande.name).toBe('verdande');
|
||||
expect(verdande.id ?? '/').not.toBe('/urd');
|
||||
// The shared fields come from one source, so a colour changed in the build
|
||||
// reaches both doors.
|
||||
expect(urd.theme_color).toBe(verdande.theme_color);
|
||||
expect(urd.background_color).toBe(verdande.background_color);
|
||||
});
|
||||
|
||||
test('entering through the urd door lands on the notes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/urd');
|
||||
|
||||
// Asserted on what is drawn rather than on the URL: the Go binary serves the
|
||||
// shell for anything it does not recognise, so a route that no longer exists
|
||||
// still answers 200 here.
|
||||
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
|
||||
// replaceState, so the phone's back gesture does not return to a page whose
|
||||
// only job is to redirect.
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
|
||||
// And the window now wears urd's face: its mark, its name, its tab. This is
|
||||
// chrome only — the task routes are all still there and still answer, which is
|
||||
// the whole reason this is one instance.
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('urd');
|
||||
await expect(brand.locator('.rune')).toHaveText('ð');
|
||||
await expect(page).toHaveTitle(/· urd$/);
|
||||
|
||||
// Nothing is withheld: the same window can still read a task route. A face that
|
||||
// hid data would be the separate instance wearing a costume.
|
||||
const tasks = await page.request.get('/api/v1/tasks');
|
||||
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
|
||||
|
||||
// And the face survives a reload — sessionStorage, because the two doors share
|
||||
// an origin. Nobody passes through /urd again after the first navigation.
|
||||
await page.reload();
|
||||
await expect(brand.locator('.name')).toHaveText('urd');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
||||
// The control, and it is the half that stops the face from being a rename: a
|
||||
// window that never went through /urd must be verdande, in the same browser,
|
||||
// on the same origin, with the other app's sessionStorage sitting beside it.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||
await expect(brand.locator('.rune')).toHaveText('\u16B9');
|
||||
await expect(page).toHaveTitle(/· verdande$/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* The button does not do speech recognition and is not meant to: it puts the
|
||||
* quick-add field in front of the thumb so the keyboard's own dictation can be
|
||||
* used. So what there is to test is the field — that it is reachable, that it ends
|
||||
* up with the caret in it, and that what lands there becomes a task.
|
||||
*
|
||||
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
|
||||
* only when focus() is called inside the user gesture, and no test can see the
|
||||
* difference. What a test CAN see is the precondition — that the field is already
|
||||
* in the document before the button is pressed — and that is asserted below,
|
||||
* because it is exactly what a well-meaning `{#if open}` would take away.
|
||||
*/
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
|
||||
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
|
||||
await expect(knap).toBeVisible();
|
||||
|
||||
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
|
||||
// synkront, og den eneste del af iOS-kravet en prøve kan se.
|
||||
//
|
||||
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
|
||||
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
|
||||
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
|
||||
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
|
||||
|
||||
await knap.click();
|
||||
|
||||
// Markøren er i skuffens felt — ikke i sidens eget.
|
||||
const fokusIskuffen = await page.evaluate(
|
||||
() => !!document.activeElement?.closest('.sheet')
|
||||
);
|
||||
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
|
||||
|
||||
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
await felt.fill('ring til tandlægen i dag p1');
|
||||
await felt.press('Enter');
|
||||
|
||||
// "i dag", af samme grund som de andre prøver her siger det: dette er
|
||||
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
|
||||
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
|
||||
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
|
||||
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
|
||||
await expect(page.locator('.sheet.open')).toHaveCount(0);
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
|
||||
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
|
||||
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
|
||||
// begynder et tastetryk at skrive i en kasse, ingen kan se.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
|
||||
const mærketErIskuffen = await page.evaluate(
|
||||
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
|
||||
);
|
||||
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
|
||||
|
||||
await page.keyboard.press('k');
|
||||
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
|
||||
});
|
||||
|
||||
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
|
||||
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
|
||||
// bunden er til tommelfingeren.
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
|
||||
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
|
||||
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/urd');
|
||||
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
|
||||
|
||||
// Kontrollen: samme telefon, den anden dør, og så er den der.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*
|
||||
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
|
||||
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
|
||||
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
|
||||
* der ser den.
|
||||
*/
|
||||
export function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
+83
-36
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = join(here, '..');
|
||||
const dataDir = join(here, '.playwright-data');
|
||||
const notesDataDir = join(here, '.playwright-data-notes');
|
||||
|
||||
// A clean instance every run: the first test creates the first account, which only
|
||||
// works on an empty database.
|
||||
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
|
||||
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(notesDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
|
||||
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
|
||||
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
|
||||
const NOTES_PORT = 8098;
|
||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||
// every browser makes. The passkey test cannot run against a loopback address.
|
||||
@@ -72,6 +78,10 @@ export default defineConfig({
|
||||
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
testIgnore: /edition\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -95,6 +105,21 @@ export default defineConfig({
|
||||
*
|
||||
* WebKit here is the same engine Safari is, which is the whole point.
|
||||
*/
|
||||
/**
|
||||
* Noter-udgaven, som sit eget program.
|
||||
*
|
||||
* Egen server, egen port, egen database, og ingen `storageState`: den her
|
||||
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
|
||||
* at låne dens cookie ville prøve to instanser som var de én.
|
||||
*
|
||||
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
|
||||
*/
|
||||
{
|
||||
name: 'notes-edition',
|
||||
testMatch: /edition\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -103,42 +128,64 @@ export default defineConfig({
|
||||
}
|
||||
],
|
||||
|
||||
webServer: {
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
webServer: [
|
||||
{
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
},
|
||||
|
||||
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
|
||||
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
|
||||
// det her koster en opstart og ikke en oversættelse.
|
||||
{
|
||||
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
|
||||
cwd: here,
|
||||
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${NOTES_PORT}`,
|
||||
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
|
||||
VERDANDE_DATA_DIR: notesDataDir,
|
||||
VERDANDE_EDITION: 'notes',
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,40 +1,71 @@
|
||||
/**
|
||||
* Renders static/icon.svg to the PNGs the manifest and iOS need.
|
||||
* Renders each edition's icon SVG to the PNGs the manifest and iOS need.
|
||||
*
|
||||
* node scripts/generate-icons.js
|
||||
*
|
||||
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
||||
* binary. Run it when the icon changes; the smoke test fetches every icon the
|
||||
* binary. Run it when an icon changes; the smoke test fetches every icon the
|
||||
* manifest names, so a reference with no file behind it fails there.
|
||||
*
|
||||
* It drives Chromium because there is no SVG renderer on this machine — which is
|
||||
* why these references were once removed rather than left pointing at files that
|
||||
* did not exist. Playwright is a renderer, and it is already a dependency.
|
||||
*
|
||||
* Two sets, because there are two editions. Both ship in the one binary and the
|
||||
* server decides which to serve at /icon.svg and friends — see
|
||||
* internal/httpapi/shell.go. The alternative, rewriting the manifest to point at
|
||||
* urd-icon-192.png, would have left `apple-touch-icon.png` behind: Safari looks
|
||||
* for that one BY NAME and ignores the manifest entirely, so a renamed path there
|
||||
* is a home-screen icon that is a screenshot of the page.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// The three sizes, and why each is here.
|
||||
const SIZES = [
|
||||
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||
{ file: 'static/icon-192.png', size: 192 },
|
||||
{ file: 'static/icon-512.png', size: 512 },
|
||||
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
||||
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
||||
// screenshot of the page as its home-screen icon.
|
||||
{ file: 'static/apple-touch-icon.png', size: 180 }
|
||||
{ suffix: '-192.png', size: 192 },
|
||||
{ suffix: '-512.png', size: 512 },
|
||||
// And the one Safari wants by name.
|
||||
{ suffix: '-apple-touch.png', size: 180 }
|
||||
];
|
||||
|
||||
// `out` is spelled out per set rather than built from the prefix, because
|
||||
// verdande's three were named before there was a second set and renaming them
|
||||
// would move the paths the manifest and app.html already point at.
|
||||
const SETS = [
|
||||
{
|
||||
source: 'static/icon.svg',
|
||||
out: {
|
||||
'-192.png': 'static/icon-192.png',
|
||||
'-512.png': 'static/icon-512.png',
|
||||
'-apple-touch.png': 'static/apple-touch-icon.png'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: 'static/urd-icon.svg',
|
||||
out: {
|
||||
'-192.png': 'static/urd-icon-192.png',
|
||||
'-512.png': 'static/urd-icon-512.png',
|
||||
'-apple-touch.png': 'static/urd-apple-touch-icon.png'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const svg = readFileSync('static/icon.svg', 'utf8');
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
for (const { file, size } of SIZES) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
for (const set of SETS) {
|
||||
const svg = readFileSync(set.source, 'utf8');
|
||||
for (const { suffix, size } of SIZES) {
|
||||
const file = set.out[suffix];
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
+9
-2
@@ -365,8 +365,13 @@ export const api = {
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
quickAdd: (text, projectId, sectionId, parentId) =>
|
||||
post('/tasks/quick-add', {
|
||||
text,
|
||||
project_id: projectId,
|
||||
section_id: sectionId,
|
||||
parent_id: parentId
|
||||
}),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
@@ -511,6 +516,8 @@ export const api = {
|
||||
importCSV: (data) => post('/import/csv', data),
|
||||
exportAccountURL: () => '/api/v1/export/account',
|
||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
||||
exportAllCSVURL: () => '/api/v1/export/projects.zip',
|
||||
exportAllICSURL: () => '/api/v1/export/tasks.ics',
|
||||
// Through `upload`, not `request`: a multipart body has to set its own boundary,
|
||||
// and the browser only does that when nothing has claimed the header.
|
||||
importNotes: (file) => upload('/notes/import', file),
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
{#if notice}
|
||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||
<h2>{t('beacon.noticeTitle')}</h2>
|
||||
<p>{t('beacon.noticeBody')}</p>
|
||||
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
|
||||
|
||||
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
|
||||
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
|
||||
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
|
||||
// skal ⌘K ikke være stedet, det viser sig.
|
||||
...(app.notesOnly ? [] : tasks).map((t) => ({
|
||||
kind: 'task',
|
||||
id: t.id,
|
||||
label: t.content,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script>
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
|
||||
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
|
||||
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
|
||||
*
|
||||
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
|
||||
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
|
||||
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
|
||||
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
|
||||
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
|
||||
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
|
||||
* på det, og intet oprettes før man siger ja.
|
||||
*
|
||||
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
|
||||
* lyden, den stiller feltet frem.
|
||||
*/
|
||||
import QuickAdd from './QuickAdd.svelte';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let open = $state(false);
|
||||
let box;
|
||||
let sheet;
|
||||
|
||||
/**
|
||||
* Åbner og sætter fokus I SAMME HUG som klikket.
|
||||
*
|
||||
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
|
||||
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
|
||||
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
|
||||
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
|
||||
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
|
||||
* frem for efter næste opdatering.
|
||||
*
|
||||
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
|
||||
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
|
||||
* og uden pointer-events frem for skjult.
|
||||
*/
|
||||
function start() {
|
||||
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
|
||||
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
|
||||
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
|
||||
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
|
||||
// åbne tastaturet.
|
||||
//
|
||||
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
|
||||
// tabbes til, mens den er usynlig, på hver eneste side.
|
||||
sheet?.removeAttribute('inert');
|
||||
open = true;
|
||||
box?.focusField();
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
// Tastaturet bliver stående, hvis feltet beholder fokus.
|
||||
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
|
||||
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
|
||||
bredde, ikke skal kunne tabe et felt, der har fokus. -->
|
||||
{#if !app.showsUrd}
|
||||
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
|
||||
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
|
||||
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
|
||||
og navnet står i aria-label. -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="11" rx="3" />
|
||||
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
|
||||
<path d="M12 17.5V21" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
class:open
|
||||
aria-hidden="true"
|
||||
onclick={close}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="sheet"
|
||||
class:open
|
||||
bind:this={sheet}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={t('task.speak')}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div class="grab" aria-hidden="true"></div>
|
||||
<p class="hint">{t('task.speakHint')}</p>
|
||||
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
|
||||
Se kommentaren ved `marked` i QuickAdd. -->
|
||||
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
|
||||
<button class="done" onclick={close}>{t('detail.close')}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: max(var(--s4), env(safe-area-inset-right));
|
||||
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
|
||||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||||
z-index: 40;
|
||||
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--fast) var(--ease-out),
|
||||
opacity var(--fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.fab svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
|
||||
.fab.away {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 44;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.scrim.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 45;
|
||||
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
|
||||
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
|
||||
background: var(--surface-raised);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--s2);
|
||||
|
||||
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
|
||||
fokuseres i samme hug som klikket. Se `start()`. */
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--medium) var(--ease-out),
|
||||
opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.sheet.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.grab {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--line-strong);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.done {
|
||||
justify-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
|
||||
på. Knappen er til tommelfingeren. */
|
||||
@media (min-width: 821px) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<h2>{t('net.downTitle')}</h2>
|
||||
<p class="lede">{t('net.downBody')}</p>
|
||||
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
|
||||
|
||||
<button onclick={again} disabled={trying}>
|
||||
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||
|
||||
@@ -821,23 +821,54 @@
|
||||
* the caret in another heading in most browsers, and a note where every line is
|
||||
* a title is a note with no title at all.
|
||||
*/
|
||||
function keepTitleFirst() {
|
||||
const block = currentBlock();
|
||||
if (!block) return;
|
||||
function keepTitleFirst(split) {
|
||||
if (!editor) return;
|
||||
|
||||
// A heading that is not the first line becomes body.
|
||||
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
return;
|
||||
}
|
||||
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
|
||||
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
|
||||
// ligesom den tager et ord tilbage.
|
||||
const caret = currentBlock();
|
||||
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
|
||||
|
||||
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||
// return rather than choosing Brødtekst from the menu.
|
||||
if (block.tagName === 'DIV') {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
}
|
||||
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
|
||||
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
|
||||
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
|
||||
// nogen har valgt Titel til i menuen, og den er deres at beholde.
|
||||
const lavet = split?.nextElementSibling;
|
||||
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
|
||||
|
||||
// Og hver <div>, rettelsen ikke nåede.
|
||||
//
|
||||
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
|
||||
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
|
||||
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
|
||||
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
|
||||
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
|
||||
// afstand, hos netop de brugere der skriver hurtigst.
|
||||
//
|
||||
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
|
||||
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
|
||||
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
|
||||
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
|
||||
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
|
||||
const her = currentBlock();
|
||||
for (const block of [...editor.children])
|
||||
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* En blok, der ikke må stå, som den står.
|
||||
*
|
||||
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
|
||||
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
|
||||
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
|
||||
* hver linje er en titel, er en note uden titel.
|
||||
*/
|
||||
function stray(block) {
|
||||
if (block.parentNode !== editor) return false;
|
||||
return (
|
||||
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -912,9 +943,10 @@
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
// After the split, not before it.
|
||||
// After the split, not before it — and with the block the split started from,
|
||||
// because by the time this runs the caret may be two blocks further on.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
keepTitleFirst(block);
|
||||
changed();
|
||||
}, 0);
|
||||
return;
|
||||
|
||||
@@ -15,14 +15,54 @@
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
let {
|
||||
projectId = undefined,
|
||||
onadded = undefined,
|
||||
autofocus = false,
|
||||
/**
|
||||
* Whether this box carries the `data-quickadd` marker.
|
||||
*
|
||||
* The layout's type-anywhere shortcut finds the field with
|
||||
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
|
||||
* in the document. So a second box on the page — the dictate sheet's — has
|
||||
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
|
||||
*/
|
||||
marked = true,
|
||||
/**
|
||||
* The field's accessible name.
|
||||
*
|
||||
* A prop because the dictate sheet puts a second one of these on the page,
|
||||
* and two fields answering to "Ny opgave" is a screen reader reading the
|
||||
* same name twice with no way to tell which is which. They are different
|
||||
* affordances — one is the box on the page, the other is the thing the
|
||||
* round button opens — so they say different names.
|
||||
*/
|
||||
label = undefined
|
||||
} = $props();
|
||||
|
||||
let fieldLabel = $derived(label ?? t('task.new'));
|
||||
|
||||
let text = $state('');
|
||||
let spans = $state([]);
|
||||
let submitting = $state(false);
|
||||
let input;
|
||||
let mirror;
|
||||
let focused = $state(false);
|
||||
|
||||
/**
|
||||
* Keeps the tint under the right characters when the line is longer than the
|
||||
* field.
|
||||
*
|
||||
* The input scrolls horizontally and the mirror has to scroll with it. Done on
|
||||
* both `scroll` and `input` because typing at the end of a long line moves the
|
||||
* caret — and therefore the scroll — without ever firing a scroll event in some
|
||||
* browsers.
|
||||
*/
|
||||
function syncScroll() {
|
||||
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
|
||||
}
|
||||
|
||||
// Kept beside the parser's own vocabulary on purpose: a legend that promises
|
||||
// something internal/quickadd cannot read is worse than no legend. These four
|
||||
// are what Parse recognises as marks; dates it reads as prose, which is why the
|
||||
@@ -71,6 +111,20 @@
|
||||
if (autofocus && input) input.focus();
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus the field from outside, synchronously.
|
||||
*
|
||||
* This exists because of iOS and nothing else. Safari opens the on-screen
|
||||
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
|
||||
* it — so the `autofocus` effect above, which runs after the click handler has
|
||||
* returned, gives the field a caret and no keyboard. For the dictate button that
|
||||
* is the whole feature failing quietly: the sheet opens, nothing to dictate
|
||||
* into, and there is no error anywhere.
|
||||
*/
|
||||
export function focusField() {
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/** Splits the raw text into highlighted and plain runs, by byte offset. */
|
||||
let segments = $derived.by(() => {
|
||||
if (!spans.length) return [{ text, kind: null }];
|
||||
@@ -97,6 +151,60 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
/**
|
||||
* En indsat liste bliver til opgaver frem for til én opgave med det hele i.
|
||||
*
|
||||
* Browseren smider linjeskiftene væk, når man indsætter i et `<input>`, så uden
|
||||
* det her blev en kopieret liste til én opgave med punkterne mast sammen. En
|
||||
* enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
|
||||
* som altid.
|
||||
*
|
||||
* Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
|
||||
* opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
|
||||
* stillet mens der stadig er en Fortryd-knap.
|
||||
*/
|
||||
let pending = $state([]);
|
||||
|
||||
function onpaste(event) {
|
||||
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (!pasted.includes('\n')) return;
|
||||
|
||||
const rows = readList(pasted);
|
||||
if (rows.length < 2) return;
|
||||
|
||||
event.preventDefault();
|
||||
pending = rows;
|
||||
}
|
||||
|
||||
async function createPending() {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
|
||||
// Den seneste opgave på hvert niveau, så en indrykket linje havner under den
|
||||
// linje, den stod under — og ikke under den første, den bedste.
|
||||
const parents = [];
|
||||
let made = 0;
|
||||
for (const row of pending) {
|
||||
const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
|
||||
const task = await app.quickAdd(row.text, projectId, undefined, parentId);
|
||||
// Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
|
||||
// resten af listen ind uden den, de hang under, og det er værre end at
|
||||
// standse et sted, man kan se.
|
||||
if (!task) break;
|
||||
parents[row.depth] = task.id;
|
||||
parents.length = row.depth + 1;
|
||||
if (row.done) await app.complete(task.id);
|
||||
onadded?.(task);
|
||||
made++;
|
||||
}
|
||||
|
||||
submitting = false;
|
||||
// Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
|
||||
// den kom og prøve resten igen.
|
||||
pending = pending.slice(made);
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event?.preventDefault();
|
||||
const value = text.trim();
|
||||
@@ -142,7 +250,7 @@
|
||||
<!-- The mirror sits behind the input and must match it exactly: same font,
|
||||
same padding, same wrapping. Any difference shows up as highlighting
|
||||
that drifts out of alignment as the line grows. -->
|
||||
<div class="mirror" aria-hidden="true">
|
||||
<div class="mirror" aria-hidden="true" bind:this={mirror}>
|
||||
{#each segments as segment}
|
||||
{#if segment.kind}
|
||||
<mark data-kind={segment.kind}>{segment.text}</mark>
|
||||
@@ -156,21 +264,42 @@
|
||||
bind:this={input}
|
||||
bind:value={text}
|
||||
{onkeydown}
|
||||
onscroll={syncScroll}
|
||||
oninput={syncScroll}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={() => (focused = false)}
|
||||
type="text"
|
||||
data-quickadd
|
||||
data-quickadd={marked ? '' : undefined}
|
||||
placeholder={t('task.placeholder')}
|
||||
aria-label={t('task.new')}
|
||||
aria-label={fieldLabel}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{onpaste}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if text.trim()}
|
||||
{#if text.trim() && !pending.length}
|
||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||
{/if}
|
||||
|
||||
{#if pending.length}
|
||||
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
|
||||
<ul>
|
||||
{#each pending as row, i (i)}
|
||||
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<button type="button" class="submit" disabled={submitting} onclick={createPending}>
|
||||
{t('task.pastedAdd', { n: String(pending.length) })}
|
||||
</button>
|
||||
<button type="button" class="cancel" onclick={() => (pending = [])}>
|
||||
{t('task.pastedCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- What the parser understands, spelled out while somebody is typing into it.
|
||||
It was in the placeholder before, which is the one place a hint cannot be:
|
||||
the placeholder disappears at the first keystroke, so the help was gone
|
||||
@@ -190,6 +319,57 @@
|
||||
</form>
|
||||
|
||||
<style>
|
||||
/* Den indsatte liste, før den bliver til opgaver. Ligger i flowet og ikke
|
||||
svævende: den skal kunne klikkes på, og den er noget, man læser færdig frem
|
||||
for et vink, der forsvinder. */
|
||||
.pasted {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: var(--s2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
padding: var(--s2);
|
||||
}
|
||||
|
||||
.pasted ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--s2);
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pasted li {
|
||||
/* Indrykningen er hele pointen med visningen: den viser, hvad der bliver en
|
||||
underopgave, mens der stadig er en vej tilbage. */
|
||||
padding: 2px 0 2px calc(var(--depth) * var(--s4));
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pasted li.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.pasted .actions {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
|
||||
.syntax {
|
||||
/* Nothing to click, and it floats over the first task in the list — which it
|
||||
@@ -260,8 +440,16 @@
|
||||
letter-spacing: normal;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
/* `pre`, not `pre-wrap`, and that is the whole alignment.
|
||||
|
||||
An <input type="text"> is single-line by specification and cannot wrap
|
||||
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
|
||||
not drift a little — it puts the tint on a second line that the input does
|
||||
not have, and the highlight ends up under empty space. Rare on a wide
|
||||
desktop field, constant in the dictate sheet on a 390px phone, where a
|
||||
spoken sentence overflows immediately. */
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
@@ -269,6 +457,10 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: transparent;
|
||||
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
|
||||
has to be hidden rather than visible, or the long tail of a line paints
|
||||
out past the field's edge. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The tint is behind the glyphs, not on them: colouring the text itself would
|
||||
|
||||
@@ -73,6 +73,11 @@
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
|
||||
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
|
||||
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
|
||||
// drift i to merges.
|
||||
if (app.notesOnly) return;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
@@ -155,9 +160,12 @@
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
|
||||
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
|
||||
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
|
||||
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
|
||||
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
!app.notesOnly && app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
@@ -185,7 +193,7 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -472,11 +480,15 @@
|
||||
|
||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||
<div class="brand">
|
||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
||||
the name is in the tab and everywhere else already. -->
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<span class="name">verdande</span>
|
||||
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
|
||||
was meant to be: uruz is already another of this author's apps and
|
||||
carries that glyph. See web/static/urd-icon.svg.
|
||||
|
||||
Both come from the store rather than from here, because the two editions
|
||||
of this program are two products with two names, and a wordmark is the
|
||||
one place a reader decides which one they are looking at. -->
|
||||
<span class="rune" aria-hidden="true">{app.productMark}</span>
|
||||
<span class="name">{app.productSlug}</span>
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -282,6 +283,73 @@
|
||||
|
||||
// --- sub-tasks ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
|
||||
*
|
||||
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
|
||||
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
|
||||
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
|
||||
* punkter under punkter kommer ind som den stod.
|
||||
*
|
||||
* Som i quick-add vises den, før den oprettes — og af samme grund.
|
||||
*/
|
||||
let pastedSubtasks = $state([]);
|
||||
|
||||
function onPasteSubtasks(event) {
|
||||
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (!pasted.includes('\n')) return;
|
||||
const rows = readList(pasted);
|
||||
if (rows.length < 2) return;
|
||||
event.preventDefault();
|
||||
pastedSubtasks = rows;
|
||||
}
|
||||
|
||||
async function createPastedSubtasks() {
|
||||
if (addingPasted) return;
|
||||
addingPasted = true;
|
||||
|
||||
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
|
||||
// seneste linje ovenfor, som i quick-add.
|
||||
const parents = [task.id];
|
||||
let made = 0;
|
||||
for (const row of pastedSubtasks) {
|
||||
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
|
||||
if (!created) break;
|
||||
parents[row.depth + 1] = created.id;
|
||||
parents.length = row.depth + 2;
|
||||
if (row.done) await app.complete(created.id);
|
||||
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
|
||||
// resten hører under deres egen.
|
||||
if (row.depth === 0) subtasks = [...subtasks, created];
|
||||
made++;
|
||||
}
|
||||
|
||||
addingPasted = false;
|
||||
pastedSubtasks = pastedSubtasks.slice(made);
|
||||
countSubtasks();
|
||||
}
|
||||
|
||||
let addingPasted = $state(false);
|
||||
|
||||
/**
|
||||
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
|
||||
*
|
||||
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
|
||||
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
|
||||
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
|
||||
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
|
||||
*
|
||||
* Samme regel som resten af butikken: en lokal handling slår igennem med det
|
||||
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
|
||||
*/
|
||||
function countSubtasks() {
|
||||
app.upsert({
|
||||
...task,
|
||||
subtask_count: subtasks.length,
|
||||
subtask_done: subtasks.filter((s) => s.completed).length
|
||||
});
|
||||
}
|
||||
|
||||
async function addSubtask(event) {
|
||||
event.preventDefault();
|
||||
const text = newSubtask.trim();
|
||||
@@ -294,6 +362,7 @@
|
||||
parent_id: task.id
|
||||
});
|
||||
subtasks = [...subtasks, created];
|
||||
countSubtasks();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -635,8 +704,31 @@
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addSubtask}>
|
||||
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
|
||||
<input
|
||||
bind:value={newSubtask}
|
||||
onpaste={onPasteSubtasks}
|
||||
placeholder={t('detail.addSubtask')}
|
||||
aria-label={t('detail.newSubtask')}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{#if pastedSubtasks.length}
|
||||
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
|
||||
<ul>
|
||||
{#each pastedSubtasks as row, i (i)}
|
||||
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="pasted-actions">
|
||||
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
|
||||
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
|
||||
</button>
|
||||
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
|
||||
{t('task.pastedCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -741,6 +833,43 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
|
||||
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
|
||||
.pasted {
|
||||
margin-top: var(--s2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s2);
|
||||
}
|
||||
.pasted ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--s2);
|
||||
padding: 0;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pasted li {
|
||||
padding: 2px 0 2px calc(var(--depth) * var(--s4));
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.pasted li.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.pasted-actions {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
}
|
||||
.pasted-actions .plain {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linked-note {
|
||||
display: block;
|
||||
padding: var(--s2);
|
||||
|
||||
@@ -145,7 +145,7 @@ export const da = {
|
||||
// læserens mulighed for at efterprøve det.
|
||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||
'beacon.noticeBody':
|
||||
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'beacon.noticeTo': 'Sendes til {url}',
|
||||
'beacon.noticeKeep': 'Behold den',
|
||||
'beacon.noticeStop': 'Slå den fra',
|
||||
@@ -238,7 +238,7 @@ export const da = {
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'net.downTitle': 'Ingen forbindelse',
|
||||
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downRetry': 'Prøv igen',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
@@ -263,7 +263,15 @@ export const da = {
|
||||
|
||||
// --- a task in a list ----------------------------------------------------------
|
||||
'task.add': 'Tilføj',
|
||||
'task.pastedTitle': 'Indsat liste',
|
||||
'task.pastedAdd': 'Opret {n} opgaver',
|
||||
'task.pastedCancel': 'Fortryd',
|
||||
'task.new': 'Ny opgave',
|
||||
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||
'task.speak': 'Tal en opgave ind',
|
||||
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
|
||||
'task.syntaxProject': 'projekt',
|
||||
'task.syntaxSection': 'sektion',
|
||||
'task.syntaxLabel': 'etiket',
|
||||
@@ -350,7 +358,7 @@ export const da = {
|
||||
'size.default': 'Standard',
|
||||
'size.large': 'Stor',
|
||||
'size.xl': 'Størst',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'Som du kender den.',
|
||||
'look.rolig': 'Rolig',
|
||||
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||
@@ -611,6 +619,8 @@ export const da = {
|
||||
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
|
||||
'data.notesImported': '{n} noter og {files} filer hentet ind.',
|
||||
'data.exportNotes': 'Alle noter som Markdown-filer',
|
||||
'data.exportAllCSV': 'Alle opgaver (CSV)',
|
||||
'data.exportAllICS': 'Alle opgaver (kalender)',
|
||||
'data.exportAccount': 'Hele kontoen som JSON',
|
||||
'data.exportOne': 'Ét projekt',
|
||||
'data.pickProject': 'Vælg et projekt',
|
||||
|
||||
@@ -142,7 +142,7 @@ export const en = {
|
||||
// the reader's ability to check it.
|
||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||
'beacon.noticeBody':
|
||||
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'beacon.noticeTo': 'Sent to {url}',
|
||||
'beacon.noticeKeep': 'Keep it on',
|
||||
'beacon.noticeStop': 'Turn it off',
|
||||
@@ -235,7 +235,7 @@ export const en = {
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'net.downTitle': 'No connection',
|
||||
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downRetry': 'Try again',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
@@ -260,7 +260,12 @@ export const en = {
|
||||
|
||||
// --- a task in a list ----------------------------------------------------------
|
||||
'task.add': 'Add',
|
||||
'task.pastedTitle': 'Pasted list',
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.speak': 'Speak a task',
|
||||
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
|
||||
'task.syntaxProject': 'project',
|
||||
'task.syntaxSection': 'section',
|
||||
'task.syntaxLabel': 'label',
|
||||
@@ -347,7 +352,7 @@ export const en = {
|
||||
'size.default': 'Default',
|
||||
'size.large': 'Large',
|
||||
'size.xl': 'Largest',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'As you know it.',
|
||||
'look.rolig': 'Calm',
|
||||
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||
@@ -607,6 +612,8 @@ export const en = {
|
||||
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
|
||||
'data.notesImported': '{n} notes and {files} files brought in.',
|
||||
'data.exportNotes': 'Every note as Markdown files',
|
||||
'data.exportAllCSV': 'All tasks (CSV)',
|
||||
'data.exportAllICS': 'All tasks (calendar)',
|
||||
'data.exportAccount': 'The whole account as JSON',
|
||||
'data.exportOne': 'One project',
|
||||
'data.pickProject': 'Pick a project',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* En indsat liste, læst som opgaver og underopgaver.
|
||||
*
|
||||
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
|
||||
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
|
||||
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
|
||||
*
|
||||
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
|
||||
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
|
||||
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
|
||||
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
|
||||
* faktisk er set, klarer begge uden at kende tallet på forhånd.
|
||||
*
|
||||
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
|
||||
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
|
||||
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
|
||||
* uløste opgaver, har tabt det, man indsatte den for.
|
||||
*
|
||||
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
|
||||
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
|
||||
* forstå en opgavelinje, ikke to.
|
||||
*/
|
||||
|
||||
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
|
||||
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
|
||||
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
|
||||
const BOX = /^\[([ xX])\]\s*/;
|
||||
|
||||
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
|
||||
function width(indent) {
|
||||
return indent.replace(/\t/g, ' ').length;
|
||||
}
|
||||
|
||||
export function readList(text) {
|
||||
const out = [];
|
||||
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
|
||||
const levels = [];
|
||||
|
||||
for (const raw of (text ?? '').split(/\r?\n/)) {
|
||||
const indent = /^[ \t]*/.exec(raw)[0];
|
||||
let rest = raw.slice(indent.length).trim();
|
||||
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
|
||||
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
|
||||
if (!rest) continue;
|
||||
|
||||
rest = rest.replace(MARKER, '');
|
||||
let done = false;
|
||||
const box = BOX.exec(rest);
|
||||
if (box) {
|
||||
done = box[1] !== ' ';
|
||||
rest = rest.slice(box[0].length);
|
||||
}
|
||||
rest = rest.trim();
|
||||
if (!rest) continue;
|
||||
|
||||
const w = width(indent);
|
||||
while (levels.length && w < levels[levels.length - 1]) levels.pop();
|
||||
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
|
||||
|
||||
out.push({ depth: levels.length - 1, text: rest, done });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -43,6 +43,126 @@ class AppState {
|
||||
*/
|
||||
unreachable = $state(false);
|
||||
|
||||
/**
|
||||
* Hvilken udgave af programmet den her instans er.
|
||||
*
|
||||
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
|
||||
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
|
||||
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
|
||||
* det er en knap, der fører et sted hen, hvor der ikke er noget.
|
||||
*
|
||||
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
|
||||
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
|
||||
*
|
||||
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
|
||||
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
|
||||
* program.
|
||||
*/
|
||||
edition = $state('full');
|
||||
|
||||
get notesOnly() {
|
||||
return this.edition === 'notes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue kom ind ad.
|
||||
*
|
||||
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
|
||||
* filen her. De to ligner hinanden og betyder ikke det samme:
|
||||
*
|
||||
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
|
||||
* dem, for de svarer 404.
|
||||
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
|
||||
* dataene er deres egne, og der er intet at holde tilbage.
|
||||
*
|
||||
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
|
||||
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
|
||||
* database og personen bare valgte en anden indgang.
|
||||
*
|
||||
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
|
||||
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
|
||||
* PWA er sit eget vindue.
|
||||
*/
|
||||
urdFace = $state(false);
|
||||
|
||||
enterUrd() {
|
||||
this.setDoor('urd');
|
||||
}
|
||||
|
||||
leaveUrd() {
|
||||
this.setDoor('verdande');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue sidst kom ind ad.
|
||||
*
|
||||
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
|
||||
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
|
||||
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
|
||||
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
|
||||
* tiden.
|
||||
*/
|
||||
setDoor(door) {
|
||||
this.urdFace = door === 'urd';
|
||||
try {
|
||||
sessionStorage.setItem('verdande:door', door);
|
||||
} catch (e) {
|
||||
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
||||
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
||||
// mister det.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
|
||||
*
|
||||
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
|
||||
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
|
||||
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
|
||||
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
|
||||
*
|
||||
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud
|
||||
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
|
||||
* vise søsterens navn et øjeblik og så rette sig selv.
|
||||
*/
|
||||
/**
|
||||
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
|
||||
* fordi personen kom ind ad urd-døren.
|
||||
*
|
||||
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
|
||||
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
|
||||
* om der overhovedet er en rute at hente fra.
|
||||
*/
|
||||
get showsUrd() {
|
||||
return this.notesOnly || this.urdFace;
|
||||
}
|
||||
|
||||
get productName() {
|
||||
return this.showsUrd ? 'Urd' : 'Verdande';
|
||||
}
|
||||
|
||||
get productSlug() {
|
||||
return this.showsUrd ? 'urd' : 'verdande';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mærket: ᚹ wunjo for Verdande, ð for Urd.
|
||||
*
|
||||
* Ikke to runer, selvom det var meningen. Urds oplagte mærke var ᚢ uruz,
|
||||
* bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er
|
||||
* wunjo — og det blev tegnet sådan først. Men uruz er allerede et af husets
|
||||
* egne produkter, og dets `gen-icons.ts` tegner samme glyf i guld. To af samme
|
||||
* persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det sted,
|
||||
* mærket skal kunne skelnes. ᚦ thurisaz blev forkastet et niveau nede af samme
|
||||
* grund: en stav med en trekant til højre, og det er ᚹ også.
|
||||
*
|
||||
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
|
||||
*/
|
||||
get productMark() {
|
||||
return this.showsUrd ? 'ð' : '\u16B9';
|
||||
}
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
@@ -108,6 +228,26 @@ class AppState {
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||
// linje ville ansigtet falde af ved hvert F5.
|
||||
try {
|
||||
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
|
||||
} catch (e) {
|
||||
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
|
||||
}
|
||||
|
||||
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
||||
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
||||
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
||||
const shape = api
|
||||
.setupState()
|
||||
.then((state) => {
|
||||
if (state?.edition) this.edition = state.edition;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
try {
|
||||
this.user = await api.me();
|
||||
this.unreachable = false;
|
||||
@@ -131,6 +271,10 @@ class AppState {
|
||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||
this.unreachable = e?.code === 'offline';
|
||||
} finally {
|
||||
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
|
||||
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
|
||||
// en instans, der ikke har nogen.
|
||||
await shape;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -278,6 +422,21 @@ class AppState {
|
||||
* `completed: 'exclude'`.
|
||||
*/
|
||||
async loadTasks(params) {
|
||||
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
|
||||
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
|
||||
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
|
||||
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
|
||||
// den fjerde, og det var præcis sådan den her slap igennem.
|
||||
if (this.notesOnly) {
|
||||
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
|
||||
// looper: projektsiden har en effekt, der læser app.tasks og kalder
|
||||
// herind, så et skriv ved hvert kald er en uendelig runde —
|
||||
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
|
||||
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
|
||||
// fejl.
|
||||
if (this.tasks.length) this.tasks = [];
|
||||
return this.tasks;
|
||||
}
|
||||
const { tasks } = await api.listTasks({
|
||||
completed: completedView.shown ? 'include' : undefined,
|
||||
...params
|
||||
@@ -524,9 +683,9 @@ class AppState {
|
||||
* request is fast and the box clears immediately, which is where the
|
||||
* responsiveness actually needs to be.
|
||||
*/
|
||||
async quickAdd(text, projectId) {
|
||||
async quickAdd(text, projectId, sectionId, parentId) {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId);
|
||||
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||
this.upsert(task);
|
||||
return task;
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import DictateSheet from '$lib/components/DictateSheet.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||
@@ -57,6 +58,58 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue er inde ad.
|
||||
*
|
||||
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
|
||||
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
|
||||
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
|
||||
* vinduet åbnede med.
|
||||
*
|
||||
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
|
||||
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
|
||||
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
|
||||
* hver sin dør — men en browserfane springer mellem dem hele tiden.
|
||||
*/
|
||||
$effect(() => {
|
||||
const here = $page.url.pathname;
|
||||
if (here === '/urd') app.enterUrd();
|
||||
else if (here === '/') app.leaveUrd();
|
||||
});
|
||||
|
||||
/**
|
||||
* Opgavernes sider findes ikke i noter-udgaven.
|
||||
*
|
||||
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
|
||||
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
|
||||
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
|
||||
*
|
||||
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
|
||||
* Adressen hører bare til et andet program end det, der kører her — og nogen,
|
||||
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
|
||||
* mening frem for i en blindgyde.
|
||||
*
|
||||
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
|
||||
* væk fra, og derfra tilbage igen.
|
||||
*/
|
||||
const TASK_PAGES = [
|
||||
'/',
|
||||
'/upcoming',
|
||||
'/uddelegeret',
|
||||
'/kalender',
|
||||
'/faerdige',
|
||||
'/opgave',
|
||||
'/filter',
|
||||
'/etiket'
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (!app.notesOnly) return;
|
||||
const here = $page.url.pathname;
|
||||
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
|
||||
if (taskPage) goto('/noter', { replaceState: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||
*
|
||||
@@ -130,8 +183,20 @@
|
||||
// Folding the sidebar had a button and nothing else, and a rectangle with a
|
||||
// line down it is not a word: somebody who wanted the menu out of the way
|
||||
// asked for the feature that was already there. ⌘B is where an editor puts
|
||||
// it. Works while typing as well — it moves nothing in the field.
|
||||
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
|
||||
// it.
|
||||
//
|
||||
// Men ikke i en rig editor. Her stod der "works while typing as well — it
|
||||
// moves nothing in the field", og det passer for et <input>, hvor ⌘B ikke
|
||||
// gør noget. I en contenteditable ER ⌘B fed tekst, og `preventDefault()`
|
||||
// nedenfor stoppede browseren i at gøre den — så i noterne foldede
|
||||
// sidebjælken sig i stedet for at gøre ordet fedt. Feltet, markøren står i,
|
||||
// har førsteret til en tast, det selv bruger.
|
||||
const writing = el?.isContentEditable || el?.closest?.('[contenteditable="true"]');
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
(event.key === 'b' || event.key === 'B') &&
|
||||
!writing
|
||||
) {
|
||||
event.preventDefault();
|
||||
sidebar.toggle();
|
||||
return;
|
||||
@@ -250,6 +315,15 @@
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
|
||||
komponenten.
|
||||
|
||||
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
|
||||
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
|
||||
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
|
||||
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
|
||||
<DictateSheet />
|
||||
</div>
|
||||
|
||||
<CommandPalette bind:open={paletteOpen} />
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
{#if status === 'denied'}
|
||||
|
||||
@@ -379,7 +379,10 @@
|
||||
<!-- Each card is written in the face it selects, because that is the
|
||||
only part of the choice a name cannot carry. -->
|
||||
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
||||
<span class="theme-name">{t(option.name)}</span>
|
||||
<!-- The product name is passed to every look, and only one of them has a
|
||||
place for it: the look named after the program. The other three
|
||||
carry no placeholder, so the parameter is ignored. -->
|
||||
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
|
||||
<span class="theme-note">{t(option.note)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -141,6 +141,13 @@
|
||||
let startDate = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
|
||||
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
|
||||
// fejlbesked på indstillingssiden frem for en tom liste.
|
||||
if (app.notesOnly) {
|
||||
loadingTemplates = false;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.listTemplates()
|
||||
.then((r) => (templates = r.templates))
|
||||
@@ -393,6 +400,8 @@
|
||||
do with a Content-Disposition, and the session cookie rides along. -->
|
||||
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||||
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
|
||||
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
|
||||
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
|
||||
</div>
|
||||
|
||||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
||||
@@ -410,6 +419,8 @@
|
||||
<p class="hint">{t('data.importNotesHint')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
|
||||
projekt, og det er en anden opgave end at flytte hele kontoen. -->
|
||||
<div class="field">
|
||||
<label for="export-project">{t('data.exportOne')}</label>
|
||||
<select id="export-project" bind:value={exportProject}>
|
||||
|
||||
@@ -296,7 +296,11 @@
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
// Feedet er en kalender af opgaver, så adressen findes ikke i
|
||||
// noter-udgaven.
|
||||
if (!app.notesOnly) {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
}
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
let planBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Dagens plan er dagens opgaver.
|
||||
if (app.notesOnly) return;
|
||||
api
|
||||
.planSettings()
|
||||
.then((p) => (plan = p))
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* and it reads the token out of the URL itself.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
|
||||
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
|
||||
// pointen med den her rute.
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Invitation · verdande</title></svelte:head>
|
||||
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
|
||||
@@ -933,7 +933,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onclick={onShareOutside}
|
||||
|
||||
@@ -88,9 +88,14 @@
|
||||
if (!projectId) return;
|
||||
status = 'loading';
|
||||
try {
|
||||
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
|
||||
// — og det er ikke bare støj: Promise.all kaster på den første, der
|
||||
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
|
||||
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
|
||||
// butikken, fordi den har tre kaldesteder her.
|
||||
const [p, s] = await Promise.all([
|
||||
api.getProject(projectId),
|
||||
api.listSections(projectId),
|
||||
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
|
||||
app.loadTasks({ project_id: projectId })
|
||||
]);
|
||||
project = p;
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
* fill in. Same shape as the invite link, and found the same way.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
|
||||
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
|
||||
// pointen med den her rute.
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
|
||||
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
/**
|
||||
* Urds dør.
|
||||
*
|
||||
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
|
||||
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
|
||||
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
|
||||
* går videre til noterne.
|
||||
*
|
||||
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
|
||||
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
|
||||
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
|
||||
* halvdel bliver vedligeholdt.
|
||||
*
|
||||
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
|
||||
* der kun omdirigerer.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
onMount(() => goto('/noter', { replaceState: true }));
|
||||
</script>
|
||||
|
||||
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
|
||||
være et glimt af noget, ingen nåede at læse. -->
|
||||
<svelte:head><title>urd</title></svelte:head>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Urd's mark: ð, the eth in Urðr. Drawn in Verdande's strokes so the two read
|
||||
as one family — same width, same round caps, same plate.
|
||||
|
||||
NOT a rune, and that is the whole story of this file. The obvious mark was
|
||||
ᚢ uruz, the letter the name begins with in the elder futhark, exactly as
|
||||
Verdande's is ᚹ wunjo. It was drawn that way first. Then: uruz is already
|
||||
one of this author's products, and uruz/scripts/gen-icons.ts says in its own
|
||||
comment that it "draws the Uruz rune (ᚢ) in rune-gold" with "a tall left
|
||||
vertical, a shoulder across the top, and a shorter" one — the same glyph,
|
||||
the same construction, differing only in colour. Two of one person's apps
|
||||
side by side on a home screen, which is precisely where this mark is meant
|
||||
to be distinguishable.
|
||||
|
||||
ᚦ thurisaz was considered next, for the ð itself, and rejected for the same
|
||||
reason one level down: it is a stave with a triangle on its right, and so is
|
||||
ᚹ. At 16px they are one shape.
|
||||
|
||||
So the letter rather than the rune. ð is unmistakably Nordic, nothing else
|
||||
here uses it, and it is a closed bowl where both neighbours are open staves.
|
||||
|
||||
Centred by counting painted pixels — white on black, headless Chromium, the
|
||||
plate removed so the card is not what gets measured — rather than from the
|
||||
path numbers, because a centred box is not a centred glyph:
|
||||
|
||||
verdande ᚹ ink x 53–108 centre 80.5 · y 18–131 centre 74.5
|
||||
urd ð ink x 39–110 centre 74.5 · y 15–134 centre 74.5
|
||||
|
||||
Verdande's +5.5 in x is its own deliberate nudge: a heavy stave beside a
|
||||
light flag has to sit right of centre for the ink to look centred. ð is
|
||||
symmetric enough not to need it, and the two agree exactly in y.
|
||||
|
||||
Legibility, measured rather than hoped: it reads as ð at 32px and above. At
|
||||
16px it is a blob — and so is ᚹ, and a favicon is not what tells two
|
||||
installed apps apart in a dock. -->
|
||||
<rect width="64" height="64" rx="14" fill="#111312"/>
|
||||
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="32" cy="42" r="12"/>
|
||||
<path d="M44 36L27 10"/>
|
||||
<path d="M25 20h18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -14,6 +14,37 @@ export default {
|
||||
}),
|
||||
alias: {
|
||||
$lib: 'src/lib'
|
||||
},
|
||||
|
||||
/**
|
||||
* The build's own version string, which SvelteKit puts in
|
||||
* `_app/version.json` and the service worker reads to decide whether a new
|
||||
* version has been deployed.
|
||||
*
|
||||
* Its default is `Date.now()`, and that made every build of this frontend
|
||||
* produce different bytes: the millisecond lands in version.json, in
|
||||
* index.html and in service-worker.js, which changes the content hash of
|
||||
* every chunk that references them, which changes those chunks' filenames.
|
||||
* So two builds of the same commit had different file names in them.
|
||||
*
|
||||
* That is not an aesthetic problem, and it had two costs. The Dockerfile
|
||||
* said `-trimpath` and an empty buildid "keep the output reproducible", and
|
||||
* that sentence had been false for a year — the flags did their job and the
|
||||
* frontend embedded in the binary carried a millisecond. And the Dockerfile
|
||||
* builds one binary into two images, verdande and urd, where the claim is
|
||||
* that they are the same program rather than a fork; with a timestamp in the
|
||||
* build, that claim could not be checked, because no two builds ever
|
||||
* matched. CI checks it now.
|
||||
*
|
||||
* Measured by building twice and diffing the tree, before and after.
|
||||
*
|
||||
* `dev` when nothing sets it, so a local build is stable too. The cost is
|
||||
* that a local `npm run build` no longer looks like a new version to the
|
||||
* service worker — which only matters on a deployed instance, where the
|
||||
* release workflow passes the real tag.
|
||||
*/
|
||||
version: {
|
||||
name: process.env.VERDANDE_VERSION ?? 'dev'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user