Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff988d26bb | ||
|
|
b451fd585d | ||
|
|
8c3aa33f13 | ||
|
|
c6c7979d1a | ||
|
|
6154e6b247 | ||
|
|
7e3d300183 | ||
|
|
3cee9bfb6e | ||
|
|
9b067f7057 | ||
|
|
0c45080dc2 | ||
|
|
bba2dab209 | ||
|
|
438095e252 | ||
|
|
3132bba7e5 | ||
|
|
44b28f1cd9 | ||
|
|
b497b76be4 | ||
|
|
dba99b7ecc | ||
|
|
f34690f41a | ||
|
|
1bb2c9cda6 | ||
|
|
cac6c14a23 | ||
|
|
f632fb8643 | ||
|
|
eabb00ecc2 | ||
|
|
d2b5821e57 | ||
|
|
4874f7bbd8 | ||
|
|
8bf4f8fbe4 | ||
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 | ||
|
|
de673260a3 | ||
|
|
804c81f74e | ||
|
|
87054ac8ca | ||
|
|
c62dcc3d7c | ||
|
|
0110a86858 | ||
|
|
c868be8f89 | ||
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 | ||
|
|
9b21b3eac5 | ||
|
|
a39f655565 | ||
|
|
7e981354c2 | ||
|
|
1f0a43ac90 | ||
|
|
61f8887319 | ||
|
|
72637a153a | ||
|
|
f06a25b43e | ||
|
|
6cf184caa6 | ||
|
|
3c14906db2 | ||
|
|
70d7398d5a | ||
|
|
1cabbb7a78 |
+21
-1
@@ -97,7 +97,13 @@ jobs:
|
|||||||
# -race is the whole point of running tests in CI for a server with a
|
# -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.
|
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||||
- name: Test
|
- 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
|
- name: Build
|
||||||
run: CGO_ENABLED=0 go build ./...
|
run: CGO_ENABLED=0 go build ./...
|
||||||
@@ -271,3 +277,17 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||||
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
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
|
# -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.
|
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||||
- name: Test
|
- 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
|
- name: Build
|
||||||
run: CGO_ENABLED=0 go 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
|
# 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
|
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||||
# than at release time.
|
# 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
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: verdande:ci
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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'
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
name: Documentation
|
name: Documentation
|
||||||
|
|
||||||
# The docs are built on every push so a broken build is caught immediately, but
|
# Built on every push, and published from main.
|
||||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
#
|
||||||
# owner decides to publish, this job builds and stops.
|
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||||
|
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||||
|
# changed, so the site was built forty times and published none of them. What it
|
||||||
|
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
|
||||||
|
# all answered 404, months after they were written. Nothing failed, because
|
||||||
|
# building *is* what the job was asked to do.
|
||||||
|
#
|
||||||
|
# The lesson is not about this line. It is that "publish later" needs something
|
||||||
|
# that fails when later arrives — a switch that is off and silent looks exactly
|
||||||
|
# like a switch that is on.
|
||||||
|
#
|
||||||
|
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
|
||||||
|
# link to a page that does not exist, and building only after the merge means
|
||||||
|
# finding that out on `main`.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -10,6 +23,11 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'mkdocs.yml'
|
- 'mkdocs.yml'
|
||||||
- '.github/workflows/docs.yml'
|
- '.github/workflows/docs.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'mkdocs.yml'
|
||||||
|
- '.github/workflows/docs.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -44,9 +62,9 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Only runs once Pages is enabled. Until then the build above still guards
|
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||||
# against a broken docs tree.
|
# publish over the site by opening one.
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name != 'pull_request'
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deploy.outputs.page_url }}
|
url: ${{ steps.deploy.outputs.page_url }}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
|
||||||
|
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
|
||||||
|
# commit'en uden den.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@v3
|
- uses: docker/setup-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
@@ -60,7 +65,59 @@ jobs:
|
|||||||
#
|
#
|
||||||
# Created after the image, so a release never announces a version that
|
# Created after the image, so a release never announces a version that
|
||||||
# failed to build.
|
# failed to build.
|
||||||
|
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
|
||||||
|
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
|
||||||
|
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
|
||||||
|
# commit-overskrifter, som er skrevet til dem, der læser koden.
|
||||||
|
#
|
||||||
|
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||||
|
# side, der linker til sig selv, hjælper ingen.
|
||||||
|
#
|
||||||
|
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||||
|
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||||
|
# netop den ene, der udløste kørslen:
|
||||||
|
#
|
||||||
|
# git rev-parse refs/tags/v0.46.0
|
||||||
|
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||||
|
#
|
||||||
|
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||||
|
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||||
|
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||||
|
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||||
|
#
|
||||||
|
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||||
|
- id: notes
|
||||||
|
run: |
|
||||||
|
git fetch --force origin \
|
||||||
|
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
|
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||||
|
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||||
|
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||||
|
|
||||||
|
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||||
|
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||||
|
# frem for at blive opdaget på siden bagefter.
|
||||||
|
case "$body" in
|
||||||
|
"${GITHUB_REF_NAME}"*) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||||
|
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo 'body<<VERDANDE_EOF'
|
||||||
|
printf '%s\n' "$body"
|
||||||
|
echo VERDANDE_EOF
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2
|
- uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
body: ${{ steps.notes.outputs.body }}
|
||||||
|
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
|
||||||
|
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
|
||||||
|
# ændringer det var, kan læse det samme sted.
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ node_modules.nosync/
|
|||||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||||
# signs in with, and whatever a failure left behind.
|
# signs in with, and whatever a failure left behind.
|
||||||
/web/.playwright-data/
|
/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/e2e/.auth/
|
||||||
/web/test-results/
|
/web/test-results/
|
||||||
/web/playwright-report/
|
/web/playwright-report/
|
||||||
|
|||||||
+35
-1
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY web/ ./
|
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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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; -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 \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||||
-tags embedweb \
|
-tags embedweb \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
|||||||
-o /verdande ./cmd/verdande
|
-o /verdande ./cmd/verdande
|
||||||
|
|
||||||
# --- 3. what actually ships --------------------------------------------------
|
# --- 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
|
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,
|
# 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.
|
# 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
|
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||||
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
|
|||||||
# nightly backups.
|
# nightly backups.
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
ENV VERDANDE_DATA_DIR=/data \
|
ENV VERDANDE_DATA_DIR=/data \
|
||||||
VERDANDE_ADDR=:8080
|
VERDANDE_ADDR=:8080 \
|
||||||
|
VERDANDE_EDITION=${EDITION}
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER nonroot:nonroot
|
USER nonroot:nonroot
|
||||||
|
|||||||
@@ -15,12 +15,30 @@
|
|||||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
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.
|
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
|
## Status
|
||||||
|
|
||||||
All six phases are built. See [What works today](#what-works-today) for what is
|
All six phases are built. See [What works today](#what-works-today) for what is
|
||||||
tested and what is not.
|
tested and what is not.
|
||||||
|
|
||||||
Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
|
||||||
|
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
@@ -37,17 +55,36 @@ and an MCP endpoint so Claude can work your task list directly.
|
|||||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||||
views, and foldable groups over the projects in the sidebar.
|
views, and foldable groups over the projects in the sidebar.
|
||||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
lists, quotes, fenced code. A note is shared by filing it in a project, by
|
||||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
handing it to one person — including somebody who has no account yet, who gets an
|
||||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
invitation that becomes the share when they sign up — or by writing `@Sofie` in
|
||||||
|
the text, which shares it with her and tells her. `#project` and
|
||||||
|
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||||
|
- **Notes are sealed at rest.** Title and body are encrypted in the database under
|
||||||
|
a key in a file beside it — the title too, because a title beside a sealed body
|
||||||
|
leaves the beginning of every note in the open. A copy of the file is not a copy
|
||||||
|
of the notes.
|
||||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||||
a group, drop a task on another project, or drop it on another day.
|
a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||||
everybody arrives through an invite link, either to a project or — from
|
everybody arrives through an invite link — to a project, to a single note, or
|
||||||
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
from **Settings → Brugere** to the instance itself. Nobody's password is ever
|
||||||
by somebody else.
|
chosen by somebody else.
|
||||||
- **Live sync.** A change made by somebody else appears without a refresh.
|
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||||
|
- **Notifications that are worth reading.** A bell for what other people did to what
|
||||||
|
you share — folded, so one person typing for five minutes is one line — pushed to
|
||||||
|
your devices and counted on the app's icon in the Dock.
|
||||||
|
- **AI that suggests and never writes.** Optional, off by default, your own key and
|
||||||
|
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
|
||||||
|
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
|
||||||
|
action the mail asks for, and answers questions about your own notes with links to
|
||||||
|
what the answer rests on. Every one of them arrives as a line you accept, edit or
|
||||||
|
refuse — a model that edits your tasks directly has to be right every time to be
|
||||||
|
worth having.
|
||||||
|
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
|
||||||
|
or a POST of one line of text from a shortcut on your phone, a script or any
|
||||||
|
service that can call a URL.
|
||||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||||
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
||||||
@@ -113,6 +150,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
|
||||||
|
| `@name` in a note shares it with that person, and tells them | Done, tested |
|
||||||
|
| Share a project with somebody who already has an account, by name | Done, tested |
|
||||||
|
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
|
||||||
|
| Told when a project is shared with you — and able to leave it | Done, tested |
|
||||||
|
| Push from other programs: a webhook address per person | Done, tested |
|
||||||
|
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
|
||||||
|
| Search the settings, from the page or from ⌘K | Done, tested |
|
||||||
|
| Choose which browser a link opens in | Done, tested |
|
||||||
|
| Images in notes: open full size, copy to the clipboard | Done, tested |
|
||||||
|
| Install count: two values, once a day, one click to stop | Done, tested |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||||
implemented against their specifications and have only ever been exercised there,
|
implemented against their specifications and have only ever been exercised there,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ var version = "dev"
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
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)
|
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,9 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log := httpapi.NewLogger(cfg.Dev)
|
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
|
// 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
|
// front so a failure here is a startup error rather than a surprise at the
|
||||||
|
|||||||
+78
-9
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
|
|||||||
a page that repopulates a password field is one that will eventually leak it into a
|
a page that repopulates a password field is one that will eventually leak it into a
|
||||||
screenshot.
|
screenshot.
|
||||||
|
|
||||||
|
## The shape of all of it
|
||||||
|
|
||||||
|
**The model proposes; you decide.** Nothing is written until you say yes, and the
|
||||||
|
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
|
||||||
|
p1 #Firma` — sitting in a field you can edit before you accept it.
|
||||||
|
|
||||||
|
That is not caution for its own sake. A model that edits your tasks directly has to
|
||||||
|
be right *every* time to be worth having. One that suggests only has to be right
|
||||||
|
often enough to save you some typing, and when it is wrong you have already seen it.
|
||||||
|
|
||||||
|
It is also why the suggestion is a line rather than a set of fields: there is one
|
||||||
|
reading of "i morgen" in this program, and a suggestion goes through the same
|
||||||
|
[quick add](quick-add.md) parser as anything you type — including after you have
|
||||||
|
edited it by hand.
|
||||||
|
|
||||||
|
The model is given the names of the projects that exist and may name no others. A
|
||||||
|
`#Projekt` it invented would create one by accident the first time you accepted it,
|
||||||
|
and a guess must not be able to move a task somewhere you will not look for it.
|
||||||
|
|
||||||
## What it can do
|
## What it can do
|
||||||
|
|
||||||
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
|
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
|
||||||
seven concrete actions and creates them as sub-tasks. Written in your language.
|
line it suggests how you would have written it if you had finished it — project,
|
||||||
|
date, priority — with one sentence on what it read out of the task. Accept, edit, or
|
||||||
|
skip, one line at a time.
|
||||||
|
|
||||||
|
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
|
||||||
|
somebody committed to or was asked to do; a heading is not a task and a topic that
|
||||||
|
was discussed is not a task. Each suggestion quotes the words it came from, so a
|
||||||
|
wrong one can be seen through rather than merely being wrong. The note itself is
|
||||||
|
never edited — the task carries `[[the note's title]]` in its description instead.
|
||||||
|
|
||||||
|
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
|
||||||
|
default; pick the hour yourself. One notification about what is due today and what
|
||||||
|
has been missed, and what to start with — and the same text as a card at the top of
|
||||||
|
**I dag**, where you are looking anyway when you start the day. The notification is
|
||||||
|
the reminder; the card is where it stays.
|
||||||
|
|
||||||
|
The card shows the plan *as it was sent*, with the time it was made, rather than a
|
||||||
|
new one each time you look: a plan that says something different when you look at it
|
||||||
|
than it said when it arrived is worse than either. **Forny planen** makes a new one
|
||||||
|
when the day has moved, and it does not send a notification about something you are
|
||||||
|
already looking at. With the morning notification off, the card offers to make one
|
||||||
|
on the spot.
|
||||||
|
|
||||||
|
The counts are done in the database and handed to the model as facts it may not
|
||||||
|
recompute — a plan that says four tasks where there are three is worse than no plan.
|
||||||
|
With no model configured the counts stand on their own, which is what you needed to
|
||||||
|
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
|
||||||
|
|
||||||
|
**Mail that becomes a task you can act on.** Where a
|
||||||
|
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
||||||
|
12"*, the model writes the action the mail asks for — and leaves the mail alone if
|
||||||
|
it asks for nothing. The subject stays in the description, so a mail it misread can
|
||||||
|
still be read. If the model is unavailable the task is named sender and subject as
|
||||||
|
before: a model that is down must not be able to stop the post.
|
||||||
|
|
||||||
|
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
|
||||||
|
Anders i august?"* — search finds what contains the words, this answers the
|
||||||
|
question, and the answer links to the notes and tasks it rests on. If nothing
|
||||||
|
matches, it says so; a guess would be useless here, because you are asking precisely
|
||||||
|
because you cannot remember.
|
||||||
|
|
||||||
|
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
|
||||||
|
actions, created as sub-tasks.
|
||||||
|
|
||||||
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
||||||
what appears to be slipping. At most six lines — it is a prompt to think, not a
|
what appears to be slipping. At most six lines — a prompt to think, not a report.
|
||||||
report.
|
|
||||||
|
|
||||||
## What is sent
|
## What is sent
|
||||||
|
|
||||||
Only what the feature needs: for a split, the task's title and notes; for a
|
Only what the feature needs, and only when you invoke it:
|
||||||
summary, the titles, dates and priorities of your open tasks. Never comments,
|
|
||||||
attachments, or anything from a project the feature was not invoked on.
|
|
||||||
|
|
||||||
Nothing is sent to anybody until you configure a provider. With a local model,
|
| Feature | What leaves the instance |
|
||||||
nothing leaves your machine at all.
|
| --- | --- |
|
||||||
|
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
|
||||||
|
| Tasks in a note | That one note's text, and your project names |
|
||||||
|
| Today's plan | The titles and dates of what is due or overdue today |
|
||||||
|
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
|
||||||
|
| Ask | Your question, and the notes and tasks the search matched |
|
||||||
|
| Split a task | That task's title and notes |
|
||||||
|
| Weekly summary | The titles, dates and priorities of your open tasks |
|
||||||
|
|
||||||
|
Never comments, never attachments, never a project the feature was not invoked on,
|
||||||
|
and never anything belonging to somebody else. Nothing is sent to anybody until you
|
||||||
|
configure a provider, and with a local model nothing leaves your machine at all.
|
||||||
|
|
||||||
## Turning it off
|
## Turning it off
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
||||||
| `GET` `POST` | `/projects/{id}/sections` | |
|
| `GET` `POST` | `/projects/{id}/sections` | |
|
||||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||||
| `GET` | `/projects/{id}/members` | |
|
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
|
||||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
|
||||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
|
||||||
|
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
|
||||||
| `GET` | `/projects/{id}/activity` | |
|
| `GET` | `/projects/{id}/activity` | |
|
||||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# The install count
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
|
||||||
|
"version": "v0.44.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 — 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.
|
||||||
|
|
||||||
|
## On by default, off in one click
|
||||||
|
|
||||||
|
The alternative that was considered first was compulsory, and the argument against
|
||||||
|
it is practical rather than principled: it cannot be enforced. The domain can be
|
||||||
|
blocked, the binary can be patched, and a fork removes it in its first commit. So
|
||||||
|
mandatory buys nothing over on-by-default while costing the trust the rest of this
|
||||||
|
program works for.
|
||||||
|
|
||||||
|
Which is why the first administrator to sign in is **told, unprompted**, with the
|
||||||
|
real id, the real version and the real address in front of them, and two buttons.
|
||||||
|
On by default is only defensible if it is said out loud without being asked; a
|
||||||
|
settings page where it is stated fully is not the same thing, because that page is
|
||||||
|
opened by people who already suspect there is something to read.
|
||||||
|
|
||||||
|
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
|
||||||
|
|
||||||
|
!!! note "This disagrees with the update check, on purpose"
|
||||||
|
|
||||||
|
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
|
||||||
|
a self-hosted app reaching out unbidden has broken the deal its operator made by
|
||||||
|
self-hosting. That is the same situation as this one, and the two defaults do
|
||||||
|
not agree. The case for treating them differently: the update check reveals an
|
||||||
|
instance to a third party (GitHub) and returns something you can act on, while
|
||||||
|
this returns nothing and goes to the project. Whether that is a distinction or
|
||||||
|
an excuse is a judgement call, and it was made deliberately.
|
||||||
|
|
||||||
|
## What the collector cannot see, and what it can
|
||||||
|
|
||||||
|
A request has a source address whether or not anybody writes it down. The collector
|
||||||
|
stores none and logs none, and the code is arranged so the address never reaches a
|
||||||
|
function that could — the table has nowhere to put one. But the request passes
|
||||||
|
through a reverse proxy and a tunnel on the way, and those keep their own logs.
|
||||||
|
"We send no IP" would be a half-truth; what is true is that nothing here records
|
||||||
|
one.
|
||||||
|
|
||||||
|
The ping is not authenticated either, so anybody can post a made-up id and inflate
|
||||||
|
the count. That is an accepted trade: a token would have to ship inside every
|
||||||
|
install anyway.
|
||||||
|
|
||||||
|
## Running your own count
|
||||||
|
|
||||||
|
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
|
||||||
|
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
|
||||||
|
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
|
||||||
|
nobody made a collector must not become a bucket anyone can post into.
|
||||||
|
|
||||||
|
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
|
||||||
|
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
|
||||||
|
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
|
||||||
|
published as a fact — a bad release, a DNS change that quietly stops the pings, or
|
||||||
|
lost rows all look like "fewer people use this now" — and a small number mostly says
|
||||||
|
how little it takes to move it.
|
||||||
|
|
||||||
|
## When it cannot reach
|
||||||
|
|
||||||
|
The reason is written down and shown on the settings page. A beacon that cannot
|
||||||
|
reach its collector otherwise fails in complete silence: it tries again tomorrow,
|
||||||
|
the page says "last sent" with a date that quietly gets older, and nobody notices.
|
||||||
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
|
|||||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
| `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_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
|
## Behind a proxy
|
||||||
|
|
||||||
@@ -127,11 +128,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
|
|||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
|
||||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
|
|
||||||
|
With the check on, administrators are told **once per version** — in the bell, in
|
||||||
|
push and on the app's badge — rather than having to open the settings page and
|
||||||
|
look. The notification leads to the page where the restart button is. With the
|
||||||
|
check off nothing is fetched and nothing is said; see
|
||||||
|
[Notifications](notifications.md).
|
||||||
|
|
||||||
All three together let **Settings → Notifications** restart this instance,
|
All three together let **Settings → Notifications** restart this instance,
|
||||||
which is also how it picks up a new version: a container cannot replace its own
|
which is also how it picks up a new version: a container cannot replace its own
|
||||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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 being built as a second face of one
|
||||||
|
instance, with its own entry point and its own web manifest, so that both can be
|
||||||
|
installed as separate apps while sharing one database, one login and one set of
|
||||||
|
links. Until that lands, this page describes the edition only.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+22
-4
@@ -26,20 +26,38 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
The calendar draws each task to its length — drag the foot to resize, the body
|
The calendar draws each task to its length — drag the foot to resize, the body
|
||||||
to move it.
|
to move it.
|
||||||
|
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
|
||||||
|
settings themselves, by what they *do* rather than only by what they are called,
|
||||||
|
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
|
||||||
|
settings pages carry the same field at the top of their own list.
|
||||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||||
single note with a person, or file it in a project; `#project` and
|
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||||
`[[another note]]` are links, and a task shows what has been written about it.
|
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||||
|
and a task shows what has been written about it.
|
||||||
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||||
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||||
greyed, at the foot of the list until a moment you pick — without touching when
|
greyed, at the foot of the list until a moment you pick — without touching when
|
||||||
it is due.
|
it is due.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
under a group, drop a task on another project, or drop it on another day.
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||||
joins through an invite link.
|
account here you pick by name — the accounts on an instance are a closed,
|
||||||
|
invited set, so they are an address book rather than a directory to protect.
|
||||||
|
Anyone outside your instance joins through an invite link, and the invitation
|
||||||
|
stays visible until they arrive, so it does not get sent twice.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
|
- **[Told when it matters](notifications.md).** A bell for what other people did to
|
||||||
|
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||||
|
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
|
||||||
|
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
|
||||||
|
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
|
||||||
|
answers questions about your own notes with links to what the answer rests on —
|
||||||
|
every one of them as a line you accept, edit or refuse.
|
||||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||||
out as Web Push to an iPhone or a Mac.
|
out as Web Push to an iPhone or a Mac.
|
||||||
|
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
|
||||||
|
address, or a POST from a shortcut on your phone, a script, or any service that
|
||||||
|
can call a URL.
|
||||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Opening links
|
||||||
|
|
||||||
|
A link in a task or a note opens in whichever browser your system opens links in.
|
||||||
|
**Settings → Links** can try to change that — and the honest description of what it
|
||||||
|
does is *try*.
|
||||||
|
|
||||||
|
## What a web page cannot do
|
||||||
|
|
||||||
|
Two things, and they set the limits of this feature:
|
||||||
|
|
||||||
|
- **It cannot see which browsers are installed.** The list is deliberately closed:
|
||||||
|
it would be a fingerprint, distinctive enough to follow somebody from site to
|
||||||
|
site.
|
||||||
|
- **It cannot ask the system to use a particular one.** A link goes to the default
|
||||||
|
browser, and there is no API that says otherwise.
|
||||||
|
|
||||||
|
The only handle that exists is the URL schemes browsers register for themselves —
|
||||||
|
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
|
||||||
|
way to ask whether one exists. You can only try it.
|
||||||
|
|
||||||
|
## So the setting is an attempt
|
||||||
|
|
||||||
|
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
|
||||||
|
link, that scheme is tried first; if nothing has happened within about half a
|
||||||
|
second, the link opens the ordinary way.
|
||||||
|
|
||||||
|
The fallback is the whole reason this is safe to turn on. Without it, a setting
|
||||||
|
that works on your phone would leave links doing nothing on your desktop.
|
||||||
|
|
||||||
|
In practice: **it works on a phone**, where browsers register these schemes, and
|
||||||
|
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
|
||||||
|
the usual exception. There is a **Prøv det** button for exactly this reason — you
|
||||||
|
cannot ask whether a scheme exists, so it is better to find out on example.dk than
|
||||||
|
on a link you meant to follow.
|
||||||
|
|
||||||
|
## Writing your own
|
||||||
|
|
||||||
|
Three placeholders, because browsers disagree about the shape they want the address
|
||||||
|
in:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `{url}` | The address as it is — `x-safari-https://example.dk` |
|
||||||
|
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
|
||||||
|
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
|
||||||
|
|
||||||
|
## It belongs to the machine, not the account
|
||||||
|
|
||||||
|
The choice is kept in the browser you are sitting at, not on your account — the
|
||||||
|
same rule the sidebar's width follows. `firefox://` works on the phone and does
|
||||||
|
nothing on the Mac, so a choice that followed the account would be wrong in one of
|
||||||
|
the two places every time.
|
||||||
@@ -67,3 +67,36 @@ immediately.
|
|||||||
|
|
||||||
!!! tip "Keep the address to yourself"
|
!!! tip "Keep the address to yourself"
|
||||||
Treat it like a secret. If you publish it somewhere, rotate it.
|
Treat it like a secret. If you publish it somewhere, rotate it.
|
||||||
|
|
||||||
|
## Pushing from another program
|
||||||
|
|
||||||
|
The address above needs a mail server. When what you want is a shortcut on your
|
||||||
|
phone, a script, or any service that can call a URL, there is a second way in:
|
||||||
|
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
|
||||||
|
POST a line of text to.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://todo.example.dk/inbound/hook/<token> \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "Ring til Anders i morgen p1 #Firma"
|
||||||
|
```
|
||||||
|
|
||||||
|
The body is read three ways, because those are the three you meet: plain text from
|
||||||
|
a shortcut that just sends what was selected, JSON with a `text` field from a
|
||||||
|
service, or a form field from a webhook form. The first line becomes the task and
|
||||||
|
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
|
||||||
|
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
|
||||||
|
top of the app.
|
||||||
|
|
||||||
|
**The address is a key.** It has its own token rather than sharing the mail one:
|
||||||
|
the two are the same kind of secret but they leak separately, and a URL that has
|
||||||
|
been sitting in a shortcut on a lost phone has to be replaceable without changing
|
||||||
|
the address other people have in their address books. A token here can do one
|
||||||
|
thing — put a task in your inbox — which is why it is not an
|
||||||
|
[API token](api.md), which can do everything. Change it under the same setting; the
|
||||||
|
old one stops working at once.
|
||||||
|
|
||||||
|
**POST only.** A GET would be easier to call, and that is exactly the problem: a
|
||||||
|
browser prefetch, a link check in a chat, and a revisited history entry would all
|
||||||
|
create tasks nobody asked for.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
|
|||||||
it is a document that can run script, and a note is not a place to run somebody
|
it is a document that can run script, and a note is not a place to run somebody
|
||||||
else's.
|
else's.
|
||||||
|
|
||||||
|
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||||
|
column — a photo off a phone is four thousand pixels across — so what you are
|
||||||
|
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||||
|
button that copies the picture to the clipboard, and a link that opens it in its
|
||||||
|
own tab. Escape closes it and puts the cursor back where it was.
|
||||||
|
|
||||||
|
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||||
|
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||||
|
either fails or succeeds with something nothing will paste.
|
||||||
|
|
||||||
### Code blocks
|
### Code blocks
|
||||||
|
|
||||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||||
@@ -79,6 +89,37 @@ There is no folder tree. Projects, groups and labels already exist, are already
|
|||||||
shared and already have roles — a second hierarchy to file things in is one too
|
shared and already have roles — a second hierarchy to file things in is one too
|
||||||
many, and a note in a project inherits every one of those answers.
|
many, and a note in a project inherits every one of those answers.
|
||||||
|
|
||||||
|
### Sharing one note with one person
|
||||||
|
|
||||||
|
Filing a note in a project is the right tool when the note belongs with a body of
|
||||||
|
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
|
||||||
|
at the foot of the note does the second: this note, this person, as reader or
|
||||||
|
editor. Only the note's owner can share it.
|
||||||
|
|
||||||
|
Type an **email address** rather than picking a name, and one of two things
|
||||||
|
happens. An address that already has an account is shared with straight away. One
|
||||||
|
that does not gets an invitation — a link that creates the account and the share
|
||||||
|
together, so the note is theirs the moment they sign up. Invitations wait in the
|
||||||
|
panel under the note, where they can be withdrawn; that is the only way back, since
|
||||||
|
only the token's hash is stored and a link sent to the wrong address cannot be
|
||||||
|
looked up.
|
||||||
|
|
||||||
|
### Naming somebody in a note
|
||||||
|
|
||||||
|
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
|
||||||
|
note *is* wanting them to read it, and doing it as two acts — write the name, then
|
||||||
|
open the panel and find the person — meant the second was forgotten, and the note
|
||||||
|
sat there addressing somebody who could not see it. The editor suggests names after
|
||||||
|
`@`, the same way `#` suggests projects.
|
||||||
|
|
||||||
|
- **Only the owner's mentions share.** An editor may write the name; giving
|
||||||
|
somebody else's note away is the owner's alone.
|
||||||
|
- **As a reader**, which is the least that satisfies "look at this". A role you
|
||||||
|
have raised in the panel is never lowered by a mention.
|
||||||
|
- **Only a name that has just appeared** counts. A note is saved on every typing
|
||||||
|
pause, so without that every save would be another share and another notification
|
||||||
|
about the same name.
|
||||||
|
|
||||||
## Finding
|
## Finding
|
||||||
|
|
||||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||||
@@ -97,6 +138,14 @@ Search finds a word wherever it is:
|
|||||||
body. A word in the title is what the note is about; the same word once in a long
|
body. A word in the title is what the note is about; the same word once in a long
|
||||||
body is a mention.
|
body is a mention.
|
||||||
|
|
||||||
|
Two places to search from, and they now agree. The field above the list filters it
|
||||||
|
in place. **⌘K** searches everything at once — tasks, projects, notes and the
|
||||||
|
settings — and picking a note from it opens that note *with the rest of the matches
|
||||||
|
still in the list beside it*. It used to drop you on the ordinary list, so the
|
||||||
|
other results existed only in the popup you had just closed. The address carries
|
||||||
|
the search, which also means a search worth keeping can be bookmarked or sent to
|
||||||
|
somebody.
|
||||||
|
|
||||||
## Putting notes away
|
## Putting notes away
|
||||||
|
|
||||||
Two different things, and the difference matters:
|
Two different things, and the difference matters:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
What other people did that you need to know about, and what the day has in it.
|
||||||
|
Verdande tells you in three places at once, and they are the same thing said three
|
||||||
|
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
|
||||||
|
the app's icon.
|
||||||
|
|
||||||
|
## What causes one
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||||
|
| Somebody shared a note with you | Once, when it happens |
|
||||||
|
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||||
|
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||||
|
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||||
|
| Somebody commented | |
|
||||||
|
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
|
||||||
|
| A new version is out | Administrators only, once per version |
|
||||||
|
|
||||||
|
Not your own actions: the server does not tell anybody what they just did
|
||||||
|
themselves. And not everything that happens — a bell that rings at every change in
|
||||||
|
a shared project is a bell people stop looking at.
|
||||||
|
|
||||||
|
**Repeat edits are folded.** A note is saved on every typing pause, so one person
|
||||||
|
writing for five minutes would otherwise be twenty identical lines. While the
|
||||||
|
message is unread, a further edit moves it back to the top and says who touched it
|
||||||
|
last. Once you have read it, the next edit is a new thing to be told.
|
||||||
|
|
||||||
|
## Push, and the badge
|
||||||
|
|
||||||
|
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
|
||||||
|
device: the phone in your pocket and the laptop you have open are different
|
||||||
|
answers, and a permission granted on one is not the other's.
|
||||||
|
|
||||||
|
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
|
||||||
|
PC — needs the app to be installed (added to the Dock, or installed as an app in
|
||||||
|
Chrome or Edge). Half the point of being told is being told while you are doing
|
||||||
|
something else, and the icon is the only place the app can say anything then. It
|
||||||
|
counts unread messages and goes down by itself as you read them. Firefox does not
|
||||||
|
support it; nothing else changes there.
|
||||||
|
|
||||||
|
The sentence you see is written in your browser from *what happened* and *who did
|
||||||
|
it*, so an English interface gets an English bell. The server's own wording is
|
||||||
|
Danish and is used only for push, which has no dictionary to look a phrase up in.
|
||||||
|
|
||||||
|
## Telling you about a new version
|
||||||
|
|
||||||
|
If the [update check](configuration.md) is on, administrators get one notification
|
||||||
|
per version — never one a day about the same one — and it leads to
|
||||||
|
**Settings → Beskeder**, where the button that restarts the instance to update it
|
||||||
|
sits. With the check off, nothing is fetched and nothing is said.
|
||||||
+164
-9
@@ -53,7 +53,7 @@ paths:
|
|||||||
/api/v1/auth/setup:
|
/api/v1/auth/setup:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
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: []
|
security: []
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -64,6 +64,16 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
needs_setup: { type: boolean }
|
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:
|
post:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
summary: Create the first account, which becomes the administrator.
|
summary: Create the first account, which becomes the administrator.
|
||||||
@@ -848,6 +858,20 @@ paths:
|
|||||||
project_id:
|
project_id:
|
||||||
type: string
|
type: string
|
||||||
description: Where it goes when the text names no project.
|
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:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Created
|
||||||
@@ -2307,7 +2331,15 @@ paths:
|
|||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
get:
|
get:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Who the project is shared with.
|
summary: Who the project is shared with, who can be added, and who has been invited.
|
||||||
|
description: >
|
||||||
|
Three lists in one answer, so the share panel opens complete in a single
|
||||||
|
round-trip. `candidates` is everybody with an account here who is not
|
||||||
|
already on the project — the instance's accounts are a closed, invited set,
|
||||||
|
so listing them to each other is the address book the feature needs, not a
|
||||||
|
disclosure. `pending` is the invitations sent to people who have no account
|
||||||
|
yet: without them an invitation sent yesterday is in neither of the other
|
||||||
|
two lists, and gets sent a second time.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2319,6 +2351,17 @@ paths:
|
|||||||
members:
|
members:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Member" }
|
items: { $ref: "#/components/schemas/Member" }
|
||||||
|
candidates:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Person" }
|
||||||
|
pending:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
|
role: { type: string, enum: [editor, viewer] }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}/members/{userID}:
|
/api/v1/projects/{projectID}/members/{userID}:
|
||||||
@@ -2360,8 +2403,19 @@ paths:
|
|||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
delete:
|
delete:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Remove somebody from a project. Its owner only.
|
summary: Remove somebody from a project — its owner, or yourself.
|
||||||
description: |
|
description: |
|
||||||
|
Two things through one route: the owner taking somebody off, and anybody
|
||||||
|
taking themselves off. The second is what makes a share something you can
|
||||||
|
undo. You are let into a project without being asked, so a way out that
|
||||||
|
does not go through asking its owner is the other half of that.
|
||||||
|
|
||||||
|
The owner cannot leave their own project. That is a transfer of ownership,
|
||||||
|
not an exit, and there is no transfer yet.
|
||||||
|
|
||||||
|
Pointing at somebody else's membership without owning the project answers
|
||||||
|
404 rather than 403: saying "you may not" confirms the membership exists.
|
||||||
|
|
||||||
Their tasks are unassigned rather than left pointing at somebody who can
|
Their tasks are unassigned rather than left pointing at somebody who can
|
||||||
no longer see the project — which is why changing a role is a PATCH and
|
no longer see the project — which is why changing a role is a PATCH and
|
||||||
not a remove-and-reinvite.
|
not a remove-and-reinvite.
|
||||||
@@ -2384,13 +2438,32 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [email, role]
|
|
||||||
properties:
|
properties:
|
||||||
email: { type: string, format: email }
|
user_id:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Somebody who already has an account here, picked from the
|
||||||
|
candidates in GET /members. One of user_id or email.
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: >
|
||||||
|
An address. If it belongs to an account they are added at
|
||||||
|
once; otherwise an invitation is sent.
|
||||||
role: { type: string, enum: [editor, viewer] }
|
role: { type: string, enum: [editor, viewer] }
|
||||||
responses:
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Added straight away — the person already had an account.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
added: { type: boolean }
|
||||||
|
user: { $ref: "#/components/schemas/Person" }
|
||||||
|
role: { type: string, enum: [editor, viewer] }
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Invited. No account yet, so a link was made.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -2399,8 +2472,30 @@ paths:
|
|||||||
link: { type: string }
|
link: { type: string }
|
||||||
emailed: { type: boolean }
|
emailed: { type: boolean }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/projects/{projectID}/invites/{inviteID}:
|
||||||
|
delete:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Withdraw an invitation to a project before it is used. Its owner only.
|
||||||
|
description: >
|
||||||
|
The only way back. Only the token's hash is stored, so a link sent to the
|
||||||
|
wrong address cannot be looked up and invalidated — only the row it belongs
|
||||||
|
to can.
|
||||||
|
parameters:
|
||||||
|
- name: projectID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: inviteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Withdrawn }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}/activity:
|
/api/v1/projects/{projectID}/activity:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -3072,6 +3167,40 @@ paths:
|
|||||||
to: { type: string, format: date }
|
to: { type: string, format: date }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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:
|
/api/v1/export/notes.zip:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -3688,7 +3817,21 @@ paths:
|
|||||||
/api/v1/ai/plan/now:
|
/api/v1/ai/plan/now:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
summary: Send today's plan now, to whoever asks.
|
summary: Make today's plan now, to whoever asks.
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
silent:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
Make and store it without sending a notification — the card on
|
||||||
|
Today asking for a plan while somebody is looking at it. A
|
||||||
|
notification about something in front of you is the kind that
|
||||||
|
teaches people to ignore the bell.
|
||||||
description: |
|
description: |
|
||||||
The counts come from the database and the sentence from the model, in that
|
The counts come from the database and the sentence from the model, in that
|
||||||
order: a model that counted for itself would sometimes count wrong, and a
|
order: a model that counted for itself would sometimes count wrong, and a
|
||||||
@@ -4545,8 +4688,9 @@ components:
|
|||||||
DailyPlanSettings:
|
DailyPlanSettings:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
One person's morning plan. Off by default — a notification at seven in the
|
One person's morning plan, and today's plan itself. One resource, because
|
||||||
morning that nobody asked for is not a help.
|
it is one thing: the plan is the resource and the setting is when it gets
|
||||||
|
made.
|
||||||
properties:
|
properties:
|
||||||
enabled: { type: boolean }
|
enabled: { type: boolean }
|
||||||
hour:
|
hour:
|
||||||
@@ -4555,6 +4699,17 @@ components:
|
|||||||
last_sent:
|
last_sent:
|
||||||
type: string
|
type: string
|
||||||
description: The date it last went out, in that person's own timezone.
|
description: The date it last went out, in that person's own timezone.
|
||||||
|
plan:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Today's plan as it was sent — kept rather than recomputed on every
|
||||||
|
look. A plan that says something different when you look at it than it
|
||||||
|
said when it arrived is worse than either. Absent when the stored plan
|
||||||
|
is from another day.
|
||||||
|
plan_date: { type: string }
|
||||||
|
plan_at:
|
||||||
|
type: integer
|
||||||
|
description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.
|
||||||
|
|
||||||
AISuggestion:
|
AISuggestion:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
|
|||||||
anywhere: the first person to open a fresh instance creates the administrator
|
anywhere: the first person to open a fresh instance creates the administrator
|
||||||
account, and after that people arrive by invite link.
|
account, and after that people arrive by invite link.
|
||||||
|
|
||||||
|
## Being let into a project
|
||||||
|
|
||||||
|
Two different situations, one panel — **the project's ⋯ menu → Share**.
|
||||||
|
|
||||||
|
Somebody who **already has an account here** you pick from a list by name. You do
|
||||||
|
not need to know their email address to share a project with a colleague whose
|
||||||
|
name you know. The accounts on an instance are a closed set — there is no open
|
||||||
|
signup, every one of them was invited — so listing them to each other is the
|
||||||
|
address book the feature needs, not a disclosure.
|
||||||
|
|
||||||
|
Somebody who is **not here yet** you invite by email. If the address turns out to
|
||||||
|
belong to an account it becomes an ordinary share straight away; otherwise it
|
||||||
|
becomes a link that creates their account and their membership at once. With no
|
||||||
|
mail server configured the link is shown in the panel instead of being sent, which
|
||||||
|
is the correct behaviour on a one-person instance rather than an error.
|
||||||
|
|
||||||
|
**You are told, and you can leave.** Being added to a project is not something
|
||||||
|
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||||
|
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||||
|
the project* button. That is deliberate rather than an approval step — an
|
||||||
|
invitation waiting on a yes means the person who shared thinks it is done while
|
||||||
|
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||||
|
added without being asked, without being told, and with no way out except asking
|
||||||
|
the owner. The owner cannot leave their own project; that is a transfer of
|
||||||
|
ownership, and there is no transfer yet.
|
||||||
|
|
||||||
|
An invitation that has not been taken up stays listed under the members, greyed,
|
||||||
|
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||||
|
so they are neither a member nor somebody you can pick — and the usual next move
|
||||||
|
is to send it a second time. The second link works exactly as well as the first,
|
||||||
|
and then there are two to keep track of.
|
||||||
|
|
||||||
## A code on top of it
|
## A code on top of it
|
||||||
|
|
||||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ type Config struct {
|
|||||||
// Empty means a key file beside the data — see internal/secret.
|
// Empty means a key file beside the data — see internal/secret.
|
||||||
SecretKey string
|
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
|
// 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
|
// for: a self-hosted app that reaches out without being told to has broken
|
||||||
// the deal its operator made by self-hosting.
|
// the deal its operator made by self-hosting.
|
||||||
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
|
|||||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||||
|
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
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")
|
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,19 @@ type planSettings struct {
|
|||||||
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
||||||
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
||||||
LastSent string `json:"last_sent,omitempty"`
|
LastSent string `json:"last_sent,omitempty"`
|
||||||
|
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
|
||||||
|
//
|
||||||
|
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
|
||||||
|
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
|
||||||
|
// andet end den besked, man fik i morges, er værre end begge dele hver for
|
||||||
|
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
|
||||||
|
// gæt hver gang man kigger.
|
||||||
|
//
|
||||||
|
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
|
||||||
|
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
|
||||||
|
Plan string `json:"plan,omitempty"`
|
||||||
|
PlanDate string `json:"plan_date,omitempty"`
|
||||||
|
PlanAt int64 `json:"plan_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
||||||
@@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings,
|
|||||||
if v, ok := stored["last_sent"].(string); ok {
|
if v, ok := stored["last_sent"].(string); ok {
|
||||||
out.LastSent = v
|
out.LastSent = v
|
||||||
}
|
}
|
||||||
|
if v, ok := stored["plan"].(string); ok {
|
||||||
|
out.Plan = v
|
||||||
|
}
|
||||||
|
if v, ok := stored["plan_date"].(string); ok {
|
||||||
|
out.PlanDate = v
|
||||||
|
}
|
||||||
|
if v, ok := stored["plan_at"].(float64); ok {
|
||||||
|
out.PlanAt = int64(v)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||||
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
||||||
|
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetPlanSettings er både indstillingen og dagens plan.
|
||||||
|
//
|
||||||
|
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
|
||||||
|
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
|
||||||
|
// to steder at huske at rydde en plan fra i går.
|
||||||
|
//
|
||||||
|
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
|
||||||
|
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
|
||||||
|
// et tomt kort.
|
||||||
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
|
user := userFrom(r.Context())
|
||||||
|
p, err := s.planSettings(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "plan settings", err)
|
s.internal(w, r, "plan settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
|
||||||
|
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, p)
|
writeJSON(w, http.StatusOK, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, p)
|
writeJSON(w, http.StatusOK, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
|
type planNowRequest struct {
|
||||||
|
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
|
||||||
|
//
|
||||||
|
// En besked om noget, man står og ser på, er en besked, der lærer folk at
|
||||||
|
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
|
||||||
|
// hele dens formål: at vise, at push virker på det her apparat.
|
||||||
|
Silent bool `json:"silent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
|
||||||
//
|
//
|
||||||
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
||||||
// af, er en indstilling, ingen tør slå til.
|
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
|
||||||
|
// lave en plan for den, der har beskeden slået fra.
|
||||||
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req planNowRequest
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
plan, err := s.buildPlan(r.Context(), user)
|
plan, err := s.buildPlan(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "build plan", err)
|
s.internal(w, r, "build plan", err)
|
||||||
@@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.notify(r, &store.Notification{
|
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||||
UserID: user.ID, Kind: "daily.plan",
|
s.internal(w, r, "save plan", err)
|
||||||
Title: planTitle(user.Locale), Body: plan,
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := false
|
||||||
|
if !req.Silent {
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: user.ID, Kind: "daily.plan",
|
||||||
|
Title: planTitle(user.Locale), Body: plan,
|
||||||
|
})
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"plan": plan, "sent": sent,
|
||||||
|
"plan_at": time.Now().Unix(),
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
|
}
|
||||||
|
|
||||||
|
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
|
||||||
|
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
|
||||||
|
p, err := s.planSettings(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
p.Plan = plan
|
||||||
|
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
p.PlanAt = now.Unix()
|
||||||
|
return s.savePlanSettings(ctx, user.ID, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
||||||
@@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error {
|
|||||||
UserID: user.ID, Kind: "daily.plan",
|
UserID: user.ID, Kind: "daily.plan",
|
||||||
Title: planTitle(user.Locale), Body: plan,
|
Title: planTitle(user.Locale), Body: plan,
|
||||||
})
|
})
|
||||||
|
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
|
||||||
|
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
|
||||||
|
// ene overskrive den andens `last_sent`.
|
||||||
|
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
|
||||||
}
|
}
|
||||||
p.LastSent = today
|
p.LastSent = today
|
||||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
||||||
@@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
|
|||||||
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
|
||||||
|
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
|
||||||
|
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.quickTask(t, "noget i dag")
|
||||||
|
|
||||||
|
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
|
||||||
|
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
|
||||||
|
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("lav planen: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if made["sent"] != false {
|
||||||
|
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||||
|
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||||
|
if after["plan"] != made["plan"] {
|
||||||
|
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
|
||||||
|
}
|
||||||
|
if after["plan_at"] == nil {
|
||||||
|
t.Error("planen bærer ikke, hvornår den blev lavet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
|
||||||
|
// overskriften "i dag", er værre end et tomt kort.
|
||||||
|
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.quickTask(t, "noget i dag")
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("lav planen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sat en dag tilbage, som om den var lavet i går.
|
||||||
|
user := userID(t, ts, "kristian@example.dk")
|
||||||
|
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||||
|
if plan, _ := out["plan"].(string); plan != "" {
|
||||||
|
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "count users", err)
|
s.internal(w, r, "count users", err)
|
||||||
return
|
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) {
|
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 {
|
if err != nil {
|
||||||
return 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
|
var out []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
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
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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
|
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||||
// takes, rebuilding the task tree from parent ids.
|
// takes, rebuilding the task tree from parent ids.
|
||||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
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",
|
// `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.
|
// and a description that only names the endpoint gives it nothing to decide with.
|
||||||
func (s *Server) buildMCP() *mcp.Server {
|
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{
|
m.Register(mcp.Tool{
|
||||||
Name: "list_projects",
|
Name: "list_projects",
|
||||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
|||||||
}),
|
}),
|
||||||
}, s.mcpListProjects)
|
}, s.mcpListProjects)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||||
Name: "search_tasks",
|
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||||
"This is the tool for any question about what the user has to do — " +
|
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
if !s.cfg.NotesOnly() {
|
||||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
m.Register(mcp.Tool{
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
Name: "search_tasks",
|
||||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
"This is the tool for any question about what the user has to do — " +
|
||||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||||
}),
|
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||||
}, s.mcpSearchTasks)
|
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{
|
m.Register(mcp.Tool{
|
||||||
Name: "create_task",
|
Name: "create_task",
|
||||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
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 " +
|
"`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 " +
|
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||||
"and English. Use the explicit fields only when the user was explicit.",
|
"and English. Use the explicit fields only when the user was explicit.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
"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`."),
|
"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."),
|
"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."),
|
"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."),
|
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||||
"description": mcp.Str("Longer notes on the task."),
|
"description": mcp.Str("Longer notes on the task."),
|
||||||
"labels": mcp.StrArray("Label names."),
|
"labels": mcp.StrArray("Label names."),
|
||||||
}),
|
}),
|
||||||
}, s.mcpCreateTask)
|
}, s.mcpCreateTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "update_task",
|
Name: "update_task",
|
||||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
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; " +
|
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||||
"to clear it, pass an empty string.",
|
"to clear it, pass an empty string.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to change."),
|
"task_id": mcp.Str("The task to change."),
|
||||||
"content": mcp.Str("A new title."),
|
"content": mcp.Str("A new title."),
|
||||||
"description": mcp.Str("New notes."),
|
"description": mcp.Str("New notes."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"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."),
|
"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."),
|
"project_id": mcp.Str("Move the task to another project."),
|
||||||
"labels": mcp.StrArray("Replace the task's labels."),
|
"labels": mcp.StrArray("Replace the task's labels."),
|
||||||
}, "task_id"),
|
}, "task_id"),
|
||||||
}, s.mcpUpdateTask)
|
}, s.mcpUpdateTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "complete_task",
|
Name: "complete_task",
|
||||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||||
"instead of closing, and the result says so.",
|
"instead of closing, and the result says so.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to complete."),
|
"task_id": mcp.Str("The task to complete."),
|
||||||
}, "task_id"),
|
}, "task_id"),
|
||||||
}, s.mcpCompleteTask)
|
}, s.mcpCompleteTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "add_comment",
|
Name: "add_comment",
|
||||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to comment on."),
|
"task_id": mcp.Str("The task to comment on."),
|
||||||
"body": mcp.Str("What to say."),
|
"body": mcp.Str("What to say."),
|
||||||
}, "task_id", "body"),
|
}, "task_id", "body"),
|
||||||
}, s.mcpAddComment)
|
}, s.mcpAddComment)
|
||||||
|
}
|
||||||
|
|
||||||
s.registerNoteTools(m)
|
s.registerNoteTools(m)
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||||
|
//
|
||||||
|
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||||
|
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||||
|
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||||
|
// opgaver til dig i det.
|
||||||
|
//
|
||||||
|
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||||
|
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||||
|
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||||
|
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||||
|
if project == nil || userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
if actor.ID == userID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||||
|
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
// it — folded into one line per note while it is unread.
|
// it — folded into one line per note while it is unread.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -452,22 +452,100 @@ type memberJSON struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
|
||||||
|
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
|
||||||
|
// dukker op.
|
||||||
|
type projectInviteJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMembers is everything the share panel needs, in one answer: who is on
|
||||||
|
// the project, who can be added, and who has been invited and not arrived.
|
||||||
|
//
|
||||||
|
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
|
||||||
|
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
|
||||||
|
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
|
||||||
|
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
|
||||||
|
//
|
||||||
|
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
|
||||||
|
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
|
||||||
|
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
|
||||||
|
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
|
||||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
members, err := s.db.ListMembers(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "list members", err)
|
s.internal(w, r, "list members", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
out := make([]memberJSON, 0, len(members))
|
||||||
|
already := map[string]bool{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
||||||
|
already[m.UserID] = true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
|
||||||
|
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
already[project.OwnerID] = true
|
||||||
|
|
||||||
|
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "share candidates", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates := make([]personJSON, 0, len(people))
|
||||||
|
for _, p := range people {
|
||||||
|
if already[p.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
|
}
|
||||||
|
|
||||||
|
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list project invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending := make([]projectInviteJSON, 0, len(invites))
|
||||||
|
for _, i := range invites {
|
||||||
|
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"members": out,
|
||||||
|
"candidates": candidates,
|
||||||
|
"pending": pending,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
|
||||||
|
//
|
||||||
|
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
|
||||||
|
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
|
||||||
|
// udløber, med et link, der virker hele tiden.
|
||||||
|
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "delete project invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteRequest struct {
|
type inviteRequest struct {
|
||||||
Email string `json:"email"`
|
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||||
Role string `json:"role"`
|
// på — og den er også den, der virker, når der ingen konto er endnu.
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteResponse struct {
|
type inviteResponse struct {
|
||||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
|||||||
Emailed bool `json:"emailed"`
|
Emailed bool `json:"emailed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
// handleInvite shares a project with somebody.
|
||||||
// anybody else gets a link that creates their account and their membership at once.
|
//
|
||||||
|
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
|
||||||
|
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
|
||||||
|
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
|
||||||
|
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
|
||||||
|
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
|
||||||
|
// kontoen og medlemskabet på én gang.
|
||||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
var req inviteRequest
|
var req inviteRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := store.NormalizeEmail(req.Email)
|
email := store.NormalizeEmail(req.Email)
|
||||||
if email == "" || !strings.Contains(email, "@") {
|
if req.UserID == "" && email == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserID == "" && !strings.Contains(email, "@") {
|
||||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Somebody who already has an account is added straight away: sending them
|
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||||
// through a signup form they cannot complete would be a dead end.
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||||
if existing.ID == project.OwnerID {
|
if req.UserID == "" {
|
||||||
|
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||||
|
req.UserID = existing.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID != "" {
|
||||||
|
if req.UserID == project.OwnerID {
|
||||||
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
|
||||||
|
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
|
||||||
|
// tom plads, ingen kan fjerne.
|
||||||
|
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
|
||||||
s.internal(w, r, "add member", err)
|
s.internal(w, r, "add member", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||||
|
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||||
|
s.notifyProjectShared(r, project, person.ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"added": true,
|
||||||
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||||
|
// og derfor ikke selv ved, hvem den ramte.
|
||||||
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||||
|
"role": string(role),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
||||||
|
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
||||||
|
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||||
|
// anybody removing themselves.
|
||||||
|
//
|
||||||
|
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||||
|
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||||
|
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||||
|
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||||
|
//
|
||||||
|
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||||
|
// overdragelse, og den findes ikke endnu.
|
||||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, r, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||||
|
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||||
|
if me.ID != owner && userID != me.ID {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||||
|
return
|
||||||
|
}
|
||||||
if userID == owner {
|
if userID == owner {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
"the owner cannot be removed from their own project")
|
"the owner cannot be removed from their own project")
|
||||||
|
|||||||
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||||
|
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||||
|
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||||
|
// beskytte ved at lade være med at vise dem.
|
||||||
|
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
// Først står de i listen over dem, der kan tilføjes.
|
||||||
|
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if !hasPerson(before["candidates"], anden.ID) {
|
||||||
|
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delt ved at pege, ikke ved at stave.
|
||||||
|
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||||
|
}
|
||||||
|
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||||
|
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||||
|
t.Errorf("user = %v, want Anden", added["user"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||||
|
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if hasPerson(after["candidates"], anden.ID) {
|
||||||
|
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, raw := range after["members"].([]any) {
|
||||||
|
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||||
|
found = true
|
||||||
|
if m["role"] != "editor" {
|
||||||
|
t.Errorf("role = %v, want editor", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||||
|
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if hasPerson(body["candidates"], me.ID) {
|
||||||
|
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||||
|
//
|
||||||
|
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||||
|
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||||
|
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||||
|
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
pending, _ := body["pending"].([]any)
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||||
|
}
|
||||||
|
first := pending[0].(map[string]any)
|
||||||
|
if first["email"] != "fremmed@example.dk" {
|
||||||
|
t.Errorf("email = %v", first["email"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen.
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||||
|
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||||
|
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||||
|
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||||
|
func hasPerson(raw any, id string) bool {
|
||||||
|
list, _ := raw.([]any)
|
||||||
|
for _, item := range list {
|
||||||
|
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||||
|
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||||
|
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
anden := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||||
|
|
||||||
|
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||||
|
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||||
|
var shared map[string]any
|
||||||
|
for _, raw := range inbox["notifications"].([]any) {
|
||||||
|
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||||
|
shared = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared == nil {
|
||||||
|
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||||
|
}
|
||||||
|
if shared["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||||
|
shared["project_id"], projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen.
|
||||||
|
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||||
|
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
en := ts.newUser(t, "en@example.dk", "En")
|
||||||
|
ts.newUser(t, "to@example.dk", "To")
|
||||||
|
|
||||||
|
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": u, "role": "editor"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||||
|
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if len(members["members"].([]any)) != 3 {
|
||||||
|
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||||
|
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+227
-80
@@ -3,6 +3,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -77,6 +78,14 @@ type Server struct {
|
|||||||
// Computed once from the built index.html; see csp.go.
|
// Computed once from the built index.html; see csp.go.
|
||||||
csp string
|
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
|
||||||
|
|
||||||
router chi.Router
|
router chi.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +95,7 @@ type Server struct {
|
|||||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg, db: db, log: log, web: web,
|
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),
|
hub: realtime.NewHub(log),
|
||||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||||
// Generous on purpose: the caller is the mail server, one address delivering
|
// Generous on purpose: the caller is the mail server, one address delivering
|
||||||
@@ -101,8 +110,41 @@ 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.
|
// generous for a person and hopeless for a search through a million.
|
||||||
totpLimiter: newLimiter(10, time.Hour),
|
totpLimiter: newLimiter(10, time.Hour),
|
||||||
}
|
}
|
||||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
s.passkeys = w
|
||||||
} else {
|
} else {
|
||||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||||
@@ -125,13 +167,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.
|
// Docker both need this to answer before anyone has logged in.
|
||||||
r.Get("/healthz", s.handleHealth)
|
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
|
// 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.
|
// 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
|
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||||
// against the root and authenticate with Basic rather than a session.
|
// against the root and authenticate with Basic rather than a session.
|
||||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
//
|
||||||
|
// 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
|
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
@@ -156,8 +216,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
|
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||||
// for den indgående post, og omvendt.
|
// 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
|
// MCP with the token in the query string, for clients that cannot send a
|
||||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
@@ -184,7 +249,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// Gmail connection with calendar tokens.
|
// Gmail connection with calendar tokens.
|
||||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
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.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.requireCSRF)
|
r.Use(s.requireCSRF)
|
||||||
@@ -285,6 +353,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
// Everything past this point needs a complete login.
|
// Everything past this point needs a complete login.
|
||||||
r.Group(func(r chi.Router) {
|
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.Use(s.requireAuth)
|
||||||
|
|
||||||
r.Get("/ws", s.handleWebSocket)
|
r.Get("/ws", s.handleWebSocket)
|
||||||
@@ -294,17 +366,26 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/mcp", s.handleMCP)
|
r.Post("/mcp", s.handleMCP)
|
||||||
|
|
||||||
r.Get("/today", s.handleToday)
|
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) {
|
if full {
|
||||||
r.Get("/", s.handleListFilters)
|
r.Route("/filters", func(r chi.Router) {
|
||||||
r.Post("/", s.handleCreateFilter)
|
r.Get("/", s.handleListFilters)
|
||||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
r.Post("/", s.handleCreateFilter)
|
||||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
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.Route("/notifications", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListNotifications)
|
r.Get("/", s.handleListNotifications)
|
||||||
@@ -330,7 +411,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateComment)
|
r.Patch("/", s.handleUpdateComment)
|
||||||
r.Delete("/", s.handleDeleteComment)
|
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.Get("/mail-address", s.handleGetMailAddress)
|
||||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||||
@@ -351,20 +434,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/settings", s.handleGetAISettings)
|
r.Get("/settings", s.handleGetAISettings)
|
||||||
r.Put("/settings", s.handleSetAISettings)
|
r.Put("/settings", s.handleSetAISettings)
|
||||||
r.Post("/summary", s.handleAISummary)
|
r.Post("/summary", s.handleAISummary)
|
||||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
if full {
|
||||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||||
// der sker, når nogen siger ja til én af dem.
|
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
// der sker, når nogen siger ja til én af dem.
|
||||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
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", s.handleAINoteActions)
|
||||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||||
// er en indstilling, ingen tør slå til.
|
// er en indstilling, ingen tør slå til.
|
||||||
r.Get("/plan", s.handleGetPlanSettings)
|
if full {
|
||||||
r.Put("/plan", s.handleSetPlanSettings)
|
r.Get("/plan", s.handleGetPlanSettings)
|
||||||
r.Post("/plan/now", s.handlePlanNow)
|
r.Put("/plan", s.handleSetPlanSettings)
|
||||||
|
r.Post("/plan/now", s.handlePlanNow)
|
||||||
|
}
|
||||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||||
// det er bygget på.
|
// det er bygget på.
|
||||||
@@ -449,33 +536,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/import", func(r chi.Router) {
|
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||||
r.Post("/todoist", s.handleImportTodoist)
|
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||||
r.Post("/csv", s.handleImportCSV)
|
// 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/account", s.handleExportAccount)
|
||||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
if full {
|
||||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
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) {
|
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||||
r.Get("/", s.handleListTemplates)
|
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||||
r.Post("/", s.handleSaveTemplate)
|
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
// overskrifter i.
|
||||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
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) {
|
if full {
|
||||||
r.Get("/", s.handleListLabels)
|
r.Route("/labels", func(r chi.Router) {
|
||||||
r.Post("/", s.handleCreateLabel)
|
r.Get("/", s.handleListLabels)
|
||||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
r.Post("/", s.handleCreateLabel)
|
||||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||||
})
|
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||||
r.Get("/upcoming", s.handleUpcoming)
|
})
|
||||||
r.Get("/delegated", s.handleDelegated)
|
r.Get("/upcoming", s.handleUpcoming)
|
||||||
|
r.Get("/delegated", s.handleDelegated)
|
||||||
|
}
|
||||||
r.Get("/people", s.handlePeople)
|
r.Get("/people", s.handlePeople)
|
||||||
r.Get("/search", s.handleSearch)
|
r.Get("/search", s.handleSearch)
|
||||||
|
|
||||||
@@ -491,14 +598,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Use(s.requireProject(store.RoleViewer))
|
r.Use(s.requireProject(store.RoleViewer))
|
||||||
|
|
||||||
r.Get("/", s.handleGetProject)
|
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)
|
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.
|
||||||
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
r.Get("/activity", s.handleActivity)
|
r.Get("/activity", s.handleActivity)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireProject(store.RoleEditor))
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
r.Post("/sections", s.handleCreateSection)
|
if full {
|
||||||
r.Post("/sections/reorder", s.handleReorderSections)
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
r.Post("/sections/reorder", s.handleReorderSections)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -506,8 +624,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateProject)
|
r.Patch("/", s.handleUpdateProject)
|
||||||
r.Delete("/", s.handleDeleteProject)
|
r.Delete("/", s.handleDeleteProject)
|
||||||
r.Post("/invites", s.handleInvite)
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -573,32 +691,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
if full {
|
||||||
r.Patch("/", s.handleUpdateSection)
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
r.Delete("/", s.handleDeleteSection)
|
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("/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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -666,7 +788,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.web == nil {
|
if s.web == nil {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,6 +797,13 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = "index.html"
|
name = "index.html"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
f, err := s.web.Open(name)
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
// A client-side route: hand back the shell and let the app route it.
|
// A client-side route: hand back the shell and let the app route it.
|
||||||
@@ -687,6 +816,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
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()
|
info, err := f.Stat()
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if name == "manifest.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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" />
|
||||||
|
<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": "/"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
for _, pair := range shellRebrands("urd", "d") {
|
||||||
|
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 — "+
|
||||||
|
"the notes edition renames the shell by replacing it", n, pair.old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 anchors found", len(raw), len(shellRebrands("urd", "d")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,6 +501,18 @@ type quickAddRequest struct {
|
|||||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
// 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.
|
// because typing it is a choice and standing there is a circumstance.
|
||||||
SectionID string `json:"section_id,omitempty"`
|
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) {
|
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
|
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
|
projectID := req.ProjectID
|
||||||
// Set when the text named a project that does not exist. The task is still
|
// 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
|
// 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
|
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 == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
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,
|
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||||
RecurrenceRule: parsed.Recurrence,
|
RecurrenceRule: parsed.Recurrence,
|
||||||
}
|
}
|
||||||
|
if parent != nil {
|
||||||
|
t.ParentID = parent.ID
|
||||||
|
}
|
||||||
if parsed.DueDate != "" {
|
if parsed.DueDate != "" {
|
||||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The whole point of import and export together: what goes in comes back out.
|
// 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) {
|
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|||||||
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||||
Search: query,
|
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||||
// Completed tasks are included: "what was that thing I did last week" is
|
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||||
// a search, and excluding them makes the box feel broken.
|
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||||
IncludeCompleted: true,
|
// ind til den halvdel, resten af programmet lige har lukket.
|
||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
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 {
|
if err != nil {
|
||||||
s.internal(w, r, "search tasks", err)
|
s.internal(w, r, "search tasks", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
|||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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 {
|
func seedUser(t *testing.T, db *store.DB) *store.User {
|
||||||
|
|||||||
+19
-13
@@ -24,13 +24,19 @@ import (
|
|||||||
type Sender struct {
|
type Sender struct {
|
||||||
cfg config.SMTP
|
cfg config.SMTP
|
||||||
log *slog.Logger
|
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
|
appName string
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
|
||||||
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
|
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured reports whether mail can actually be sent. Handlers use it to decide
|
// 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) Configured() bool { return s.cfg.Configured() }
|
||||||
|
|
||||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||||
what := "verdande"
|
what := s.appName
|
||||||
if projectName != "" {
|
if projectName != "" {
|
||||||
what = "projektet “" + 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 {
|
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
|
body := fmt.Sprintf(`Hej
|
||||||
|
|
||||||
%s har inviteret dig til %s.
|
%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.
|
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
|
%s
|
||||||
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
|
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||||
|
|
||||||
return s.send(ctx, to, subject, body)
|
return s.send(ctx, to, subject, body)
|
||||||
}
|
}
|
||||||
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
|
|||||||
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
— verdande
|
— %s
|
||||||
`, name, task, link)
|
`, name, task, link, s.appName)
|
||||||
|
|
||||||
return s.send(ctx, to, subject, body)
|
return s.send(ctx, to, subject, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
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
|
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:
|
Vælg en ny adgangskode her:
|
||||||
%s
|
%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.
|
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
|
%s
|
||||||
`, name, link, humanDuration(ttl), s.baseURL)
|
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||||
|
|
||||||
return s.send(ctx, to, subject, body)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- invitationer til ét projekt --------------------------------------------------
|
||||||
|
|
||||||
|
// ProjectInvite is somebody invited to a project who has not arrived yet.
|
||||||
|
//
|
||||||
|
// The same shape as NoteInvite and deliberately not the same type: the two rows
|
||||||
|
// differ in which column carries the target, and one struct covering both would
|
||||||
|
// need a field that is empty half the time — which is how a caller ends up
|
||||||
|
// reading the wrong one.
|
||||||
|
type ProjectInvite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
ExpiresAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjectInvites is who has been invited to a project and not yet taken it up.
|
||||||
|
//
|
||||||
|
// The panel needs them beside the members for the same reason the note panel does:
|
||||||
|
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
|
||||||
|
// second link to the same inbox, and no way to take either back. That mattered
|
||||||
|
// less while inviting meant typing an address; it matters more now that the panel
|
||||||
|
// offers a list to pick from, because the people who are *missing* from that list
|
||||||
|
// are exactly the ones already invited.
|
||||||
|
//
|
||||||
|
// Expired ones are left out, the same rule the administrator's list follows.
|
||||||
|
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, email, role, created_at, expires_at
|
||||||
|
FROM invites
|
||||||
|
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY created_at`, projectID, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []ProjectInvite{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ProjectInvite
|
||||||
|
var created, expires int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
|
||||||
|
// withdraw anything else.
|
||||||
|
//
|
||||||
|
// The project id is in the WHERE rather than checked beforehand, for the same
|
||||||
|
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
|
||||||
|
// established for *this* project, and a statement that can only touch this
|
||||||
|
// project's rows cannot be talked into deleting somebody else's invite by id.
|
||||||
|
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
|
||||||
|
inviteID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingProjectInvite reports whether this address already has a live invitation
|
||||||
|
// to this project, so a second attempt can say so instead of sending another link.
|
||||||
|
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*) FROM invites
|
||||||
|
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
|
||||||
|
AND accepted_at IS NULL AND expires_at > ?`,
|
||||||
|
projectID, email, time.Now().Unix()).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// PendingNoteInvite reports whether this address already has a live invitation to
|
// PendingNoteInvite reports whether this address already has a live invitation to
|
||||||
// this note, so a second attempt can say so instead of sending a second link.
|
// this note, so a second attempt can say so instead of sending a second link.
|
||||||
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ extra_css:
|
|||||||
nav:
|
nav:
|
||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Setting it up:
|
- Setting it up:
|
||||||
|
- Two editions: editions.md
|
||||||
- As a Rune: rune.md
|
- As a Rune: rune.md
|
||||||
- With Docker: self-hosting.md
|
- With Docker: self-hosting.md
|
||||||
- Configuration: configuration.md
|
- Configuration: configuration.md
|
||||||
@@ -51,6 +52,8 @@ nav:
|
|||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
- Quick add: quick-add.md
|
- Quick add: quick-add.md
|
||||||
- Notes: notes.md
|
- Notes: notes.md
|
||||||
|
- Notifications: notifications.md
|
||||||
|
- Opening links: links.md
|
||||||
- Filters: filters.md
|
- Filters: filters.md
|
||||||
- Importing from Todoist: import.md
|
- Importing from Todoist: import.md
|
||||||
- Connecting things:
|
- Connecting things:
|
||||||
@@ -60,6 +63,8 @@ nav:
|
|||||||
- Mailboxes: mailboxes.md
|
- Mailboxes: mailboxes.md
|
||||||
- Claude (MCP): mcp.md
|
- Claude (MCP): mcp.md
|
||||||
- AI features: ai.md
|
- AI features: ai.md
|
||||||
|
- Privacy:
|
||||||
|
- The install count: beacon.md
|
||||||
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
|
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
|
||||||
# file, and api.md links to it. A nav entry expects a page to render.
|
# file, and api.md links to it. A nav entry expects a page to render.
|
||||||
- Reference:
|
- Reference:
|
||||||
|
|||||||
+67
-11
@@ -819,6 +819,15 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
|
||||||
|
står under "It counts itself". Plausible på vores egen maskine: ingen
|
||||||
|
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
|
||||||
|
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
data-domain="verdandetodo.com"
|
||||||
|
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
|
||||||
|
></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -1326,6 +1335,64 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.47</span>
|
||||||
|
Click a picture in a note and it opens at full size, with a button that
|
||||||
|
copies it to the clipboard. In the note it is scaled to the width of the
|
||||||
|
column — a photo off a phone is four thousand pixels across — so what you
|
||||||
|
were looking at was a thumbnail that did not look like one.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.47</span>
|
||||||
|
Search in ⌘K, pick a note, and the rest of the matches stay in the list
|
||||||
|
beside it. They used to exist only in the popup you had just closed.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.46</span>
|
||||||
|
Share a project with somebody who already has an account by picking their
|
||||||
|
name, rather than by spelling an email address you would have to know.
|
||||||
|
Invitations that have not been taken up stay visible so they do not get
|
||||||
|
sent twice, and can be withdrawn.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.46</span>
|
||||||
|
You are told when a project is shared with you, and there is a
|
||||||
|
<strong>Leave the project</strong> button. Being let in without being
|
||||||
|
asked, without being told, and with no way out was three things at once.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.45</span>
|
||||||
|
Search for a setting instead of remembering which tab it was under — in
|
||||||
|
the settings themselves and in ⌘K. It knows them by what they do, so
|
||||||
|
<em>beacon</em> finds the install count even though that is not its name.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.45</span>
|
||||||
|
Today's plan is a card on <strong>I dag</strong> rather than only a
|
||||||
|
message in the morning that was gone if you did not read it at once. And
|
||||||
|
a link can be told which browser to open in.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.44</span>
|
||||||
|
An optional AI layer, on your own key: it tidies the inbox, pulls the
|
||||||
|
tasks out of a meeting note, writes a morning plan, turns
|
||||||
|
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
|
||||||
|
for, and answers questions about your own notes with links to what the
|
||||||
|
answer rests on. Every one of them is a line you accept, edit or refuse —
|
||||||
|
it suggests, it never writes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.44</span>
|
||||||
|
Administrators are told in the bell when a new version is out, once per
|
||||||
|
version, and the message leads to the button that restarts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.43</span>
|
||||||
|
Anything that can call a URL can put a task in your inbox — a shortcut on
|
||||||
|
your phone, a script, a service. Notes can be handed to somebody who has
|
||||||
|
no account yet, and writing <code>@Sofie</code> in a note shares it with
|
||||||
|
her and tells her. Unread counts show on the app's icon in the Dock.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="ver">0.42</span>
|
<span class="ver">0.42</span>
|
||||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||||
@@ -1362,17 +1429,6 @@
|
|||||||
body to move it. A single note can be shared with one person, and a
|
body to move it. A single note can be shared with one person, and a
|
||||||
<code>[[note]]</code> is a link you follow inline.
|
<code>[[note]]</code> is a link you follow inline.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<span class="ver">0.34</span>
|
|
||||||
Sections can be dragged into the order the work actually happens in. The
|
|
||||||
sidebar keeps its head still while the projects scroll.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.33</span>
|
|
||||||
Notes grew an archive, selection of many at once, pictures you can paste
|
|
||||||
or drag in, a source view, and a copy button on code blocks. Search
|
|
||||||
stopped answering by date and started answering by relevance.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indekset over indstillingerne skal passe på siderne.
|
||||||
|
*
|
||||||
|
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
|
||||||
|
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
|
||||||
|
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
|
||||||
|
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
|
||||||
|
*
|
||||||
|
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
|
||||||
|
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
|
||||||
|
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
|
||||||
|
* ville melde fejl på det, der med rette var skjult.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const routes = join(here, '..', 'src', 'routes');
|
||||||
|
const locales = join(here, '..', 'src', 'lib', 'locales');
|
||||||
|
|
||||||
|
/** `/indstillinger/data` → filen, den side tegnes af. */
|
||||||
|
function pageFile(path) {
|
||||||
|
return join(routes, path, '+page.svelte');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hvert afsnit i indekset findes på den side, det peger på', () => {
|
||||||
|
const missing = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
const src = readFileSync(pageFile(entry.path), 'utf8');
|
||||||
|
const hits = src.split(`id="${entry.anchor}"`).length - 1;
|
||||||
|
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('to afsnit deler ikke et anker', () => {
|
||||||
|
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
|
||||||
|
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
|
||||||
|
const seen = new Set();
|
||||||
|
const dupes = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
const key = `${entry.path}#${entry.anchor}`;
|
||||||
|
if (seen.has(key)) dupes.push(key);
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
expect(dupes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
|
||||||
|
const missing = [];
|
||||||
|
for (const locale of ['da', 'en']) {
|
||||||
|
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
for (const key of [entry.key, entry.tab]) {
|
||||||
|
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('søgningen finder det, man ville skrive', () => {
|
||||||
|
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
|
||||||
|
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
|
||||||
|
const t = (key) => key;
|
||||||
|
const found = (q, admin = true) =>
|
||||||
|
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
|
||||||
|
|
||||||
|
expect(found('beacon')).toContain('/indstillinger/data#beacon');
|
||||||
|
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
|
||||||
|
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
|
||||||
|
expect(found('2fa')).toContain('/indstillinger#to-faktor');
|
||||||
|
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
|
||||||
|
|
||||||
|
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
|
||||||
|
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
|
||||||
|
expect(found('')).toEqual([]);
|
||||||
|
expect(found(' ')).toEqual([]);
|
||||||
|
|
||||||
|
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
|
||||||
|
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
|
||||||
|
// kan holde.
|
||||||
|
expect(found('backup', false)).toEqual([]);
|
||||||
|
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
|
||||||
|
});
|
||||||
+1234
-44
File diff suppressed because it is too large
Load Diff
@@ -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 here = dirname(fileURLToPath(import.meta.url));
|
||||||
const repo = join(here, '..');
|
const repo = join(here, '..');
|
||||||
const dataDir = join(here, '.playwright-data');
|
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
|
// A clean instance every run: the first test creates the first account, which only
|
||||||
// works on an empty database.
|
// 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.
|
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||||
rmSync(dataDir, { recursive: true, force: true });
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
rmSync(notesDataDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = 8097;
|
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
|
// `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
|
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||||
// every browser makes. The passkey test cannot run against a loopback address.
|
// 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: 'setup', testMatch: /.*\.setup\.js/ },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
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' },
|
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||||
dependencies: ['setup']
|
dependencies: ['setup']
|
||||||
},
|
},
|
||||||
@@ -95,6 +105,21 @@ export default defineConfig({
|
|||||||
*
|
*
|
||||||
* WebKit here is the same engine Safari is, which is the whole point.
|
* 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',
|
name: 'webkit-forms',
|
||||||
grep: /@forms/,
|
grep: /@forms/,
|
||||||
@@ -103,42 +128,64 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: {
|
webServer: [
|
||||||
// The whole chain, every run.
|
{
|
||||||
//
|
// 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.
|
|
||||||
//
|
//
|
||||||
// The passkey test navigates to localhost explicitly so its origin
|
// The frontend is embedded in the binary at compile time, from a copy under
|
||||||
// matches. Everything else stays on the loopback address, where it has
|
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||||
// always been.
|
// test whatever frontend was copied there last, which during development is
|
||||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
// nearly always the wrong one. Testing a stale build is worse than not
|
||||||
VERDANDE_DATA_DIR: dataDir,
|
// testing: it reports green on code that is not running.
|
||||||
// Off: an update check would reach out to GitHub from a test run.
|
command: [
|
||||||
VERDANDE_UPDATE_CHECK: 'false'
|
'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
|
* node scripts/generate-icons.js
|
||||||
*
|
*
|
||||||
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
* 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.
|
* 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
|
* 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
|
* 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.
|
* 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 { chromium } from '@playwright/test';
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
// The three sizes, and why each is here.
|
||||||
const SIZES = [
|
const SIZES = [
|
||||||
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||||
{ file: 'static/icon-192.png', size: 192 },
|
{ suffix: '-192.png', size: 192 },
|
||||||
{ file: 'static/icon-512.png', size: 512 },
|
{ suffix: '-512.png', size: 512 },
|
||||||
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
// And the one Safari wants by name.
|
||||||
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
{ suffix: '-apple-touch.png', size: 180 }
|
||||||
// screenshot of the page as its home-screen icon.
|
];
|
||||||
{ file: 'static/apple-touch-icon.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 browser = await chromium.launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
for (const { file, size } of SIZES) {
|
for (const set of SETS) {
|
||||||
await page.setViewportSize({ width: size, height: size });
|
const svg = readFileSync(set.source, 'utf8');
|
||||||
await page.setContent(
|
for (const { suffix, size } of SIZES) {
|
||||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
const file = set.out[suffix];
|
||||||
);
|
await page.setViewportSize({ width: size, height: size });
|
||||||
writeFileSync(file, await page.locator('svg').screenshot());
|
await page.setContent(
|
||||||
console.log(`${file} ${size}×${size}`);
|
`<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();
|
await browser.close();
|
||||||
|
|||||||
+17
-4
@@ -338,7 +338,11 @@ export const api = {
|
|||||||
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
|
||||||
|
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
|
||||||
|
// hører til en konto her.
|
||||||
|
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
|
||||||
|
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
|
||||||
setMemberRole: (projectId, userId, role) =>
|
setMemberRole: (projectId, userId, role) =>
|
||||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||||
@@ -361,8 +365,13 @@ export const api = {
|
|||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||||
|
|
||||||
quickAdd: (text, projectId, sectionId) =>
|
quickAdd: (text, projectId, sectionId, parentId) =>
|
||||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
post('/tasks/quick-add', {
|
||||||
|
text,
|
||||||
|
project_id: projectId,
|
||||||
|
section_id: sectionId,
|
||||||
|
parent_id: parentId
|
||||||
|
}),
|
||||||
quickAddPreview: (text, signal) =>
|
quickAddPreview: (text, signal) =>
|
||||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||||
|
|
||||||
@@ -496,7 +505,9 @@ export const api = {
|
|||||||
// Dagens plan om morgenen.
|
// Dagens plan om morgenen.
|
||||||
planSettings: () => get('/ai/plan'),
|
planSettings: () => get('/ai/plan'),
|
||||||
setPlanSettings: (body) => put('/ai/plan', body),
|
setPlanSettings: (body) => put('/ai/plan', body),
|
||||||
planNow: () => post('/ai/plan/now'),
|
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
|
||||||
|
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
|
||||||
|
planNow: (options) => post('/ai/plan/now', options ?? {}),
|
||||||
|
|
||||||
version: () => get('/version'),
|
version: () => get('/version'),
|
||||||
|
|
||||||
@@ -505,6 +516,8 @@ export const api = {
|
|||||||
importCSV: (data) => post('/import/csv', data),
|
importCSV: (data) => post('/import/csv', data),
|
||||||
exportAccountURL: () => '/api/v1/export/account',
|
exportAccountURL: () => '/api/v1/export/account',
|
||||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
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,
|
// 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.
|
// and the browser only does that when nothing has claimed the header.
|
||||||
importNotes: (file) => upload('/notes/import', file),
|
importNotes: (file) => upload('/notes/import', file),
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
{#if notice}
|
{#if notice}
|
||||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||||
<h2>{t('beacon.noticeTitle')}</h2>
|
<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
|
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { humanMessage } from '$lib/api.js';
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false) } = $props();
|
||||||
|
|
||||||
@@ -68,10 +69,27 @@
|
|||||||
}, 140);
|
}, 140);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indstillingerne, og hvorfor de ligger øverst.
|
||||||
|
*
|
||||||
|
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
|
||||||
|
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
|
||||||
|
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
|
||||||
|
* modsat en søgning i noget, man selv har skrevet.
|
||||||
|
*/
|
||||||
|
let settings = $derived(
|
||||||
|
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
|
||||||
|
);
|
||||||
|
|
||||||
let results = $derived([
|
let results = $derived([
|
||||||
|
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
...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) })),
|
...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',
|
kind: 'task',
|
||||||
id: t.id,
|
id: t.id,
|
||||||
label: t.content,
|
label: t.content,
|
||||||
@@ -108,7 +126,16 @@
|
|||||||
|
|
||||||
function choose(item) {
|
function choose(item) {
|
||||||
open = false;
|
open = false;
|
||||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||||
|
// op, den ER stedet.
|
||||||
|
if (item.kind === 'setting') return goto(item.id);
|
||||||
|
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
||||||
|
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
||||||
|
// lige lukkede.
|
||||||
|
if (item.kind === 'note') {
|
||||||
|
const q = query.trim();
|
||||||
|
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
||||||
|
}
|
||||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* What the app shows when it could not reach the server at all.
|
||||||
|
*
|
||||||
|
* Before this, an outage rendered the sign-in screen: the boot asks who you
|
||||||
|
* are, the request never gets an answer, `user` stays null, and null means
|
||||||
|
* "logged out" everywhere else. So the app blamed the session for the network
|
||||||
|
* being down, and offered a password box that could not possibly work. That is
|
||||||
|
* worse than an error message, because it looks like it might work — people
|
||||||
|
* type the password, get nothing, and conclude their account is broken.
|
||||||
|
*
|
||||||
|
* It happened for real on 17 September 2026: the instance's DNS record was
|
||||||
|
* deleted, and what the app showed was a slow-loading login page.
|
||||||
|
*
|
||||||
|
* The text says the data is safe, because that is the question somebody
|
||||||
|
* actually has when their notes vanish behind a login box.
|
||||||
|
*/
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
|
let trying = $state(false);
|
||||||
|
|
||||||
|
async function again() {
|
||||||
|
trying = true;
|
||||||
|
try {
|
||||||
|
await app.load();
|
||||||
|
} finally {
|
||||||
|
trying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||||
|
<h1>verdande</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{t('net.downTitle')}</h2>
|
||||||
|
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
|
||||||
|
|
||||||
|
<button onclick={again} disabled={trying}>
|
||||||
|
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--s4);
|
||||||
|
background: var(--ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||||
* is the file to change.
|
* is the file to change.
|
||||||
*/
|
*/
|
||||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
@@ -133,20 +133,78 @@
|
|||||||
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||||
// out of the block from the menu. Do any conversion that touches a <pre> by
|
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||||
// hand, replacing the element rather than reformatting inside it.
|
// hand, replacing the element rather than reformatting inside it.
|
||||||
const block = currentBlock();
|
//
|
||||||
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
|
||||||
replaceBlock(block, style.tag);
|
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
|
||||||
|
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
|
||||||
|
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
|
||||||
|
const blocks = selectedBlocks();
|
||||||
|
if (!blocks.length) return apply('formatBlock', style.tag);
|
||||||
|
|
||||||
|
if (style.tag === 'pre') {
|
||||||
|
mergeToMono(blocks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
|
||||||
|
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
|
||||||
|
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
|
||||||
|
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
|
||||||
|
const pres = blocks.filter((b) => b.tagName === 'PRE');
|
||||||
|
for (const b of pres) swapTag(b, style.tag);
|
||||||
|
if (pres.length === blocks.length) {
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
apply('formatBlock', style.tag);
|
apply('formatBlock', style.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces a block element with one of another tag, keeping its contents and the
|
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
|
||||||
* caret. Used where execCommand cannot be trusted — converting to or from a
|
*
|
||||||
* <pre>, which it either nests or ignores.
|
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
|
||||||
|
* som før.
|
||||||
*/
|
*/
|
||||||
function replaceBlock(block, tag) {
|
function selectedBlocks() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || !selection.rangeCount || !editor) return [];
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
return [...editor.children].filter((b) => range.intersectsNode(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lægger flere blokke sammen til én monotypeblok.
|
||||||
|
*
|
||||||
|
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
|
||||||
|
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
|
||||||
|
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
|
||||||
|
* de er.
|
||||||
|
*/
|
||||||
|
function mergeToMono(blocks) {
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
// blockText og ikke textContent: et <br> har den tomme streng som
|
||||||
|
// textContent, og det er netop <br>, der står for et linjeskift inde i en
|
||||||
|
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
|
||||||
|
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
|
||||||
|
if (!pre.textContent) pre.appendChild(document.createElement('br'));
|
||||||
|
|
||||||
|
blocks[0].replaceWith(pre);
|
||||||
|
for (const b of blocks.slice(1)) b.remove();
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(pre);
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
|
||||||
|
function swapTag(block, tag) {
|
||||||
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||||
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||||
// on the next render for any <pre> that still needs one.
|
// on the next render for any <pre> that still needs one.
|
||||||
@@ -156,6 +214,16 @@
|
|||||||
while (block.firstChild) el.appendChild(block.firstChild);
|
while (block.firstChild) el.appendChild(block.firstChild);
|
||||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||||
block.replaceWith(el);
|
block.replaceWith(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a block element with one of another tag, keeping its contents and the
|
||||||
|
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||||
|
* <pre>, which it either nests or ignores.
|
||||||
|
*/
|
||||||
|
function replaceBlock(block, tag) {
|
||||||
|
const el = swapTag(block, tag);
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
@@ -380,7 +448,7 @@
|
|||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const text = codeTextOf(pre);
|
const text = blockText(pre);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||||
btn.textContent = t('notes.copied');
|
btn.textContent = t('notes.copied');
|
||||||
@@ -394,19 +462,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
|
||||||
|
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
|
||||||
*
|
*
|
||||||
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
|
||||||
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
|
||||||
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
|
||||||
|
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
|
||||||
|
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
|
||||||
|
* væk fra den. På skærmen, ikke kun i filen.
|
||||||
*/
|
*/
|
||||||
function codeTextOf(pre) {
|
|
||||||
const from = pre.querySelector('code') ?? pre;
|
|
||||||
return [...from.childNodes]
|
|
||||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
|
||||||
.map((n) => n.textContent ?? '')
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function colourCode() {
|
function colourCode() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -419,7 +484,7 @@
|
|||||||
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||||
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||||
// spiste den kode, den var sat der for at kopiere.
|
// spiste den kode, den var sat der for at kopiere.
|
||||||
const code = codeTextOf(pre);
|
const code = blockText(pre);
|
||||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||||
if (lang) pre.setAttribute('data-lang', lang);
|
if (lang) pre.setAttribute('data-lang', lang);
|
||||||
const painted = highlight(code, lang);
|
const painted = highlight(code, lang);
|
||||||
@@ -640,9 +705,69 @@
|
|||||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Et billede i en note er et billede, man har indsat for at kunne se det. I
|
||||||
|
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
|
||||||
|
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
|
||||||
|
// der ikke ser ud som et.
|
||||||
|
const image = event.target.closest?.('img');
|
||||||
|
if (image) {
|
||||||
|
event.preventDefault();
|
||||||
|
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||||
|
copied = false;
|
||||||
|
copyError = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
colourCode();
|
colourCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- billedet i fuld størrelse ------------------------------------------------
|
||||||
|
|
||||||
|
let showing = $state(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
let copyError = $state('');
|
||||||
|
|
||||||
|
function closeImage() {
|
||||||
|
showing = null;
|
||||||
|
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||||
|
// findes længere, og den næste tast går ingen steder.
|
||||||
|
editor?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billedet som PNG, uanset hvad det kom som.
|
||||||
|
*
|
||||||
|
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
|
||||||
|
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
|
||||||
|
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
|
||||||
|
*/
|
||||||
|
async function asPNG(src) {
|
||||||
|
const blob = await (await fetch(src)).blob();
|
||||||
|
if (blob.type === 'image/png') return blob;
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImage() {
|
||||||
|
copyError = '';
|
||||||
|
try {
|
||||||
|
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
|
||||||
|
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
|
||||||
|
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
|
||||||
|
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
|
||||||
|
copyError = t('notes.copyFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole thing. */
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
function accept(item) {
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
@@ -728,6 +853,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Står markøren i en punktliste eller en nummereret liste?
|
||||||
|
*
|
||||||
|
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
|
||||||
|
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
|
||||||
|
* ikke blive uenige om, hvad der er en liste.
|
||||||
|
*/
|
||||||
|
const inList = () =>
|
||||||
|
document.queryCommandState('insertUnorderedList') ||
|
||||||
|
document.queryCommandState('insertOrderedList');
|
||||||
|
|
||||||
function onkeydown(event) {
|
function onkeydown(event) {
|
||||||
// The suggestion list owns the arrows and return while it is open, the way
|
// The suggestion list owns the arrows and return while it is open, the way
|
||||||
// every other completion does. Escape closes it without choosing.
|
// every other completion does. Escape closes it without choosing.
|
||||||
@@ -756,6 +892,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
|
||||||
|
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
|
||||||
|
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
|
||||||
|
if (event.key === 'Tab' && inList()) {
|
||||||
|
event.preventDefault();
|
||||||
|
apply(event.shiftKey ? 'outdent' : 'indent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
// A code block is the one place the browser keeps the caret inside on
|
// A code block is the one place the browser keeps the caret inside on
|
||||||
// Enter rather than starting a new block. Step out of it ourselves, or a
|
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||||
@@ -1068,7 +1213,102 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
|
||||||
|
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
|
||||||
|
af netop det ark, det skal lægge sig over. -->
|
||||||
|
{#if showing}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<img src={showing.src} alt={showing.alt} />
|
||||||
|
<div class="tools">
|
||||||
|
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||||
|
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||||
|
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||||
|
</div>
|
||||||
|
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (showing && e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeImage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||||
|
står alene — det er hele grunden til at åbne det. */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgb(0 0 0 / 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||||
|
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||||
|
passe sig ind i den. */
|
||||||
|
.shot img {
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 8rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools button,
|
||||||
|
.tools a {
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools button:hover,
|
||||||
|
.tools a:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
|
||||||
|
.page :global(img) {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -20,10 +20,36 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let wrap = $state(null);
|
let wrap = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
|
||||||
|
*
|
||||||
|
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
|
||||||
|
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
|
||||||
|
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
|
||||||
|
* klippet. Der stod tre en halv linje tekst uden for skærmen.
|
||||||
|
*
|
||||||
|
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
|
||||||
|
* kantafstand til højre for klokken — men så ville den her fil kende sine
|
||||||
|
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
|
||||||
|
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
|
||||||
|
* skærmen med en kant i hver side.
|
||||||
|
*/
|
||||||
|
let below = $state(0);
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
if (open) place();
|
||||||
|
}
|
||||||
|
|
||||||
const KINDS = {
|
const KINDS = {
|
||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
'note.shared': 'notif.noteShared',
|
'note.shared': 'notif.noteShared',
|
||||||
|
'project.shared': 'notif.projectShared',
|
||||||
'note.mention': 'notif.mention',
|
'note.mention': 'notif.mention',
|
||||||
'daily.plan': 'notif.dailyPlan',
|
'daily.plan': 'notif.dailyPlan',
|
||||||
'update.available': 'notif.update',
|
'update.available': 'notif.update',
|
||||||
@@ -56,6 +82,9 @@
|
|||||||
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
||||||
// til at gøre noget ved den sidder.
|
// til at gøre noget ved den sidder.
|
||||||
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
||||||
|
// Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er
|
||||||
|
// påmindelsen, kortet er stedet.
|
||||||
|
if (n.kind === 'daily.plan') return goto('/');
|
||||||
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
else if (n.task_id) app.openDetail(n.task_id);
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
@@ -68,6 +97,7 @@
|
|||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
onclick={outside}
|
onclick={outside}
|
||||||
|
onresize={() => open && place()}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Escape' && open) open = false;
|
if (e.key === 'Escape' && open) open = false;
|
||||||
}}
|
}}
|
||||||
@@ -77,7 +107,7 @@
|
|||||||
<button
|
<button
|
||||||
class="bell"
|
class="bell"
|
||||||
class:on={open}
|
class:on={open}
|
||||||
onclick={() => (open = !open)}
|
onclick={toggle}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-label={app.unread
|
aria-label={app.unread
|
||||||
@@ -97,7 +127,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
|
||||||
<header>
|
<header>
|
||||||
<span>{t('notif.title')}</span>
|
<span>{t('notif.title')}</span>
|
||||||
{#if app.unread}
|
{#if app.unread}
|
||||||
@@ -111,7 +141,10 @@
|
|||||||
<li>
|
<li>
|
||||||
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
<span class="what">{headline(n)}</span>
|
<span class="what">{headline(n)}</span>
|
||||||
{#if n.body}<span class="about">{n.body}</span>{/if}
|
<!-- Dagens plan er tre linjer og ikke en overskrift. Klippet af
|
||||||
|
efter én linje sagde den "2 forfalder i dag, 1 er sprun…",
|
||||||
|
hvilket er den halve oplysning og hele støjen. -->
|
||||||
|
{#if n.body}<span class="about" class:full={n.kind === 'daily.plan'}>{n.body}</span>{/if}
|
||||||
<span class="ago">{when(n.created_at)}</span>
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -186,6 +219,21 @@
|
|||||||
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
|
||||||
|
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
|
||||||
|
toplinjens egen menuknap, så de to skifter form samtidig. */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--below) + 6px);
|
||||||
|
left: var(--s3);
|
||||||
|
right: var(--s3);
|
||||||
|
width: auto;
|
||||||
|
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.panel header {
|
.panel header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -260,6 +308,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about.full {
|
||||||
|
white-space: pre-line;
|
||||||
|
text-overflow: clip;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.ago {
|
.ago {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { readList } from '$lib/pastelist.js';
|
||||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||||
|
|
||||||
let text = $state('');
|
let text = $state('');
|
||||||
@@ -97,6 +98,60 @@
|
|||||||
return out;
|
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) {
|
async function submit(event) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const value = text.trim();
|
const value = text.trim();
|
||||||
@@ -164,13 +219,32 @@
|
|||||||
aria-label={t('task.new')}
|
aria-label={t('task.new')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
{onpaste}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if text.trim()}
|
{#if text.trim() && !pending.length}
|
||||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||||
{/if}
|
{/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.
|
<!-- 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:
|
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
|
the placeholder disappears at the first keystroke, so the help was gone
|
||||||
@@ -190,6 +264,57 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<style>
|
<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. */
|
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
|
||||||
.syntax {
|
.syntax {
|
||||||
/* Nothing to click, and it floats over the first task in the list — which it
|
/* Nothing to click, and it floats over the first task in the list — which it
|
||||||
|
|||||||
@@ -73,6 +73,11 @@
|
|||||||
// through the connector.
|
// through the connector.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
app.labelsChanged;
|
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.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -155,9 +160,12 @@
|
|||||||
// entry is a question the sidebar keeps asking and answering "no".
|
// entry is a question the sidebar keeps asking and answering "no".
|
||||||
let navItems = $derived(
|
let navItems = $derived(
|
||||||
[
|
[
|
||||||
{ key: 'today', href: '/', label: 'nav.today' },
|
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
|
||||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
|
||||||
app.projects.some((p) => p.shared)
|
// 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' }
|
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||||
: null,
|
: null,
|
||||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
// 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
|
// 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
|
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||||
// eksisterende en anden.
|
// eksisterende en anden.
|
||||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -472,11 +480,15 @@
|
|||||||
|
|
||||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
|
||||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
was meant to be: uruz is already another of this author's apps and
|
||||||
the name is in the tab and everywhere else already. -->
|
carries that glyph. See web/static/urd-icon.svg.
|
||||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
|
||||||
<span class="name">verdande</span>
|
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>
|
||||||
|
|
||||||
<div class="views">
|
<div class="views">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { REPEATS } from '$lib/when.js';
|
import { REPEATS } from '$lib/when.js';
|
||||||
|
import { readList } from '$lib/pastelist.js';
|
||||||
|
|
||||||
let { task, onclose } = $props();
|
let { task, onclose } = $props();
|
||||||
|
|
||||||
@@ -171,6 +172,32 @@
|
|||||||
* took is said out loud, because a title that edits itself without a word is
|
* took is said out loud, because a title that edits itself without a word is
|
||||||
* worse than one that does nothing.
|
* worse than one that does nothing.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Lader et felt være lige så højt som sin tekst.
|
||||||
|
*
|
||||||
|
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
|
||||||
|
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
|
||||||
|
* højde.
|
||||||
|
*/
|
||||||
|
function grow(node) {
|
||||||
|
const fit = () => {
|
||||||
|
node.style.height = 'auto';
|
||||||
|
node.style.height = `${node.scrollHeight}px`;
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
node.addEventListener('input', fit);
|
||||||
|
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
|
||||||
|
// tre på en smallere skærm uden at der er rørt ved den.
|
||||||
|
const watch = new ResizeObserver(fit);
|
||||||
|
watch.observe(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('input', fit);
|
||||||
|
watch.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function saveContent() {
|
async function saveContent() {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
@@ -256,6 +283,73 @@
|
|||||||
|
|
||||||
// --- sub-tasks ----------------------------------------------------------------
|
// --- 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) {
|
async function addSubtask(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = newSubtask.trim();
|
const text = newSubtask.trim();
|
||||||
@@ -268,6 +362,7 @@
|
|||||||
parent_id: task.id
|
parent_id: task.id
|
||||||
});
|
});
|
||||||
subtasks = [...subtasks, created];
|
subtasks = [...subtasks, created];
|
||||||
|
countSubtasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
@@ -418,13 +513,27 @@
|
|||||||
|
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input
|
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
|
||||||
|
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
|
||||||
|
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
|
||||||
|
halvdelen af overskriften uden for kanten. -->
|
||||||
|
<textarea
|
||||||
class="title"
|
class="title"
|
||||||
|
rows="1"
|
||||||
use:focusOnMount
|
use:focusOnMount
|
||||||
|
use:grow
|
||||||
bind:value={content}
|
bind:value={content}
|
||||||
onblur={saveContent}
|
onblur={saveContent}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
|
||||||
|
// uanset hvor mange den fylder på skærmen.
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-label={t('detail.text')}
|
aria-label={t('detail.text')}
|
||||||
/>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -595,8 +704,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<form onsubmit={addSubtask}>
|
<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>
|
</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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -701,6 +833,43 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.linked-note {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--s2);
|
padding: var(--s2);
|
||||||
@@ -894,6 +1063,16 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
|
||||||
|
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
|
||||||
|
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
|
||||||
|
ser ikke ud som rulning; det ser ud som manglende bogstaver.
|
||||||
|
|
||||||
|
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
|
||||||
|
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
|
||||||
|
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
|
||||||
|
lektion er betalt én gang. */
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The title is the one field that should not look like a field until you are in
|
/* The title is the one field that should not look like a field until you are in
|
||||||
@@ -905,6 +1084,10 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
|
||||||
|
holde: trækker man i det, retter det næste bogstav det tilbage. */
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title:hover {
|
.title:hover {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const da = {
|
|||||||
'notif.assigned': '{name} gav dig en opgave',
|
'notif.assigned': '{name} gav dig en opgave',
|
||||||
'notif.noteChanged': '{name} rettede en note',
|
'notif.noteChanged': '{name} rettede en note',
|
||||||
'notif.noteShared': '{name} delte en note med dig',
|
'notif.noteShared': '{name} delte en note med dig',
|
||||||
|
'notif.projectShared': '{name} delte et projekt med dig',
|
||||||
'notif.mention': '{name} nævnte dig i en note',
|
'notif.mention': '{name} nævnte dig i en note',
|
||||||
'notif.dailyPlan': 'Dagens plan',
|
'notif.dailyPlan': 'Dagens plan',
|
||||||
'notif.update': 'Der er kommet en ny version',
|
'notif.update': 'Der er kommet en ny version',
|
||||||
@@ -96,6 +97,10 @@ export const da = {
|
|||||||
'ai.planHint':
|
'ai.planHint':
|
||||||
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
|
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
|
||||||
'ai.planHour': 'Klokken',
|
'ai.planHour': 'Klokken',
|
||||||
|
'ai.planMade': 'lavet {time}',
|
||||||
|
'ai.planAgain': 'Forny planen',
|
||||||
|
'ai.planMake': 'Lav dagens plan',
|
||||||
|
'ai.planDismiss': 'Skjul planen i dag',
|
||||||
'ai.planNow': 'Send den nu',
|
'ai.planNow': 'Send den nu',
|
||||||
'ai.planSent': 'Sendt — kig i klokken.',
|
'ai.planSent': 'Sendt — kig i klokken.',
|
||||||
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
||||||
@@ -140,7 +145,7 @@ export const da = {
|
|||||||
// læserens mulighed for at efterprøve det.
|
// læserens mulighed for at efterprøve det.
|
||||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||||
'beacon.noticeBody':
|
'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.noticeTo': 'Sendes til {url}',
|
||||||
'beacon.noticeKeep': 'Behold den',
|
'beacon.noticeKeep': 'Behold den',
|
||||||
'beacon.noticeStop': 'Slå den fra',
|
'beacon.noticeStop': 'Slå den fra',
|
||||||
@@ -168,6 +173,10 @@ export const da = {
|
|||||||
'notes.copyCode': 'Kopiér',
|
'notes.copyCode': 'Kopiér',
|
||||||
'notes.copied': 'Kopieret',
|
'notes.copied': 'Kopieret',
|
||||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||||
|
'notes.imageTitle': 'Billede',
|
||||||
|
'notes.imageCopy': 'Kopiér billedet',
|
||||||
|
'notes.imageCopied': 'Kopieret',
|
||||||
|
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.code': 'Kode',
|
'notes.code': 'Kode',
|
||||||
'notes.showSource': 'Vis kilden (Markdown)',
|
'notes.showSource': 'Vis kilden (Markdown)',
|
||||||
@@ -206,6 +215,7 @@ export const da = {
|
|||||||
'palette.project': 'Projekt',
|
'palette.project': 'Projekt',
|
||||||
'palette.task': 'Opgave',
|
'palette.task': 'Opgave',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Indstilling',
|
||||||
'nav.today': 'I dag',
|
'nav.today': 'I dag',
|
||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
@@ -216,7 +226,7 @@ export const da = {
|
|||||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||||
'nav.main': 'Hovedmenu',
|
'nav.main': 'Hovedmenu',
|
||||||
'nav.search': 'Søg',
|
'nav.search': 'Søg',
|
||||||
'nav.searchLong': 'Søg i opgaver og projekter',
|
'nav.searchLong': 'Søg i opgaver, noter, projekter og indstillinger',
|
||||||
'nav.noResults': 'Ingen resultater.',
|
'nav.noResults': 'Ingen resultater.',
|
||||||
'nav.showSidebar': 'Vis sidebjælken',
|
'nav.showSidebar': 'Vis sidebjælken',
|
||||||
'nav.hideSidebar': 'Skjul sidebjælken',
|
'nav.hideSidebar': 'Skjul sidebjælken',
|
||||||
@@ -227,6 +237,9 @@ export const da = {
|
|||||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||||
'net.reconnecting': 'Genopretter forbindelse…',
|
'net.reconnecting': 'Genopretter forbindelse…',
|
||||||
'net.offline': 'Ingen forbindelse',
|
'net.offline': 'Ingen forbindelse',
|
||||||
|
'net.downTitle': 'Ingen forbindelse',
|
||||||
|
'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',
|
'nav.doItToday': 'Gør det i dag',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
@@ -250,6 +263,9 @@ export const da = {
|
|||||||
|
|
||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Tilføj',
|
'task.add': 'Tilføj',
|
||||||
|
'task.pastedTitle': 'Indsat liste',
|
||||||
|
'task.pastedAdd': 'Opret {n} opgaver',
|
||||||
|
'task.pastedCancel': 'Fortryd',
|
||||||
'task.new': 'Ny opgave',
|
'task.new': 'Ny opgave',
|
||||||
'task.syntaxProject': 'projekt',
|
'task.syntaxProject': 'projekt',
|
||||||
'task.syntaxSection': 'sektion',
|
'task.syntaxSection': 'sektion',
|
||||||
@@ -297,6 +313,8 @@ export const da = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Indstillinger',
|
'settings.title': 'Indstillinger',
|
||||||
|
'settings.search': 'Søg i indstillinger',
|
||||||
|
'settings.searchNone': 'Ingen indstilling hedder det.',
|
||||||
'settings.tab.account': 'Konto',
|
'settings.tab.account': 'Konto',
|
||||||
'settings.tab.notifications': 'Notifikationer',
|
'settings.tab.notifications': 'Notifikationer',
|
||||||
'settings.tab.integrations': 'Integrationer',
|
'settings.tab.integrations': 'Integrationer',
|
||||||
@@ -335,7 +353,7 @@ export const da = {
|
|||||||
'size.default': 'Standard',
|
'size.default': 'Standard',
|
||||||
'size.large': 'Stor',
|
'size.large': 'Stor',
|
||||||
'size.xl': 'Størst',
|
'size.xl': 'Størst',
|
||||||
'look.verdande': 'Verdande',
|
'look.verdande': '{product}',
|
||||||
'look.verdandeNote': 'Som du kender den.',
|
'look.verdandeNote': 'Som du kender den.',
|
||||||
'look.rolig': 'Rolig',
|
'look.rolig': 'Rolig',
|
||||||
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||||
@@ -596,6 +614,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.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.notesImported': '{n} noter og {files} filer hentet ind.',
|
||||||
'data.exportNotes': 'Alle noter som Markdown-filer',
|
'data.exportNotes': 'Alle noter som Markdown-filer',
|
||||||
|
'data.exportAllCSV': 'Alle opgaver (CSV)',
|
||||||
|
'data.exportAllICS': 'Alle opgaver (kalender)',
|
||||||
'data.exportAccount': 'Hele kontoen som JSON',
|
'data.exportAccount': 'Hele kontoen som JSON',
|
||||||
'data.exportOne': 'Ét projekt',
|
'data.exportOne': 'Ét projekt',
|
||||||
'data.pickProject': 'Vælg et projekt',
|
'data.pickProject': 'Vælg et projekt',
|
||||||
@@ -901,6 +921,14 @@ export const da = {
|
|||||||
'project.canEdit': 'Kan redigere',
|
'project.canEdit': 'Kan redigere',
|
||||||
'project.canView': 'Kan kun se',
|
'project.canView': 'Kan kun se',
|
||||||
'project.invite': 'Inviter',
|
'project.invite': 'Inviter',
|
||||||
|
'project.pickPerson': 'Vælg en person…',
|
||||||
|
'project.leave': 'Forlad projektet',
|
||||||
|
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
|
||||||
|
'project.roleForPicked': 'Rolle for den valgte',
|
||||||
|
'project.shareWith': 'Del',
|
||||||
|
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||||
|
'project.invited': 'Inviteret, ikke kommet endnu',
|
||||||
|
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
|
||||||
'project.inviteByEmail': 'Inviter via e-mail',
|
'project.inviteByEmail': 'Inviter via e-mail',
|
||||||
'project.emailAddress': 'e-mailadresse',
|
'project.emailAddress': 'e-mailadresse',
|
||||||
'project.role': 'Rolle',
|
'project.role': 'Rolle',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const en = {
|
|||||||
'notif.assigned': '{name} gave you a task',
|
'notif.assigned': '{name} gave you a task',
|
||||||
'notif.noteChanged': '{name} edited a note',
|
'notif.noteChanged': '{name} edited a note',
|
||||||
'notif.noteShared': '{name} shared a note with you',
|
'notif.noteShared': '{name} shared a note with you',
|
||||||
|
'notif.projectShared': '{name} shared a project with you',
|
||||||
'notif.mention': '{name} mentioned you in a note',
|
'notif.mention': '{name} mentioned you in a note',
|
||||||
'notif.dailyPlan': "Today's plan",
|
'notif.dailyPlan': "Today's plan",
|
||||||
'notif.update': 'A new version is out',
|
'notif.update': 'A new version is out',
|
||||||
@@ -93,6 +94,10 @@ export const en = {
|
|||||||
'ai.planHint':
|
'ai.planHint':
|
||||||
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
|
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
|
||||||
'ai.planHour': 'At',
|
'ai.planHour': 'At',
|
||||||
|
'ai.planMade': 'made at {time}',
|
||||||
|
'ai.planAgain': 'Make it again',
|
||||||
|
'ai.planMake': "Make today's plan",
|
||||||
|
'ai.planDismiss': 'Hide the plan for today',
|
||||||
'ai.planNow': 'Send it now',
|
'ai.planNow': 'Send it now',
|
||||||
'ai.planSent': 'Sent — look in the bell.',
|
'ai.planSent': 'Sent — look in the bell.',
|
||||||
'ai.planNothing': 'There is nothing to plan for today.',
|
'ai.planNothing': 'There is nothing to plan for today.',
|
||||||
@@ -137,7 +142,7 @@ export const en = {
|
|||||||
// the reader's ability to check it.
|
// the reader's ability to check it.
|
||||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||||
'beacon.noticeBody':
|
'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.noticeTo': 'Sent to {url}',
|
||||||
'beacon.noticeKeep': 'Keep it on',
|
'beacon.noticeKeep': 'Keep it on',
|
||||||
'beacon.noticeStop': 'Turn it off',
|
'beacon.noticeStop': 'Turn it off',
|
||||||
@@ -165,6 +170,10 @@ export const en = {
|
|||||||
'notes.copyCode': 'Copy',
|
'notes.copyCode': 'Copy',
|
||||||
'notes.copied': 'Copied',
|
'notes.copied': 'Copied',
|
||||||
'notes.copyFailed': 'Could not copy',
|
'notes.copyFailed': 'Could not copy',
|
||||||
|
'notes.imageTitle': 'Image',
|
||||||
|
'notes.imageCopy': 'Copy the image',
|
||||||
|
'notes.imageCopied': 'Copied',
|
||||||
|
'notes.imageOpen': 'Open in a new tab',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.code': 'Code',
|
'notes.code': 'Code',
|
||||||
'notes.showSource': 'Show the source (Markdown)',
|
'notes.showSource': 'Show the source (Markdown)',
|
||||||
@@ -203,6 +212,7 @@ export const en = {
|
|||||||
'palette.project': 'Project',
|
'palette.project': 'Project',
|
||||||
'palette.task': 'Task',
|
'palette.task': 'Task',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Setting',
|
||||||
'nav.today': 'Today',
|
'nav.today': 'Today',
|
||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
@@ -213,7 +223,7 @@ export const en = {
|
|||||||
'nav.signOutConfirm': 'Click again to sign out',
|
'nav.signOutConfirm': 'Click again to sign out',
|
||||||
'nav.main': 'Main menu',
|
'nav.main': 'Main menu',
|
||||||
'nav.search': 'Search',
|
'nav.search': 'Search',
|
||||||
'nav.searchLong': 'Search tasks and projects',
|
'nav.searchLong': 'Search tasks, notes, projects and settings',
|
||||||
'nav.noResults': 'Nothing found.',
|
'nav.noResults': 'Nothing found.',
|
||||||
'nav.showSidebar': 'Show the sidebar',
|
'nav.showSidebar': 'Show the sidebar',
|
||||||
'nav.hideSidebar': 'Hide the sidebar',
|
'nav.hideSidebar': 'Hide the sidebar',
|
||||||
@@ -224,6 +234,9 @@ export const en = {
|
|||||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||||
'net.reconnecting': 'Reconnecting…',
|
'net.reconnecting': 'Reconnecting…',
|
||||||
'net.offline': 'No connection',
|
'net.offline': 'No connection',
|
||||||
|
'net.downTitle': 'No connection',
|
||||||
|
'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',
|
'nav.doItToday': 'Do it today',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
@@ -247,6 +260,9 @@ export const en = {
|
|||||||
|
|
||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Add',
|
'task.add': 'Add',
|
||||||
|
'task.pastedTitle': 'Pasted list',
|
||||||
|
'task.pastedAdd': 'Add {n} tasks',
|
||||||
|
'task.pastedCancel': 'Cancel',
|
||||||
'task.new': 'New task',
|
'task.new': 'New task',
|
||||||
'task.syntaxProject': 'project',
|
'task.syntaxProject': 'project',
|
||||||
'task.syntaxSection': 'section',
|
'task.syntaxSection': 'section',
|
||||||
@@ -294,6 +310,8 @@ export const en = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
|
'settings.search': 'Search settings',
|
||||||
|
'settings.searchNone': 'No setting goes by that name.',
|
||||||
'settings.tab.account': 'Account',
|
'settings.tab.account': 'Account',
|
||||||
'settings.tab.notifications': 'Notifications',
|
'settings.tab.notifications': 'Notifications',
|
||||||
'settings.tab.integrations': 'Integrations',
|
'settings.tab.integrations': 'Integrations',
|
||||||
@@ -332,7 +350,7 @@ export const en = {
|
|||||||
'size.default': 'Default',
|
'size.default': 'Default',
|
||||||
'size.large': 'Large',
|
'size.large': 'Large',
|
||||||
'size.xl': 'Largest',
|
'size.xl': 'Largest',
|
||||||
'look.verdande': 'Verdande',
|
'look.verdande': '{product}',
|
||||||
'look.verdandeNote': 'As you know it.',
|
'look.verdandeNote': 'As you know it.',
|
||||||
'look.rolig': 'Calm',
|
'look.rolig': 'Calm',
|
||||||
'look.roligNote': 'Serif, more air. For reading and writing.',
|
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||||
@@ -592,6 +610,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.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.notesImported': '{n} notes and {files} files brought in.',
|
||||||
'data.exportNotes': 'Every note as Markdown files',
|
'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.exportAccount': 'The whole account as JSON',
|
||||||
'data.exportOne': 'One project',
|
'data.exportOne': 'One project',
|
||||||
'data.pickProject': 'Pick a project',
|
'data.pickProject': 'Pick a project',
|
||||||
@@ -895,6 +915,14 @@ export const en = {
|
|||||||
'project.canEdit': 'Can edit',
|
'project.canEdit': 'Can edit',
|
||||||
'project.canView': 'Can only view',
|
'project.canView': 'Can only view',
|
||||||
'project.invite': 'Invite',
|
'project.invite': 'Invite',
|
||||||
|
'project.pickPerson': 'Pick somebody…',
|
||||||
|
'project.leave': 'Leave the project',
|
||||||
|
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
|
||||||
|
'project.roleForPicked': 'Role for the person picked',
|
||||||
|
'project.shareWith': 'Share',
|
||||||
|
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||||
|
'project.invited': 'Invited, not arrived yet',
|
||||||
|
'project.withdrawInvite': 'Withdraw the invitation to {email}',
|
||||||
'project.inviteByEmail': 'Invite by email',
|
'project.inviteByEmail': 'Invite by email',
|
||||||
'project.emailAddress': 'email address',
|
'project.emailAddress': 'email address',
|
||||||
'project.role': 'Role',
|
'project.role': 'Role',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+44
-8
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
|||||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teksten i et blok, med linjeskiftene i behold.
|
||||||
|
*
|
||||||
|
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
|
||||||
|
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
|
||||||
|
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
|
||||||
|
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
|
||||||
|
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
|
||||||
|
* tiden.
|
||||||
|
*
|
||||||
|
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
|
||||||
|
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
|
||||||
|
* gemning.
|
||||||
|
*
|
||||||
|
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
|
||||||
|
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
|
||||||
|
* der allerede har tabt linjeskiftet.
|
||||||
|
*/
|
||||||
|
export function blockText(node) {
|
||||||
|
let out = '';
|
||||||
|
for (const n of node.childNodes) {
|
||||||
|
if (n.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
out += n.textContent ?? '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n.classList?.contains('copy')) continue;
|
||||||
|
if (n.tagName === 'BR') {
|
||||||
|
out += '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += blockText(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function htmlToMarkdown(root) {
|
export function htmlToMarkdown(root) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
|
|||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
emit('```' + lang);
|
emit('```' + lang);
|
||||||
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
const code = blockText(child);
|
||||||
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
|
||||||
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
|
||||||
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
|
||||||
const code = [...child.childNodes]
|
|
||||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
|
||||||
.map((n) => n.textContent ?? '')
|
|
||||||
.join('');
|
|
||||||
for (const l of code.replace(/\n$/, '').split('\n')) {
|
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||||
emit(l);
|
emit(l);
|
||||||
}
|
}
|
||||||
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
|
|||||||
const pad = ' '.repeat(depth);
|
const pad = ' '.repeat(depth);
|
||||||
|
|
||||||
for (const li of el.children) {
|
for (const li of el.children) {
|
||||||
|
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
|
||||||
|
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
|
||||||
|
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
|
||||||
|
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
|
||||||
|
if (li.tagName === 'UL' || li.tagName === 'OL') {
|
||||||
|
list(li, depth + 1, emit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (li.tagName !== 'LI') continue;
|
if (li.tagName !== 'LI') continue;
|
||||||
|
|
||||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Hvor hver indstilling står, så den kan findes ved at søge efter den.
|
||||||
|
*
|
||||||
|
* Indstillingerne er ti sider med en snes afsnit i alt, og der var ingen vej til
|
||||||
|
* et bestemt af dem andet end at huske, hvilken fane det lå under. ⌘K kunne
|
||||||
|
* opgaver, projekter og noter — altså alt det, man selv har skrevet, og intet af
|
||||||
|
* det, programmet består af.
|
||||||
|
*
|
||||||
|
* Ét indeks og ikke to søgninger. Feltet øverst i indstillingerne og paletten
|
||||||
|
* stiller det samme spørgsmål, og to lister over de samme afsnit ville være to
|
||||||
|
* steder at glemme et nyt et.
|
||||||
|
*
|
||||||
|
* Overskrifterne står ikke her. Hver post peger på den nøgle, afsnittets egen
|
||||||
|
* overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig
|
||||||
|
* selv — og søgningen kan begge sprog uden at kende nogen af dem.
|
||||||
|
*
|
||||||
|
* `words` er derimod skrevet her, og det er med vilje: det er søgeord, ingen får
|
||||||
|
* at se. De er de tekniske navne — `imap`, `webcal`, `2fa`, `webhook` — som er de
|
||||||
|
* samme på dansk og engelsk, og som er dem, man taster, når man leder efter det,
|
||||||
|
* man kender andetsteds fra. Det, der skal oversættes, er overskriften, og den
|
||||||
|
* ligger i sprogfilen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hver post: siden, ankeret på den, overskriftens nøgle, fanens nøgle. */
|
||||||
|
export const SETTINGS = [
|
||||||
|
// Konto
|
||||||
|
{ path: '/indstillinger', anchor: 'profil', key: 'account.profile', tab: 'settings.tab.account', words: 'profil profile navn name email mail tidszone timezone sprog language' },
|
||||||
|
{ path: '/indstillinger', anchor: 'udseende', key: 'account.appearance', tab: 'settings.tab.account', words: 'tema theme dark light farve color' },
|
||||||
|
{ path: '/indstillinger', anchor: 'look', key: 'account.look', tab: 'settings.tab.account', words: 'serif terminal' },
|
||||||
|
{ path: '/indstillinger', anchor: 'stoerrelser', key: 'account.sizes', tab: 'settings.tab.account', words: 'skrift font zoom' },
|
||||||
|
{ path: '/indstillinger', anchor: 'links', key: 'link.title', tab: 'settings.tab.account', words: 'browser safari chrome firefox' },
|
||||||
|
{ path: '/indstillinger', anchor: 'adgangskode', key: 'account.password', tab: 'settings.tab.account', words: 'password kodeord' },
|
||||||
|
{ path: '/indstillinger', anchor: 'to-faktor', key: 'account.totp', tab: 'settings.tab.account', words: '2fa totp authenticator to-faktor' },
|
||||||
|
{ path: '/indstillinger', anchor: 'passkey', key: 'passkey.title', tab: 'settings.tab.account', words: 'passkey webauthn touch id face' },
|
||||||
|
{ path: '/indstillinger', anchor: 'enheder', key: 'account.devices', tab: 'settings.tab.account', words: 'session sessioner devices log ud' },
|
||||||
|
{ path: '/indstillinger', anchor: 'stoette', key: 'account.support', tab: 'settings.tab.account', words: 'donate kaffe coffee' },
|
||||||
|
|
||||||
|
// Notifikationer
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||||
|
|
||||||
|
// Integrationer
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'gmail', key: 'int.gmail', tab: 'settings.tab.integrations', words: 'gmail google oauth' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'kalender', key: 'int.calendar', tab: 'settings.tab.integrations', words: 'google calendar' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'abonnementer', key: 'int.subscriptions', tab: 'settings.tab.integrations', words: 'ics webcal abonnement subscribe' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'kalenderfeed', key: 'int.feed', tab: 'settings.tab.integrations', words: 'ical ics feed apple thunderbird' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'mail-til-opgave', key: 'int.mailToTask', tab: 'settings.tab.integrations', words: 'mail email forward videresend' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'krog', key: 'int.hookToTask', tab: 'settings.tab.integrations', words: 'webhook hook curl shortcut genvej' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'caldav-server', key: 'int.caldav', tab: 'settings.tab.integrations', words: 'caldav reminders' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'postkasser', key: 'int.mailboxes', tab: 'settings.tab.integrations', words: 'imap icloud fastmail postkasse' },
|
||||||
|
|
||||||
|
// AI
|
||||||
|
{ path: '/indstillinger/ai', anchor: 'ai', key: 'ai.title', tab: 'settings.tab.ai', words: 'ai model anthropic openai claude gpt gemini api key' },
|
||||||
|
{ path: '/indstillinger/ai', anchor: 'ugentlig', key: 'ai.weekly', tab: 'settings.tab.ai', words: 'uge weekly opsamling' },
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
{ path: '/indstillinger/tokens', anchor: 'tokens', key: 'tokens.title', tab: 'settings.tab.tokens', words: 'token api mcp connector bearer' },
|
||||||
|
|
||||||
|
// Brugere, historik, fejl — kun for administratorer
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'invitation', key: 'users.invite', tab: 'settings.tab.users', words: 'invite invitation bruger user', admin: true },
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'afventende', key: 'users.pending', tab: 'settings.tab.users', words: 'pending afventer', admin: true },
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'konti', key: 'users.accounts', tab: 'settings.tab.users', words: 'konti accounts admin', admin: true },
|
||||||
|
{ path: '/indstillinger/historik', anchor: 'historik', key: 'history.title', tab: 'settings.tab.history', words: 'aktivitet activity log', admin: true },
|
||||||
|
{ path: '/indstillinger/fejl', anchor: 'fejl', key: 'errors.title', tab: 'settings.tab.errors', words: 'error 500 log', admin: true },
|
||||||
|
|
||||||
|
// Data
|
||||||
|
{ path: '/indstillinger/data', anchor: 'import', key: 'data.import', tab: 'settings.tab.data', words: 'import todoist csv json' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'faerdige', key: 'done.title', tab: 'settings.tab.data', words: 'done completed oprydning' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'papirkurv', key: 'data.trash', tab: 'settings.tab.data', words: 'trash slettet deleted' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'sikkerhedskopier', key: 'data.backups', tab: 'settings.tab.data', words: 'backup sikkerhedskopi', admin: true },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'eksport', key: 'data.export', tab: 'settings.tab.data', words: 'export eksport zip markdown' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'skabeloner', key: 'data.templates', tab: 'settings.tab.data', words: 'template skabelon' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'beacon', key: 'beacon.title', tab: 'settings.tab.data', words: 'beacon tæller collector telemetri' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samme foldning som serverens `fold`-kolonne.
|
||||||
|
*
|
||||||
|
* `remove_diacritics` i SQLite gør intet ved ø, æ og å — de er egne bogstaver og
|
||||||
|
* ikke accenter — og en dansk søgning, der ikke kan finde "sikkerhedskopier" fra
|
||||||
|
* "sikkerhedskopier" skrevet på et fremmed tastatur, er brudt for sine egne
|
||||||
|
* brugere. Derfor samme oversættelse her som der, og `normalize` ovenpå til alt
|
||||||
|
* det, Unicode faktisk regner for accenter.
|
||||||
|
*/
|
||||||
|
export function fold(text) {
|
||||||
|
return (text ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ø/g, 'o')
|
||||||
|
.replace(/æ/g, 'ae')
|
||||||
|
.replace(/å/g, 'aa')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indstillingerne, der svarer til det skrevne.
|
||||||
|
*
|
||||||
|
* Alle ordene skal findes, hvert af dem som begyndelsen af et ord — "sik kop"
|
||||||
|
* finder sikkerhedskopierne. Den, der søger, skriver forfra; en søgning, der også
|
||||||
|
* matcher midt i et ord, finder "opgave" i "hovedopgaven" og larmer.
|
||||||
|
*
|
||||||
|
* `t` gives med udefra frem for at blive importeret. Det holder filen fri af
|
||||||
|
* Sveltes runes, så den kan læses af en almindelig prøve uden en komponent
|
||||||
|
* omkring sig.
|
||||||
|
*/
|
||||||
|
export function findSettings(query, { t, isAdmin = false, limit = 8 } = {}) {
|
||||||
|
const terms = fold(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (!terms.length) return [];
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
if (entry.admin && !isAdmin) continue;
|
||||||
|
|
||||||
|
const title = t(entry.key);
|
||||||
|
const tab = t(entry.tab);
|
||||||
|
const words = fold(`${title} ${tab} ${entry.words}`)
|
||||||
|
.split(/[\s/·,-]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Alle ordene skal findes, og hvert af dem forfra i et ord: "sikker" finder
|
||||||
|
// sikkerhedskopierne, "kopier" gør ikke. Den, der søger, skriver
|
||||||
|
// begyndelsen af det, den leder efter — en søgning, der også rammer midt i
|
||||||
|
// et ord, finder "opgave" inde i "hovedopgaven" og larmer.
|
||||||
|
if (!terms.every((term) => words.some((word) => word.startsWith(term)))) continue;
|
||||||
|
|
||||||
|
// Det, der hedder det, man søgte efter, før det, der blot nævner det. Uden
|
||||||
|
// den her ligger rækkefølgen fast som filens, og "beacon" ville få hele
|
||||||
|
// Data-siden ned over sig, fordi den står først.
|
||||||
|
const folded = fold(title);
|
||||||
|
const rank = folded.startsWith(terms[0]) ? 0 : folded.includes(terms[0]) ? 1 : 2;
|
||||||
|
hits.push({ rank, entry: { ...entry, title, tab, href: `${entry.path}#${entry.anchor}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((hit) => hit.entry);
|
||||||
|
}
|
||||||
@@ -29,6 +29,79 @@ class AppState {
|
|||||||
people = $state([]);
|
people = $state([]);
|
||||||
tasks = $state([]);
|
tasks = $state([]);
|
||||||
loading = $state(true);
|
loading = $state(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the boot never reached the server — as opposed to reaching it and
|
||||||
|
* being told the session is gone.
|
||||||
|
*
|
||||||
|
* The two are indistinguishable from `user === null`, and they are opposite
|
||||||
|
* things to the person looking at the screen: one means "log ind", the other
|
||||||
|
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
|
||||||
|
* total outage used to show the login screen, so the app blamed the user's
|
||||||
|
* session for the network being down — and a login page that cannot possibly
|
||||||
|
* work is worse than an error, because it looks like it might.
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get productName() {
|
||||||
|
return this.notesOnly ? 'Urd' : 'Verdande';
|
||||||
|
}
|
||||||
|
|
||||||
|
get productSlug() {
|
||||||
|
return this.notesOnly ? '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.notesOnly ? 'ð' : '\u16B9';
|
||||||
|
}
|
||||||
|
|
||||||
/** Transient messages: a failed save, a rolled-back change. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
toasts = $state([]);
|
toasts = $state([]);
|
||||||
connected = $state(false);
|
connected = $state(false);
|
||||||
@@ -94,8 +167,20 @@ class AppState {
|
|||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
this.user = await api.me();
|
this.user = await api.me();
|
||||||
|
this.unreachable = false;
|
||||||
// The interface's language follows the account, so it is set the moment
|
// The interface's language follows the account, so it is set the moment
|
||||||
// the session is known — before anything has drawn a string.
|
// the session is known — before anything has drawn a string.
|
||||||
i18n.set(this.user.locale);
|
i18n.set(this.user.locale);
|
||||||
@@ -111,7 +196,15 @@ class AppState {
|
|||||||
this.loadNotifications();
|
this.loadNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
|
// `offline` is the one code the request layer makes up itself: it is what
|
||||||
|
// is thrown when the retry window ran out without any status ever
|
||||||
|
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||||
|
this.unreachable = e?.code === 'offline';
|
||||||
} finally {
|
} 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;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +352,21 @@ class AppState {
|
|||||||
* `completed: 'exclude'`.
|
* `completed: 'exclude'`.
|
||||||
*/
|
*/
|
||||||
async loadTasks(params) {
|
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({
|
const { tasks } = await api.listTasks({
|
||||||
completed: completedView.shown ? 'include' : undefined,
|
completed: completedView.shown ? 'include' : undefined,
|
||||||
...params
|
...params
|
||||||
@@ -505,9 +613,9 @@ class AppState {
|
|||||||
* request is fast and the box clears immediately, which is where the
|
* request is fast and the box clears immediately, which is where the
|
||||||
* responsiveness actually needs to be.
|
* responsiveness actually needs to be.
|
||||||
*/
|
*/
|
||||||
async quickAdd(text, projectId) {
|
async quickAdd(text, projectId, sectionId, parentId) {
|
||||||
try {
|
try {
|
||||||
const task = await api.quickAdd(text, projectId);
|
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||||
this.upsert(task);
|
this.upsert(task);
|
||||||
return task;
|
return task;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
|
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { setBadge } from '$lib/badge.js';
|
import { setBadge } from '$lib/badge.js';
|
||||||
@@ -56,6 +57,39 @@
|
|||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||||
*
|
*
|
||||||
@@ -129,8 +163,20 @@
|
|||||||
// Folding the sidebar had a button and nothing else, and a rectangle with a
|
// 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
|
// 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
|
// 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.
|
// it.
|
||||||
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
|
//
|
||||||
|
// 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();
|
event.preventDefault();
|
||||||
sidebar.toggle();
|
sidebar.toggle();
|
||||||
return;
|
return;
|
||||||
@@ -166,6 +212,10 @@
|
|||||||
<div class="booting"></div>
|
<div class="booting"></div>
|
||||||
{:else if standalone}
|
{:else if standalone}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{:else if app.unreachable}
|
||||||
|
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
|
||||||
|
kodeord hjælper ikke mod en manglende forbindelse. -->
|
||||||
|
<NoConnection />
|
||||||
{:else if !app.user}
|
{:else if !app.user}
|
||||||
<SignIn />
|
<SignIn />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
@@ -17,6 +18,72 @@
|
|||||||
).padStart(2, '0')}`;
|
).padStart(2, '0')}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dagens plan, dér hvor man kigger, når man starter dagen.
|
||||||
|
*
|
||||||
|
* Hentet og ikke regnet forfra: det er den samme tekst, beskeden i morges bar.
|
||||||
|
* En plan, der siger noget andet, når man kigger på den, end den sagde, da den
|
||||||
|
* kom, er værre end begge dele hver for sig — og en model pr. sidevisning er
|
||||||
|
* dyr for noget, der ikke har ændret sig.
|
||||||
|
*
|
||||||
|
* Lukket for i dag frem for slettet. Kortet er en påmindelse, ikke en opgave,
|
||||||
|
* og den, der har læst den, skal kunne få den af vejen uden at miste den —
|
||||||
|
* derfor står lukningen i localStorage med dagens dato og ikke på kontoen.
|
||||||
|
*/
|
||||||
|
let plan = $state(null);
|
||||||
|
let planBusy = $state(false);
|
||||||
|
let planError = $state('');
|
||||||
|
const DISMISS_KEY = 'verdande:plan-dismissed';
|
||||||
|
|
||||||
|
let dismissed = $state(
|
||||||
|
typeof localStorage !== 'undefined' && localStorage.getItem(DISMISS_KEY) === isoToday
|
||||||
|
);
|
||||||
|
|
||||||
|
function dismissPlan() {
|
||||||
|
dismissed = true;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, isoToday);
|
||||||
|
} catch {
|
||||||
|
// Privat vindue; kortet er væk til næste indlæsning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePlan() {
|
||||||
|
if (planBusy) return;
|
||||||
|
planBusy = true;
|
||||||
|
planError = '';
|
||||||
|
try {
|
||||||
|
// Stille: man står og kigger på den. En besked om noget, man har foran
|
||||||
|
// sig, er den slags, der lærer folk at ignorere klokken.
|
||||||
|
const made = await api.planNow({ silent: true });
|
||||||
|
plan = { ...plan, plan: made.plan, plan_at: made.plan_at };
|
||||||
|
dismissed = false;
|
||||||
|
} catch (e) {
|
||||||
|
planError = humanMessage(e);
|
||||||
|
} finally {
|
||||||
|
planBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.planSettings()
|
||||||
|
.then((p) => (plan = p))
|
||||||
|
.catch(() => {
|
||||||
|
// Et kort, der ikke kunne hentes, må ikke stå i vejen for listen.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const planMade = $derived(
|
||||||
|
plan?.plan_at
|
||||||
|
? new Date(plan.plan_at * 1000).toLocaleTimeString(tag(), {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await api.today();
|
const data = await api.today();
|
||||||
app.tasks = [...data.overdue, ...data.today];
|
app.tasks = [...data.overdue, ...data.today];
|
||||||
@@ -54,6 +121,33 @@
|
|||||||
|
|
||||||
<QuickAdd />
|
<QuickAdd />
|
||||||
|
|
||||||
|
{#if plan?.plan && !dismissed}
|
||||||
|
<!-- Teksten som den blev sendt, og klokkeslættet den blev lavet: en plan fra
|
||||||
|
klokken syv er en anden slags oplysning klokken fire end klokken otte. -->
|
||||||
|
<section class="plan">
|
||||||
|
<header>
|
||||||
|
<h2>{t('ai.plan')}</h2>
|
||||||
|
{#if planMade}<span class="made">{t('ai.planMade', { time: planMade })}</span>{/if}
|
||||||
|
<button class="close" onclick={dismissPlan} aria-label={t('ai.planDismiss')}>×</button>
|
||||||
|
</header>
|
||||||
|
<p>{plan.plan}</p>
|
||||||
|
<button class="again" onclick={makePlan} disabled={planBusy}>
|
||||||
|
{planBusy ? t('ai.thinking') : t('ai.planAgain')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
{:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)}
|
||||||
|
<!-- Ingen plan i dag: enten er beskeden slået fra, eller også er timen ikke
|
||||||
|
kommet endnu. Knappen frem for ingenting, så den, der har slået
|
||||||
|
morgenbeskeden fra, stadig kan bede om planen. Kun når der er noget at
|
||||||
|
lægge en plan om — en tom dag har ikke brug for en knap. -->
|
||||||
|
<p class="planask">
|
||||||
|
<button class="again" onclick={makePlan} disabled={planBusy}>
|
||||||
|
{planBusy ? t('ai.thinking') : t('ai.planMake')}
|
||||||
|
</button>
|
||||||
|
{#if planError}<span class="planerror">{planError}</span>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if liveOverdue.length}
|
{#if liveOverdue.length}
|
||||||
<section>
|
<section>
|
||||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||||
@@ -93,6 +187,77 @@
|
|||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kortet ligger mellem feltet og listen: under det, man skriver i, og over det,
|
||||||
|
man skal se på. Dæmpet frem for fremhævet — det er en påmindelse om, hvad
|
||||||
|
dagen indeholder, ikke en opgave, der skal laves. */
|
||||||
|
.plan {
|
||||||
|
margin: 0 0 var(--s5);
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan h2 {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan .made {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan .close {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linjeskift bevaret: planen er to-tre linjer, og de er skrevet som linjer. */
|
||||||
|
.plan p {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.again {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.again:disabled {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planask {
|
||||||
|
margin: 0 0 var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planerror {
|
||||||
|
margin-left: var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="view">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="view">
|
||||||
{#if status === 'denied'}
|
{#if status === 'denied'}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
* is six places for them to drift apart.
|
* is six places for them to drift apart.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -37,6 +39,69 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Søgningen efter en indstilling.
|
||||||
|
*
|
||||||
|
* Ti faner med en snes afsnit i alt, og den eneste vej til et bestemt af dem
|
||||||
|
* var at huske, hvilken fane det lå under. Feltet her og ⌘K spørger det samme
|
||||||
|
* indeks — se settingsindex.js — så et nyt afsnit kun skal skrives ét sted for
|
||||||
|
* at kunne findes to.
|
||||||
|
*/
|
||||||
|
let query = $state('');
|
||||||
|
let hits = $derived(findSettings(query, { t, isAdmin: app.user?.is_admin ?? false }));
|
||||||
|
|
||||||
|
function go(hit) {
|
||||||
|
query = '';
|
||||||
|
goto(hit.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
query = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && hits.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
go(hits[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruller hen til afsnittet, når adressen nævner det, og blinker det.
|
||||||
|
*
|
||||||
|
* Browserens egen springen til et anker kan ikke bruges her: siden hentes af
|
||||||
|
* ruteren, og afsnittet findes først, når dens data er hjemme — så et spring,
|
||||||
|
* der sker med det samme, lander på en side, der endnu er tom. Derfor prøves
|
||||||
|
* det, indtil elementet er der, og gives op efter to sekunder frem for at
|
||||||
|
* prøve for evigt.
|
||||||
|
*
|
||||||
|
* Og blinket er ikke pynt. Man er landet et sted, man ikke selv rullede hen
|
||||||
|
* til, midt i en side med otte afsnit, der ligner hinanden; uden det skal man
|
||||||
|
* læse sig frem til, hvor man er.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const anchor = $page.url.hash.slice(1);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
let tries = 0;
|
||||||
|
let timer;
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const look = () => {
|
||||||
|
const el = document.getElementById(anchor);
|
||||||
|
if (!el) {
|
||||||
|
if (tries++ < 40) timer = setTimeout(look, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' });
|
||||||
|
el.classList.add('landed');
|
||||||
|
timer = setTimeout(() => el.classList.remove('landed'), 1600);
|
||||||
|
};
|
||||||
|
look();
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
@@ -44,6 +109,34 @@
|
|||||||
<h1>{t('settings.title')}</h1>
|
<h1>{t('settings.title')}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="find">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={query}
|
||||||
|
{onkeydown}
|
||||||
|
placeholder={t('settings.search')}
|
||||||
|
aria-label={t('settings.search')}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
{#if query.trim()}
|
||||||
|
{#if hits.length}
|
||||||
|
<ul class="hits">
|
||||||
|
{#each hits as hit (hit.href)}
|
||||||
|
<li>
|
||||||
|
<button onclick={() => go(hit)}>
|
||||||
|
<span class="where">{hit.tab}</span>
|
||||||
|
<span class="what">{hit.title}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="nohits">{t('settings.searchNone')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav aria-label={t('settings.title')}>
|
<nav aria-label={t('settings.title')}>
|
||||||
{#each sections as section (section.href)}
|
{#each sections as section (section.href)}
|
||||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||||
@@ -109,9 +202,108 @@
|
|||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.find {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.find input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.find input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Over indholdet frem for at skubbe det ned: listen kommer og går for hvert
|
||||||
|
bogstav, og en side, der hopper under det, man læser, er svær at sigte i. */
|
||||||
|
.hits {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: var(--s1) 0 0;
|
||||||
|
padding: var(--s1);
|
||||||
|
list-style: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hits button {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hits button:hover {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fanen står før navnet, fordi svaret på "hvor ligger det?" er halvdelen af
|
||||||
|
det, man søgte efter. */
|
||||||
|
.where {
|
||||||
|
flex: none;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nohits {
|
||||||
|
margin: var(--s2) 0 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings :global(h3[id]) {
|
||||||
|
scroll-margin-top: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinket, når man er landet et sted, man ikke selv rullede hen til. Kanten og
|
||||||
|
ikke baggrunden: en baggrund, der skifter, gør teksten svær at læse i netop
|
||||||
|
det øjeblik, man er kommet for at læse den. */
|
||||||
|
.settings :global(.landed) {
|
||||||
|
animation: landed 1.6s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes landed {
|
||||||
|
0%,
|
||||||
|
60% {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 2px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.settings :global(.landed) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- shared form chrome, for the child routes ------------------------------- */
|
/* --- shared form chrome, for the child routes ------------------------------- */
|
||||||
|
|
||||||
.settings :global(section.panel) {
|
.settings :global(section.panel) {
|
||||||
|
/* Der er en fast linje øverst på skærmen. Uden den her lander afsnittets
|
||||||
|
overskrift bag den, når man springer hertil fra en søgning. */
|
||||||
|
scroll-margin-top: var(--s5);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -151,7 +343,8 @@
|
|||||||
|
|
||||||
.settings :global(.field input),
|
.settings :global(.field input),
|
||||||
.settings :global(.field select),
|
.settings :global(.field select),
|
||||||
.settings :global(.field textarea) {
|
.settings :global(.field textarea),
|
||||||
|
.settings :global(.field output) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--s2) var(--s3);
|
padding: var(--s2) var(--s3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
@@ -168,6 +361,22 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
|
||||||
|
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
|
||||||
|
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
|
||||||
|
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
|
||||||
|
er tekst, der ombryder.
|
||||||
|
|
||||||
|
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
|
||||||
|
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
|
||||||
|
.settings :global(.field output) {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* The field error sits under its own input rather than in a summary at the top:
|
/* The field error sits under its own input rather than in a summary at the top:
|
||||||
the server answers with a map of field to message, and putting each one where
|
the server answers with a map of field to message, and putting each one where
|
||||||
it belongs is the whole point of that shape. */
|
it belongs is the whole point of that shape. */
|
||||||
|
|||||||
@@ -276,7 +276,7 @@
|
|||||||
*/
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="profil">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.profile')}</h2>
|
<h2>{t('account.profile')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="udseende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.appearance')}</h2>
|
<h2>{t('account.appearance')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||||
they are separate settings because they are separate questions. -->
|
they are separate settings because they are separate questions. -->
|
||||||
<div class="looks">
|
<div class="looks">
|
||||||
<h3>{t('account.look')}</h3>
|
<h3 id="look">{t('account.look')}</h3>
|
||||||
<p class="hint">{t('account.lookHint')}</p>
|
<p class="hint">{t('account.lookHint')}</p>
|
||||||
<div class="look-row">
|
<div class="look-row">
|
||||||
{#each LOOKS as option (option.id)}
|
{#each LOOKS as option (option.id)}
|
||||||
@@ -379,7 +379,10 @@
|
|||||||
<!-- Each card is written in the face it selects, because that is the
|
<!-- Each card is written in the face it selects, because that is the
|
||||||
only part of the choice a name cannot carry. -->
|
only part of the choice a name cannot carry. -->
|
||||||
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
<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>
|
<span class="theme-note">{t(option.note)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -390,7 +393,7 @@
|
|||||||
Separate on purpose — one is a glance down a list of names, the other is a
|
Separate on purpose — one is a glance down a list of names, the other is a
|
||||||
paragraph you sit and read. -->
|
paragraph you sit and read. -->
|
||||||
<div class="sizes">
|
<div class="sizes">
|
||||||
<h3>{t('account.sizes')}</h3>
|
<h3 id="stoerrelser">{t('account.sizes')}</h3>
|
||||||
<p class="hint">{t('account.sizesHint')}</p>
|
<p class="hint">{t('account.sizesHint')}</p>
|
||||||
<div class="size-row">
|
<div class="size-row">
|
||||||
<div class="size-field">
|
<div class="size-field">
|
||||||
@@ -421,7 +424,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="links">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('link.title')}</h2>
|
<h2>{t('link.title')}</h2>
|
||||||
<p class="hint">{t('link.hint')}</p>
|
<p class="hint">{t('link.hint')}</p>
|
||||||
@@ -464,7 +467,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="adgangskode">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.password')}</h2>
|
<h2>{t('account.password')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -506,7 +509,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="to-faktor">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.totp')}</h2>
|
<h2>{t('account.totp')}</h2>
|
||||||
<p class="hint">{t('account.totpHint')}</p>
|
<p class="hint">{t('account.totpHint')}</p>
|
||||||
@@ -616,7 +619,7 @@
|
|||||||
|
|
||||||
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||||
is a way in, and it is a thing on a device you might later want to revoke. -->
|
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||||
<section class="panel">
|
<section class="panel" id="passkey">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('passkey.title')}</h2>
|
<h2>{t('passkey.title')}</h2>
|
||||||
<p class="hint">{t('passkey.hint')}</p>
|
<p class="hint">{t('passkey.hint')}</p>
|
||||||
@@ -655,7 +658,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="enheder">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.devices')}</h2>
|
<h2>{t('account.devices')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -687,7 +690,7 @@
|
|||||||
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
||||||
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||||
snippet for why not their generated button. -->
|
snippet for why not their generated button. -->
|
||||||
<section class="panel">
|
<section class="panel" id="stoette">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.support')}</h2>
|
<h2>{t('account.support')}</h2>
|
||||||
<p class="hint">{t('account.supportHint')}</p>
|
<p class="hint">{t('account.supportHint')}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="ai">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.title')}</h2>
|
<h2>{t('ai.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if settings?.provider && settings?.has_key}
|
{#if settings?.provider && settings?.has_key}
|
||||||
<section class="panel">
|
<section class="panel" id="ugentlig">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.weekly')}</h2>
|
<h2>{t('ai.weekly')}</h2>
|
||||||
<p class="hint">{t('ai.weeklyHint')}</p>
|
<p class="hint">{t('ai.weeklyHint')}</p>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
const expires = shortDate;
|
const expires = shortDate;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="invitation">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.invite')}</h2>
|
<h2>{t('users.invite')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if invites.length}
|
{#if invites.length}
|
||||||
<section class="panel">
|
<section class="panel" id="afventende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.pending')}</h2>
|
<h2>{t('users.pending')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="konti">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.accounts')}</h2>
|
<h2>{t('users.accounts')}</h2>
|
||||||
<p class="hint">{t('users.accountsHint')}</p>
|
<p class="hint">{t('users.accountsHint')}</p>
|
||||||
|
|||||||
@@ -141,6 +141,13 @@
|
|||||||
let startDate = $state('');
|
let startDate = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$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
|
api
|
||||||
.listTemplates()
|
.listTemplates()
|
||||||
.then((r) => (templates = r.templates))
|
.then((r) => (templates = r.templates))
|
||||||
@@ -234,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="import">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.import')}</h2>
|
<h2>{t('data.import')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -279,7 +286,7 @@
|
|||||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||||||
to the four that are read every day, and it belongs with the other "where did
|
to the four that are read every day, and it belongs with the other "where did
|
||||||
that go" questions rather than above them. -->
|
that go" questions rather than above them. -->
|
||||||
<section class="panel">
|
<section class="panel" id="faerdige">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('done.title')}</h2>
|
<h2>{t('done.title')}</h2>
|
||||||
<p class="hint">{t('data.doneHint')}</p>
|
<p class="hint">{t('data.doneHint')}</p>
|
||||||
@@ -289,7 +296,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="papirkurv">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.trash')}</h2>
|
<h2>{t('data.trash')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -322,7 +329,7 @@
|
|||||||
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
||||||
is a complete copy of the database, so this panel hands out everybody's data. -->
|
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<section class="panel">
|
<section class="panel" id="sikkerhedskopier">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.backups')}</h2>
|
<h2>{t('data.backups')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -380,7 +387,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="eksport">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.export')}</h2>
|
<h2>{t('data.export')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -393,6 +400,8 @@
|
|||||||
do with a Content-Disposition, and the session cookie rides along. -->
|
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.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||||||
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</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>
|
</div>
|
||||||
|
|
||||||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
<!-- 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>
|
<p class="hint">{t('data.importNotesHint')}</p>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="export-project">{t('data.exportOne')}</label>
|
<label for="export-project">{t('data.exportOne')}</label>
|
||||||
<select id="export-project" bind:value={exportProject}>
|
<select id="export-project" bind:value={exportProject}>
|
||||||
@@ -428,7 +439,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="skabeloner">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.templates')}</h2>
|
<h2>{t('data.templates')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -512,7 +523,7 @@
|
|||||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||||||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||||
<section class="panel">
|
<section class="panel" id="beacon">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('beacon.title')}</h2>
|
<h2>{t('beacon.title')}</h2>
|
||||||
<p class="hint">{t('beacon.hint')}</p>
|
<p class="hint">{t('beacon.hint')}</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="fejl">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('errors.title')}</h2>
|
<h2>{t('errors.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="historik">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('history.title')}</h2>
|
<h2>{t('history.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -296,7 +296,11 @@
|
|||||||
let mailConfigured = $state(null);
|
let mailConfigured = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$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
|
api
|
||||||
.getMailAddress()
|
.getMailAddress()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -416,7 +420,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="gmail">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.gmail')}</h2>
|
<h2>{t('int.gmail')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -460,7 +464,7 @@
|
|||||||
it has to match Google's copy exactly, and the error Google gives
|
it has to match Google's copy exactly, and the error Google gives
|
||||||
when it does not names neither value. Google afviser desuden
|
when it does not names neither value. Google afviser desuden
|
||||||
private IP-adresser over http — det skal være https. -->
|
private IP-adresser over http — det skal være https. -->
|
||||||
<input id="redirect" readonly value={client.redirect_uri} />
|
<output id="redirect">{client.redirect_uri}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={saveClient}>
|
<form onsubmit={saveClient}>
|
||||||
@@ -520,7 +524,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalender">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.calendar')}</h2>
|
<h2>{t('int.calendar')}</h2>
|
||||||
<p class="hint">{t('int.calendarHint')}</p>
|
<p class="hint">{t('int.calendarHint')}</p>
|
||||||
@@ -546,7 +550,7 @@
|
|||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
|
||||||
<p class="hint">{t('int.calendarSameClient')}</p>
|
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !calendar.has_client}
|
{:else if !calendar.has_client}
|
||||||
@@ -605,7 +609,7 @@
|
|||||||
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||||
{#if calendar !== null}
|
{#if calendar !== null}
|
||||||
<div class="subs">
|
<div class="subs">
|
||||||
<h3>{t('int.subscriptions')}</h3>
|
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
|
||||||
<p class="hint">{t('int.subscriptionsHint')}</p>
|
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||||
|
|
||||||
{#if calendar.subscriptions?.length}
|
{#if calendar.subscriptions?.length}
|
||||||
@@ -643,7 +647,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalenderfeed">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.feed')}</h2>
|
<h2>{t('int.feed')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -653,7 +657,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="feed">{t('int.address')}</label>
|
<label for="feed">{t('int.address')}</label>
|
||||||
<input id="feed" class="mono" value={feedURL} readonly />
|
<output id="feed" class="mono">{feedURL}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -665,7 +669,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="mail-til-opgave">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailToTask')}</h2>
|
<h2>{t('int.mailToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -681,7 +685,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="mail">{t('int.yourAddress')}</label>
|
<label for="mail">{t('int.yourAddress')}</label>
|
||||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
<output id="mail" class="mono">{mailAddress}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -690,7 +694,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="krog">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.hookToTask')}</h2>
|
<h2>{t('int.hookToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -700,7 +704,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hook">{t('int.yourHook')}</label>
|
<label for="hook">{t('int.yourHook')}</label>
|
||||||
<input id="hook" class="mono" value={hookURL} readonly />
|
<output id="hook" class="mono">{hookURL}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.hookHow')}
|
{t('int.hookHow')}
|
||||||
</p>
|
</p>
|
||||||
@@ -711,8 +715,8 @@
|
|||||||
kroppen bare er den linje, opgaven skal hedde. -->
|
kroppen bare er den linje, opgaven skal hedde. -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hookcurl">{t('int.hookExample')}</label>
|
<label for="hookcurl">{t('int.hookExample')}</label>
|
||||||
<textarea id="hookcurl" class="mono" rows="3" readonly
|
<output id="hookcurl" class="mono"
|
||||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
|
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -722,7 +726,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="caldav-server">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.caldav')}</h2>
|
<h2>{t('int.caldav')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -732,7 +736,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="caldav">{t('int.server')}</label>
|
<label for="caldav">{t('int.server')}</label>
|
||||||
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
|
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.caldavAuth')}
|
{t('int.caldavAuth')}
|
||||||
</p>
|
</p>
|
||||||
@@ -743,7 +747,7 @@
|
|||||||
rather than a second mode inside the Gmail one, because they are not the same
|
rather than a second mode inside the Gmail one, because they are not the same
|
||||||
thing wearing different clothes — one is an app you sign in through, the other
|
thing wearing different clothes — one is an app you sign in through, the other
|
||||||
is a password you hold. -->
|
is a password you hold. -->
|
||||||
<section class="panel">
|
<section class="panel" id="postkasser">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailboxes')}</h2>
|
<h2>{t('int.mailboxes')}</h2>
|
||||||
<p class="hint">{t('int.mailboxesHint')}</p>
|
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||||
@@ -817,7 +821,7 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup input[readonly] {
|
.setup output {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -884,4 +888,38 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The bullet list this was is why the page scrolled sideways on a phone. A
|
||||||
|
subscription address is one word with nothing to break at, so it cannot wrap
|
||||||
|
on its own — and the widest element on a page sets the width of the page, not
|
||||||
|
of its own line. Hence `anywhere`, which breaks mid-word when there is no
|
||||||
|
other opportunity, plus `min-width: 0` so the flex item is allowed to be
|
||||||
|
narrower than its content in the first place. */
|
||||||
|
.subs-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list .url {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list button {
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -92,6 +92,8 @@
|
|||||||
let planBusy = $state(false);
|
let planBusy = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Dagens plan er dagens opgaver.
|
||||||
|
if (app.notesOnly) return;
|
||||||
api
|
api
|
||||||
.planSettings()
|
.planSettings()
|
||||||
.then((p) => (plan = p))
|
.then((p) => (plan = p))
|
||||||
@@ -213,7 +215,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="dagens-plan">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.plan')}</h2>
|
<h2>{t('ai.plan')}</h2>
|
||||||
<p class="hint">{t('ai.planHint')}</p>
|
<p class="hint">{t('ai.planHint')}</p>
|
||||||
@@ -252,7 +254,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="push">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.title')}</h2>
|
<h2>{t('push.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -296,7 +298,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="seneste">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.recent')}</h2>
|
<h2>{t('push.recent')}</h2>
|
||||||
{#if unread > 0}
|
{#if unread > 0}
|
||||||
@@ -335,7 +337,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if version}
|
{#if version}
|
||||||
<section class="panel">
|
<section class="panel" id="version">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.version')}</h2>
|
<h2>{t('push.version')}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
const expired = (iso) => iso && new Date(iso) < new Date();
|
const expired = (iso) => iso && new Date(iso) < new Date();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="tokens">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('tokens.title')}</h2>
|
<h2>{t('tokens.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -12,8 +12,12 @@
|
|||||||
* and it reads the token out of the URL itself.
|
* and it reads the token out of the URL itself.
|
||||||
*/
|
*/
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Invitation · verdande</title></svelte:head>
|
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
|
||||||
|
|
||||||
<SignIn />
|
<SignIn />
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -483,23 +484,95 @@
|
|||||||
load(query);
|
load(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
/**
|
||||||
// opens it rather than dropping the person on a list to find it again.
|
* En søgning, man kommer med.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
*
|
||||||
|
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
|
||||||
|
* træffere frem for den almindelige liste.
|
||||||
|
*
|
||||||
|
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
|
||||||
|
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
|
||||||
|
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
|
||||||
|
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
|
||||||
|
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
|
||||||
|
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
|
||||||
|
*
|
||||||
|
* En markør, der skal holde to retninger enige, er en markør, der taber
|
||||||
|
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
|
||||||
|
* den eneste, der læser den.
|
||||||
|
*/
|
||||||
|
let cameWith = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!asked || selectedId === asked) return;
|
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||||
const found = notes.find((n) => n.id === asked);
|
if (inURL === untrack(() => cameWith)) return;
|
||||||
if (found) open(found);
|
cameWith = inURL;
|
||||||
else api.note(asked).then(open).catch(() => {});
|
draftQuery = inURL;
|
||||||
|
query = inURL;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
|
||||||
|
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
|
||||||
|
* den igen.
|
||||||
|
*
|
||||||
|
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
|
||||||
|
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
|
||||||
|
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
|
||||||
|
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
|
||||||
|
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
|
||||||
|
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
|
||||||
|
* sprang tilbage til den første.
|
||||||
|
*
|
||||||
|
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
|
||||||
|
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
|
||||||
|
* ved hver ændring.
|
||||||
|
*/
|
||||||
|
function openFromURL(url) {
|
||||||
|
const id = url?.searchParams.get('note');
|
||||||
|
if (!id || id === selectedId) return;
|
||||||
|
const found = notes.find((n) => n.id === id);
|
||||||
|
if (found) open(found);
|
||||||
|
else api.note(id).then(open).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
|
||||||
|
// den anden.
|
||||||
|
//
|
||||||
|
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
|
||||||
|
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
|
||||||
|
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
|
||||||
|
// eller fulgt udefra åbnede så ingenting.
|
||||||
|
//
|
||||||
|
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
|
||||||
|
// programmet, monteres siden ikke om.
|
||||||
|
onMount(() => openFromURL($page.url));
|
||||||
|
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken hentning der gælder.
|
||||||
|
*
|
||||||
|
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
|
||||||
|
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
|
||||||
|
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
|
||||||
|
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
|
||||||
|
* ikke gøre det umuligt.
|
||||||
|
*/
|
||||||
|
let newest = 0;
|
||||||
|
|
||||||
async function load(q) {
|
async function load(q) {
|
||||||
|
const mine = ++newest;
|
||||||
try {
|
try {
|
||||||
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||||
notes = (await api.notes(params)).notes ?? [];
|
const got = (await api.notes(params)).notes ?? [];
|
||||||
|
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
|
||||||
|
// det er bare ikke længere svaret på det, der blev spurgt om.
|
||||||
|
if (mine !== newest) return;
|
||||||
|
notes = got;
|
||||||
status = 'ready';
|
status = 'ready';
|
||||||
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (mine !== newest) return;
|
||||||
status = 'failed';
|
status = 'failed';
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
@@ -516,7 +589,17 @@
|
|||||||
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
||||||
* at vente; teksten kommer, når den er der.
|
* at vente; teksten kommer, når den er der.
|
||||||
*/
|
*/
|
||||||
async function open(note) {
|
/**
|
||||||
|
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
|
||||||
|
*
|
||||||
|
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
|
||||||
|
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
|
||||||
|
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
|
||||||
|
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
|
||||||
|
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
|
||||||
|
* skrevet der forsvandt sporløst.
|
||||||
|
*/
|
||||||
|
async function open(note, { whole = false } = {}) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!note) {
|
if (!note) {
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
@@ -532,9 +615,9 @@
|
|||||||
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
||||||
// sidde med en tom note, der lige havde haft en titel.
|
// sidde med en tom note, der lige havde haft en titel.
|
||||||
selectedId = note.id;
|
selectedId = note.id;
|
||||||
if (note.body) {
|
if (whole || note.body) {
|
||||||
selected = note;
|
selected = note;
|
||||||
draft = note.body;
|
draft = note.body ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selected = null;
|
selected = null;
|
||||||
@@ -646,7 +729,8 @@
|
|||||||
try {
|
try {
|
||||||
const note = await api.createNote({ body: '' });
|
const note = await api.createNote({ body: '' });
|
||||||
notes = [note, ...notes];
|
notes = [note, ...notes];
|
||||||
open(note);
|
// Svaret er hele noten, så den skal ikke hentes igen — se open().
|
||||||
|
await open(note, { whole: true });
|
||||||
document.querySelector('.editor textarea')?.focus();
|
document.querySelector('.editor textarea')?.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
@@ -849,7 +933,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
onclick={onShareOutside}
|
onclick={onShareOutside}
|
||||||
@@ -1279,7 +1363,7 @@
|
|||||||
lovet i en besked, der ikke kom af sted. -->
|
lovet i en besked, der ikke kom af sted. -->
|
||||||
<p class="invitelink">
|
<p class="invitelink">
|
||||||
{t('notes.inviteNoMail')}
|
{t('notes.inviteNoMail')}
|
||||||
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
<output>{shareInviteLink}</output>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -2216,11 +2300,23 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invitelink input {
|
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
|
||||||
|
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
|
||||||
|
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
|
||||||
|
god til — ét tryk tager hele linket. */
|
||||||
|
.invitelink output {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: var(--text-xs);
|
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addshare {
|
.addshare {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteLink = $state('');
|
let inviteLink = $state('');
|
||||||
let inviteError = $state('');
|
let inviteError = $state('');
|
||||||
|
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
|
||||||
|
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
|
||||||
|
// medlemmerne, så panelet åbner færdigt i ét kald.
|
||||||
|
let candidates = $state([]);
|
||||||
|
let pending = $state([]);
|
||||||
|
let sharePick = $state('');
|
||||||
|
let pickRole = $state('editor');
|
||||||
|
|
||||||
let id = $derived($page.params.id);
|
let id = $derived($page.params.id);
|
||||||
|
|
||||||
@@ -81,9 +88,14 @@
|
|||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
try {
|
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([
|
const [p, s] = await Promise.all([
|
||||||
api.getProject(projectId),
|
api.getProject(projectId),
|
||||||
api.listSections(projectId),
|
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
|
||||||
app.loadTasks({ project_id: projectId })
|
app.loadTasks({ project_id: projectId })
|
||||||
]);
|
]);
|
||||||
project = p;
|
project = p;
|
||||||
@@ -101,7 +113,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showShare && project) {
|
if (showShare && project) {
|
||||||
api.listMembers(project.id).then((r) => (members = r.members));
|
api.listMembers(project.id).then(readMembers);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,23 +524,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invite(event) {
|
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
|
||||||
event.preventDefault();
|
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
|
||||||
|
function readMembers(r) {
|
||||||
|
members = r.members;
|
||||||
|
candidates = r.candidates ?? [];
|
||||||
|
pending = r.pending ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share(who, role) {
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
inviteLink = '';
|
inviteLink = '';
|
||||||
try {
|
try {
|
||||||
const result = await api.invite(project.id, inviteEmail, inviteRole);
|
const result = await api.invite(project.id, who, role);
|
||||||
inviteEmail = '';
|
|
||||||
if (result.link && !result.emailed) {
|
if (result.link && !result.emailed) {
|
||||||
// No mail server, or delivery failed. The link is the only way this
|
// No mail server, or delivery failed. The link is the only way this
|
||||||
// invite reaches anybody, so it is shown rather than swallowed.
|
// invite reaches anybody, so it is shown rather than swallowed.
|
||||||
inviteLink = result.link;
|
inviteLink = result.link;
|
||||||
}
|
}
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
inviteError = humanMessage(e);
|
inviteError = humanMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invite(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = inviteEmail.trim();
|
||||||
|
if (!email) return;
|
||||||
|
await share({ email }, inviteRole);
|
||||||
|
if (!inviteError) inviteEmail = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
|
||||||
|
// være en vej ud, der ikke går gennem at bede ejeren om det.
|
||||||
|
async function leave() {
|
||||||
|
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
|
||||||
|
try {
|
||||||
|
await api.removeMember(project.id, app.user.id);
|
||||||
|
// Væk fra en side, man ikke længere må se: bliver man stående, er det
|
||||||
|
// næste, siden gør, at hente den og få 404.
|
||||||
|
await goto('/');
|
||||||
|
await app.refreshProjects();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw(invite) {
|
||||||
|
try {
|
||||||
|
await api.withdrawInvite(project.id, invite.id);
|
||||||
|
readMembers(await api.listMembers(project.id));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||||
@@ -765,7 +815,7 @@
|
|||||||
class="remove"
|
class="remove"
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
await api.removeMember(project.id, member.user_id);
|
await api.removeMember(project.id, member.user_id);
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
}}
|
}}
|
||||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||||
>
|
>
|
||||||
@@ -774,7 +824,66 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
|
||||||
|
sendt i går usynlig: personen har ingen konto, så de er hverken
|
||||||
|
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
|
||||||
|
{#if pending.length}
|
||||||
|
<ul class="members invited">
|
||||||
|
{#each pending as invite (invite.id)}
|
||||||
|
<li>
|
||||||
|
<span class="avatar waiting">@</span>
|
||||||
|
<span class="member-name">{invite.email}</span>
|
||||||
|
<span class="role">{t('project.invited')}</span>
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
class="remove"
|
||||||
|
onclick={() => withdraw(invite)}
|
||||||
|
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
|
||||||
|
projekt — det ville være en overdragelse, ikke en udgang. -->
|
||||||
|
{#if !isOwner}
|
||||||
|
<button class="leave" onclick={leave}>{t('project.leave')}</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
|
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
||||||
|
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||||
|
en kollega, man kender navnet på — og konti her bliver kun til ved
|
||||||
|
invitation, så listen er en adressebog og ikke en udlevering. -->
|
||||||
|
{#if candidates.length}
|
||||||
|
<div class="addshare">
|
||||||
|
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
|
||||||
|
<option value="">{t('project.pickPerson')}</option>
|
||||||
|
{#each candidates as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
|
||||||
|
<option value="editor">{t('project.canEdit')}</option>
|
||||||
|
<option value="viewer">{t('project.canView')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!sharePick}
|
||||||
|
onclick={async () => {
|
||||||
|
const id = sharePick;
|
||||||
|
sharePick = '';
|
||||||
|
await share({ user_id: id }, pickRole);
|
||||||
|
}}>{t('project.shareWith')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
|
||||||
|
adressen en konto, bliver det en deling med det samme, og ellers en
|
||||||
|
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
|
||||||
|
<p class="byemail-hint">{t('project.orByEmail')}</p>
|
||||||
<form onsubmit={invite}>
|
<form onsubmit={invite}>
|
||||||
<input
|
<input
|
||||||
bind:value={inviteEmail}
|
bind:value={inviteEmail}
|
||||||
@@ -1396,6 +1505,45 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
|
||||||
|
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
|
||||||
|
.invited {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
|
||||||
|
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
|
||||||
|
.avatar.waiting {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addshare {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
|
||||||
|
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
|
||||||
|
.leave {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byemail-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
* fill in. Same shape as the invite link, and found the same way.
|
* fill in. Same shape as the invite link, and found the same way.
|
||||||
*/
|
*/
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
|
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
|
||||||
|
|
||||||
<SignIn />
|
<SignIn />
|
||||||
|
|||||||
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: {
|
alias: {
|
||||||
$lib: 'src/lib'
|
$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