Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661e47ebdf | ||
|
|
6178cbbdd8 | ||
|
|
43bd405e36 | ||
|
|
b73f55ad0b | ||
|
|
475ecbe7ef | ||
|
|
159029675c | ||
|
|
b18d9a26e6 | ||
|
|
3768cf946e | ||
|
|
60795126a5 | ||
|
|
664614b924 | ||
|
|
807aa83e58 | ||
|
|
9199709aa5 | ||
|
|
1d15cb18c0 | ||
|
|
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 | ||
|
|
5eb689e4c2 | ||
|
|
e931ddc313 | ||
|
|
2210dc082a | ||
|
|
9fa47822c1 | ||
|
|
366bed3a15 | ||
|
|
07fc170b91 | ||
|
|
8139003b35 | ||
|
|
96e1fa251b | ||
|
|
40102d6038 | ||
|
|
0cf5c8c281 | ||
|
|
445d9de57a | ||
|
|
d5ad459eba | ||
|
|
c297d256e4 | ||
|
|
d063425cae | ||
|
|
0764fbd566 | ||
|
|
b041b32aa2 | ||
|
|
d8f5f6e236 | ||
|
|
61634a6bb5 | ||
|
|
8ee9fbedfb | ||
|
|
ab77024ec9 | ||
|
|
48509e3073 | ||
|
|
916a0c0224 | ||
|
|
ff75302cf8 | ||
|
|
08d703e3af | ||
|
|
d4784fd174 | ||
|
|
094ea5cdd8 | ||
|
|
f60758b770 | ||
|
|
9d390d021f | ||
|
|
18db79c063 | ||
|
|
1612f53966 | ||
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
84ca506961 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
1dc9751a33 | ||
|
|
b81f8f67cc | ||
|
|
f62ffc8d92 | ||
|
|
73ee93deab | ||
|
|
b2fcc0b104 |
@@ -97,7 +97,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -271,3 +277,17 @@ jobs:
|
||||
set -eu
|
||||
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
||||
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
|
||||
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
|
||||
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
|
||||
|
||||
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
|
||||
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
|
||||
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
|
||||
# Det kræver `--load` to gange, og det er et halvt minut per image på en
|
||||
# runner, der deles med Yggdrasils produktionscontainere og allerede har
|
||||
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
|
||||
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
|
||||
# autoritativ for de offentlige projekter.
|
||||
#
|
||||
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
|
||||
|
||||
@@ -40,7 +40,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -117,9 +123,59 @@ jobs:
|
||||
# Build only — publishing happens on a tag, in release.yml. This job exists
|
||||
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||
# than at release time.
|
||||
#
|
||||
# Loaded into the local daemon rather than discarded, because the step below
|
||||
# has to inspect what came out.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: verdande:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: urd:ci
|
||||
build-args: EDITION=notes
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# The claim this repository makes about urd is that it is the same program,
|
||||
# not a fork — and that claim is checkable rather than a sentence in a README.
|
||||
#
|
||||
# Stages 1 and 2 are identical for both builds, and the edition is set with
|
||||
# ENV, which is image *config* rather than a layer. So the two images must
|
||||
# have the same layer digests and differ only in that one variable. A change
|
||||
# that made them diverge — a second COPY, a conditional RUN, a separate build
|
||||
# of the binary — would quietly turn "one source" into two, and the only
|
||||
# symptom would be a bug fixed in one product and not the other.
|
||||
#
|
||||
# This was impossible until the frontend build became deterministic: SvelteKit
|
||||
# put Date.now() in version.json, so no two builds ever matched. See the note
|
||||
# in web/svelte.config.js.
|
||||
- name: The two images are one build
|
||||
run: |
|
||||
set -eu
|
||||
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
|
||||
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
|
||||
echo "The verdande and urd images do not share their layers:"
|
||||
layers verdande:ci
|
||||
layers urd:ci
|
||||
echo "They are built from one source, so this is either a Dockerfile that"
|
||||
echo "now builds the binary twice, or a stage that is no longer shared."
|
||||
exit 1
|
||||
fi
|
||||
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
|
||||
|
||||
# And the half that stops the check above from passing over two identical
|
||||
# images: they must actually differ in the one way they are supposed to.
|
||||
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
|
||||
echo "verdande: $(edition verdande:ci)"
|
||||
echo "urd: $(edition urd:ci)"
|
||||
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
|
||||
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
name: Documentation
|
||||
|
||||
# The docs are built on every push so a broken build is caught immediately, but
|
||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
||||
# owner decides to publish, this job builds and stops.
|
||||
# Built on every push, and published from main.
|
||||
#
|
||||
# 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:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -10,6 +23,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -44,9 +62,9 @@ jobs:
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Only runs once Pages is enabled. Until then the build above still guards
|
||||
# against a broken docs tree.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||
# publish over the site by opening one.
|
||||
if: github.event_name != 'pull_request'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
|
||||
@@ -17,6 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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-buildx-action@v3
|
||||
@@ -60,7 +65,59 @@ jobs:
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# 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
|
||||
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
|
||||
make_latest: true
|
||||
|
||||
@@ -44,6 +44,11 @@ node_modules.nosync/
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||
# af det samme.
|
||||
/web/.playwright-data-notes/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
|
||||
# Passed into the frontend build, because SvelteKit's default build version is
|
||||
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
|
||||
# the service worker, changed the content hash of every chunk referencing them, and
|
||||
# so changed those chunks' filenames. Every build of one commit produced different
|
||||
# bytes. See the long note in web/svelte.config.js.
|
||||
ARG VERSION=dev
|
||||
ENV VERDANDE_VERSION=${VERSION}
|
||||
|
||||
# The frontend is built when it is present. During backend-only development it is
|
||||
# not, and the build must still produce a working image rather than failing on a
|
||||
# missing directory — so this stage falls back to a placeholder page. The Go stage
|
||||
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
#
|
||||
# "Reproducible" was false for a year and nobody read the sentence that said it:
|
||||
# the flags above do their job, and the frontend embedded below carried a
|
||||
# millisecond timestamp, so every build of one commit produced a different binary.
|
||||
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
|
||||
# match rather than trusting this comment.
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-o /verdande ./cmd/verdande
|
||||
|
||||
# --- 3. what actually ships --------------------------------------------------
|
||||
#
|
||||
# Two images come out of this stage, and the only difference between them is one
|
||||
# environment variable:
|
||||
#
|
||||
# docker build . → verdande
|
||||
# docker build --build-arg EDITION=notes → urd
|
||||
#
|
||||
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
|
||||
# the same build rather than a rebuild that happens to match. That is the point:
|
||||
# urd is not a fork, and "built from the same source" is a property of the layers
|
||||
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
|
||||
# the two images share every layer digest and differ only in that config — which CI
|
||||
# asserts on every pull request.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
|
||||
# so one above the FROM above would be invisible down here. `full` by default,
|
||||
# because that is what this program has always been and an unqualified build must
|
||||
# not quietly produce the other product.
|
||||
ARG EDITION=full
|
||||
|
||||
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
|
||||
# so the container needs a tz database and CA certificates for outbound SMTP.
|
||||
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
|
||||
# nightly backups.
|
||||
VOLUME ["/data"]
|
||||
ENV VERDANDE_DATA_DIR=/data \
|
||||
VERDANDE_ADDR=:8080
|
||||
VERDANDE_ADDR=:8080 \
|
||||
VERDANDE_EDITION=${EDITION}
|
||||
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
|
||||
@@ -15,12 +15,30 @@
|
||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
||||
Which is what a to-do list is.
|
||||
|
||||
## Two editions: verdande and urd
|
||||
|
||||
This repository builds one program with two faces. Start it with
|
||||
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
|
||||
routes are not mounted, the sidebar has no way to them, and the program calls
|
||||
itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
|
||||
wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||
five of nine MCP tools. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
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
|
||||
|
||||
@@ -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
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **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;
|
||||
`#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
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project, by
|
||||
handing it to one person — including somebody who has no account yet, who gets an
|
||||
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.
|
||||
- **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
|
||||
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:
|
||||
everybody arrives through an invite link, either to a project or — from
|
||||
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
||||
by somebody else.
|
||||
everybody arrives through an invite link — to a project, to a single note, or
|
||||
from **Settings → Brugere** to the instance itself. Nobody's password is ever
|
||||
chosen by somebody else.
|
||||
- **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.
|
||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
||||
@@ -103,7 +140,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| CalDAV server (two-way VTODO) | Done, tested |
|
||||
| Mail-to-task | Done, tested |
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||
@@ -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 |
|
||||
| End-to-end smoke tests (Playwright) | 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
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
|
||||
@@ -26,6 +26,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
// Not the edition's name: this fires before the configuration is read, so
|
||||
// the edition is not known yet — and one of the things it reports is a
|
||||
// VERDANDE_EDITION the program refused.
|
||||
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +40,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := httpapi.NewLogger(cfg.Dev)
|
||||
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
||||
// The edition's own name, so an urd instance's log says urd. The full edition
|
||||
// still logs "starting verdande", which is what the release check reads.
|
||||
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
||||
|
||||
// The data volume is the only state that matters; create the whole layout up
|
||||
// front so a failure here is a startup error rather than a surprise at the
|
||||
@@ -104,6 +109,8 @@ func run() error {
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.DailyPlans = api.DailyPlans
|
||||
runner.NotifyOfUpdates = api.NotifyOfUpdates
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
|
||||
seven concrete actions and creates them as sub-tasks. Written in your language.
|
||||
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
|
||||
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
|
||||
what appears to be slipping. At most six lines — it is a prompt to think, not a
|
||||
report.
|
||||
what appears to be slipping. At most six lines — a prompt to think, not a report.
|
||||
|
||||
## What is sent
|
||||
|
||||
Only what the feature needs: for a split, the task's title and notes; for a
|
||||
summary, the titles, dates and priorities of your open tasks. Never comments,
|
||||
attachments, or anything from a project the feature was not invoked on.
|
||||
Only what the feature needs, and only when you invoke it:
|
||||
|
||||
Nothing is sent to anybody until you configure a provider. With a local model,
|
||||
nothing leaves your machine at all.
|
||||
| Feature | What leaves the instance |
|
||||
| --- | --- |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
|
||||
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
-H "Authorization: Bearer vrd_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
|
||||
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
|
||||
```
|
||||
|
||||
### Projects and sections
|
||||
@@ -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` `POST` | `/projects/{id}/sections` | |
|
||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||
| `GET` | `/projects/{id}/members` | |
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `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. `{"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}/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` | `/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. |
|
||||
@@ -108,7 +109,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `GET` | `/today` | Overdue and due today, in your timezone. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
|
||||
| `GET` | `/search?q=` | Across everything you can see. |
|
||||
| `GET` `POST` | `/filters` | |
|
||||
| `GET` | `/filters/{id}/tasks` | Run one. |
|
||||
|
||||
@@ -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_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
@@ -74,7 +75,7 @@ Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Mailboxes | Gmail API | `gmail.modify` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
@@ -127,11 +128,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
|
||||
|
||||
| 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_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. |
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Two editions: verdande and urd
|
||||
|
||||
One program, built once, with two faces.
|
||||
|
||||
| | **verdande** | **urd** |
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 137 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
mounted, the sidebar has no way to them, search answers with notes only, and the
|
||||
program calls itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
## Running the notes edition
|
||||
|
||||
There is one image. The edition is an environment variable on the container:
|
||||
|
||||
```
|
||||
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
|
||||
ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
No second image and no rune of its own, deliberately.
|
||||
|
||||
The edition is for an operator who wants an instance that genuinely has no task
|
||||
routes — the routes are absent from the router, not hidden in the interface. It is
|
||||
**not** how urd is normally reached. That is the second door, below.
|
||||
|
||||
Two details that surprise people who look inside:
|
||||
|
||||
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
|
||||
both editions. Not an oversight: it is the same binary, and renaming the file per
|
||||
edition would be a migration hazard in exchange for nothing a user sees.
|
||||
- **The icons for both editions ship in the one image.** The server decides which
|
||||
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
|
||||
and never reads the manifest, so renaming paths would have left iOS with the
|
||||
wrong mark.
|
||||
|
||||
## The second door: two apps, one instance
|
||||
|
||||
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
|
||||
database, one login, one set of links between notes and tasks — and two things you
|
||||
can install.
|
||||
|
||||
| | verdande | urd |
|
||||
|---|---|---|
|
||||
| entry | `/` | `/urd` |
|
||||
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
|
||||
| manifest `id` | `/` | `/urd` |
|
||||
| tab and mark | verdande, ᚹ | urd, ð |
|
||||
| database, account, login | the same | the same |
|
||||
|
||||
An operating system does not install a URL, it installs a **manifest**, and the
|
||||
identity of an installed app is that manifest's `id`. Two manifests on one origin
|
||||
with different ids are two apps; two URLs sharing one manifest are one app with a
|
||||
bookmark. That is the whole mechanism.
|
||||
|
||||
The shell is served per door rather than patched by JavaScript after the page
|
||||
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
|
||||
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
|
||||
the manifest's icons altogether.
|
||||
|
||||
urd's manifest is built from verdande's rather than written beside it, so the
|
||||
fields the two genuinely share — the colours, the display mode, the language —
|
||||
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
|
||||
`scope`, the two names and the icons. An icon in the built manifest that urd has no
|
||||
file for stops the server rather than being passed through, because the symptom
|
||||
would otherwise be urd's install prompt quietly showing Verdande's mark.
|
||||
|
||||
### The face is a preference, not a gate
|
||||
|
||||
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
|
||||
tab. It is kept strictly apart from the notes edition in the code, and the
|
||||
distinction is the one thing worth reading twice:
|
||||
|
||||
| | what it means | what follows |
|
||||
|---|---|---|
|
||||
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
|
||||
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
|
||||
|
||||
Conflate them and the urd face starts hiding data rather than chrome: a notebook
|
||||
would stop showing its tasks because somebody chose a different entrance. A test
|
||||
asserts the opposite directly — the urd window still gets 200 from
|
||||
`/api/v1/tasks`.
|
||||
|
||||
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
|
||||
two apps be two apps: they share an origin, so they share localStorage, cookies and
|
||||
the service worker. `sessionStorage` is per window, and an installed PWA is its own
|
||||
window. It survives a reload, which it must — after the first navigation the
|
||||
address is `/noter` and nobody passes through `/urd` again.
|
||||
|
||||
!!! note "One thing the two doors cannot have separately"
|
||||
Theme, typeface and text size are shared, because they are in the localStorage
|
||||
the origin shares. That was a deliberate choice: splitting them per face would
|
||||
mean migrating everybody's stored settings for a difference few people want.
|
||||
|
||||
## Moving between the two, measured
|
||||
|
||||
A `/data` directory moves between the editions in **both directions, losslessly**.
|
||||
Same binary, same schema, same migrations — so this is not a feature that was built,
|
||||
it is a property that was checked:
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
|
||||
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
|
||||
| the same directory, back to `full` | the task is still there; so is the note |
|
||||
|
||||
The ownership stayed at the panel's service account through all three.
|
||||
|
||||
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
|
||||
getting notes **into other programs** — it writes one `.md` per note and carries the
|
||||
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
|
||||
is the right division of labour, because the database already holds those.
|
||||
|
||||
!!! note "The task rows stay"
|
||||
An instance run as `notes` still has its tasks in the database; they are simply
|
||||
not served. Harmless on a fresh instance, dead weight on a converted one — and
|
||||
worth knowing before handing a converted instance to somebody else.
|
||||
|
||||
## Why one codebase and not a fork
|
||||
|
||||
The two share everything that is hard: accounts, sessions, passkeys, sharing,
|
||||
invitations, the realtime socket, search, the backup format, the migrations. Notes
|
||||
are about a twentieth of the Go in this repository and reference no task type;
|
||||
tasks reference no note type. A fork would have duplicated the other nineteen
|
||||
twentieths in order to separate the one — and then every security fix would have
|
||||
needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 63 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
## An unknown value refuses to start
|
||||
|
||||
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
|
||||
rather than being treated as "not notes".
|
||||
|
||||
The silent direction is the dangerous one here. An operator who sets up an instance
|
||||
for notes and mistypes the value would otherwise get every task route served on it,
|
||||
with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 63 withheld routes:
|
||||
|
||||
| | what |
|
||||
|---|---|
|
||||
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||
| 2 | the Todoist and CSV imports, which create tasks |
|
||||
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||
|
||||
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||
answer, and tells the person there is nothing to do.
|
||||
|
||||
Everything else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||
- AI settings, `ask`, and the note actions
|
||||
|
||||
!!! warning "Still being decided"
|
||||
Four connectors are reachable in urd today and have not been settled either
|
||||
way, because each has a plausible notes reading and withholding them would be
|
||||
deciding what this product is rather than closing a hole: the mail-in address,
|
||||
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||
anything. The same applies to one handler rather than a route:
|
||||
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||
|
||||
## The name
|
||||
|
||||
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
|
||||
cutting marks into wood — the act of writing something down. Verdande is what is
|
||||
becoming; Urd is what has become and was kept.
|
||||
|
||||
An instance started without the task routes is a different product to the person
|
||||
using it, so it says a different name. That name is read from the configuration in
|
||||
one place (`Config.ProductName`), which is why there is no build-time flag and no
|
||||
second frontend: the frontend is byte-identical in both editions, and the server
|
||||
renames the two files a browser reads before any JavaScript runs — the HTML shell's
|
||||
title and the web manifest.
|
||||
|
||||
!!! note "The beacon counts them separately"
|
||||
Both editions report the same two values once a day — instance id and version
|
||||
— and nothing else; see [The install count](beacon.md). The edition is not in
|
||||
the payload, so the count does not distinguish them.
|
||||
@@ -18,29 +18,57 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
|
||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
|
||||
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
|
||||
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
|
||||
line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
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
|
||||
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
|
||||
single note with a person, or file it in a project; `#project` and
|
||||
`[[another note]]` are links, and a task shows what has been written about it.
|
||||
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||
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
|
||||
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
|
||||
it is due.
|
||||
- **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.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||
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.
|
||||
- **[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
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
- **[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
|
||||
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
|
||||
of only filling up.
|
||||
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||
rather than private formats.
|
||||
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||
signal and goes through when it comes back; a reload opens from what it last saw
|
||||
rather than the browser's offline page. It installs as a PWA.
|
||||
|
||||
## What it looks like
|
||||
|
||||
@@ -60,6 +88,16 @@ columns.*
|
||||
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||
meet it.*
|
||||
|
||||

|
||||
|
||||
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
|
||||
amber where the accent green usually is.*
|
||||
|
||||

|
||||
|
||||
*Six themes — three dark, three light — chosen on the settings page, with the
|
||||
typeface and how tightly it sits set apart from them.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
|
||||
@@ -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"
|
||||
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.
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
|
||||
| `complete_task` | Tick one off. |
|
||||
| `add_comment` | Comment on a task. |
|
||||
|
||||
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
|
||||
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
|
||||
labels and recurrence in either language.
|
||||
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||
projects, labels and recurrence in either language.
|
||||
|
||||
## What it can reach
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
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.
|
||||
@@ -53,7 +53,7 @@ paths:
|
||||
/api/v1/auth/setup:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance still needs its first account.
|
||||
summary: What this instance is, and whether it still needs its first account.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -64,6 +64,16 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
needs_setup: { type: boolean }
|
||||
edition:
|
||||
type: string
|
||||
enum: [full, notes]
|
||||
description: >
|
||||
`full` is tasks, projects and notes together. `notes` is
|
||||
the notes half: the task routes are not mounted at all, so
|
||||
an instance that is not a task manager cannot be asked to
|
||||
be one through its API. The interface reads this before
|
||||
anybody is signed in, because it decides what there is to
|
||||
show rather than what this person may see.
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Create the first account, which becomes the administrator.
|
||||
@@ -832,9 +842,9 @@ paths:
|
||||
tags: [tasks]
|
||||
summary: Create a task from one line of natural language.
|
||||
description: |
|
||||
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
||||
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
||||
vigtigt. An unknown `#project` is not an error — the task goes to the
|
||||
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
|
||||
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
|
||||
labelled tax. An unknown `#project` is not an error — the task goes to the
|
||||
Inbox rather than a thought being thrown away over a typo.
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -848,6 +858,20 @@ paths:
|
||||
project_id:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
section_id:
|
||||
type: string
|
||||
description: >
|
||||
The section the box was opened in. A "/section" in the text
|
||||
wins, because typing it is a choice and standing there is a
|
||||
circumstance.
|
||||
parent_id:
|
||||
type: string
|
||||
description: >
|
||||
Makes it a sub-task. A sub-task lives in its parent's
|
||||
project, so a "#project" on the same line is not honoured —
|
||||
it comes back as `unknown_project` rather than being
|
||||
silently dropped. Dates, labels and priority are read the
|
||||
same way as for any other line.
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
@@ -1250,7 +1274,7 @@ paths:
|
||||
|
||||
The one click that opens Google's consent screen already exists. What it
|
||||
needs behind it is a registered client, and no app can conjure one: Google
|
||||
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
||||
issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||
restricted scope, so an id shipped inside a public image would be both
|
||||
extractable from it and unusable without Google's security review of every
|
||||
deployment that used it.
|
||||
@@ -1564,7 +1588,15 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
installs:
|
||||
type: [integer, "null"]
|
||||
description: |
|
||||
Null below the collector's floor (`publish_min`, 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.
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
@@ -1590,7 +1622,19 @@ paths:
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min:
|
||||
type: integer
|
||||
description: The floor under the published count.
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
last_error:
|
||||
type: string
|
||||
description: |
|
||||
Why the last attempt did not arrive; absent when the last
|
||||
one did. Shown rather than only logged: a beacon that
|
||||
cannot reach its collector otherwise fails in complete
|
||||
silence, and the only trace is a "last sent" date that
|
||||
quietly gets older.
|
||||
last_error_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
@@ -1610,12 +1654,74 @@ paths:
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min: { type: integer, minimum: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/beacon/notice:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether the disclosure is still owed, and what it says. Administrators only.
|
||||
description: |
|
||||
The beacon is on by default, and that is only defensible if it is said
|
||||
unprompted. A settings page where it is stated fully is not the same as
|
||||
saying it: that page is opened by people who already suspect there is
|
||||
something to read.
|
||||
|
||||
The three values are in the response — the real id, the real version, the
|
||||
real collector address — so the notice can show what is sent rather than
|
||||
say "anonymous data" and ask to be believed.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: boolean
|
||||
description: False once anybody has answered, panel-wide.
|
||||
enabled: { type: boolean }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
collector_url: { type: string }
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Answer the notice. Administrators only.
|
||||
description: |
|
||||
One call for both answers — "keep it on" and "turn it off" are the same
|
||||
act, somebody deciding, and two endpoints would mean one of them could
|
||||
forget to record that a decision was made.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [keep]
|
||||
properties:
|
||||
keep:
|
||||
type: boolean
|
||||
description: |
|
||||
Required, and rejected when absent. An empty body must not be
|
||||
able to mean "turn it off": an opt-out nobody chose is as much
|
||||
a broken promise as an opt-in nobody chose.
|
||||
responses:
|
||||
"200":
|
||||
description: Recorded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
"422":
|
||||
description: No answer was given.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
@@ -2225,7 +2331,15 @@ paths:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
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:
|
||||
"200":
|
||||
description: OK
|
||||
@@ -2237,6 +2351,17 @@ paths:
|
||||
members:
|
||||
type: array
|
||||
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" }
|
||||
|
||||
/api/v1/projects/{projectID}/members/{userID}:
|
||||
@@ -2278,8 +2403,19 @@ paths:
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Remove somebody from a project. Its owner only.
|
||||
summary: Remove somebody from a project — its owner, or yourself.
|
||||
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
|
||||
no longer see the project — which is why changing a role is a PATCH and
|
||||
not a remove-and-reinvite.
|
||||
@@ -2302,13 +2438,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, role]
|
||||
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] }
|
||||
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":
|
||||
description: Created
|
||||
description: Invited. No account yet, so a link was made.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -2317,8 +2472,30 @@ paths:
|
||||
link: { type: string }
|
||||
emailed: { type: boolean }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"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:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -2365,7 +2542,12 @@ paths:
|
||||
tags: [views]
|
||||
summary: The next few days, empty ones included.
|
||||
parameters:
|
||||
- { name: days, in: query, schema: { type: integer, default: 7 } }
|
||||
- {
|
||||
name: days,
|
||||
in: query,
|
||||
description: "How many days to return, counting today. Above the maximum is capped, not refused.",
|
||||
schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
|
||||
}
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
@@ -2713,6 +2895,25 @@ paths:
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/MailAddress" }
|
||||
|
||||
/api/v1/hook-url:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Your personal address for pushing a task in from somewhere else.
|
||||
description: >
|
||||
Minted on first ask. Its own token rather than the mail one: the two are
|
||||
the same kind of secret, but they leak separately — 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.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/HookURL" }
|
||||
|
||||
/api/v1/hook-url/rotate:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Issue a new push address, retiring the old one at once.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/HookURL" }
|
||||
|
||||
/api/v1/notifications:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -2966,6 +3167,40 @@ paths:
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/projects.zip:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every project's tasks as a CSV, in a zip.
|
||||
description: >
|
||||
One CSV per project, in the column layout Todoist writes — so the files
|
||||
import into Todoist and into anything else that reads that shape. A zip
|
||||
rather than one wide file because that is the shape on the other side,
|
||||
and because a single project can be lifted out of it without opening a
|
||||
spreadsheet. The per-project link does the same for one project; this is
|
||||
the whole account in one download.
|
||||
responses:
|
||||
"200":
|
||||
description: A zip archive.
|
||||
content:
|
||||
application/zip:
|
||||
schema: { type: string, format: binary }
|
||||
|
||||
/api/v1/export/tasks.ics:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every dated task as a calendar of VTODOs.
|
||||
description: >
|
||||
One file, not a zip: a calendar is made to hold many things, and each
|
||||
task carries its project with it, so nothing is lost by putting them
|
||||
together. Tasks without a due date are left out — a VTODO with no date
|
||||
is a line most readers drop on the floor.
|
||||
responses:
|
||||
"200":
|
||||
description: An iCalendar file.
|
||||
content:
|
||||
text/calendar:
|
||||
schema: { type: string }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3012,6 +3247,12 @@ paths:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Write a note.
|
||||
description: >
|
||||
`#Projekt` files the note against a project and `[[Another note]]` links to
|
||||
one. A third, `@Name`, shares the note with that person as a viewer and
|
||||
tells them — only the owner's mentions share, and only a name that has just
|
||||
appeared in the text counts, so a note saved on every typing pause does not
|
||||
share itself twice.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3075,6 +3316,11 @@ paths:
|
||||
patch:
|
||||
tags: [notes]
|
||||
summary: Change a note. Every field is optional; what is sent is what changes.
|
||||
description: >
|
||||
A name written with an `@` that was not in the text before shares the note
|
||||
with that person as a viewer and tells them. Only the owner's mentions
|
||||
share — an editor may write the name, but giving somebody else's note away
|
||||
is the owner's alone — and a role already granted is never lowered by one.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3121,25 +3367,103 @@ paths:
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
invites:
|
||||
description: >
|
||||
Addresses invited to the note that have not been taken up yet.
|
||||
There is no account behind them, so there is no name or colour
|
||||
to show — the address is all there is until they arrive.
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteInvite" }
|
||||
follows:
|
||||
description: >
|
||||
The notes this one points at with [[links]], which are shared
|
||||
alongside it. Owner's own notes only.
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Share a note with a person, or change the role they hold.
|
||||
description: >
|
||||
The person is named either by `user_id` — somebody picked from the
|
||||
candidates — or by `email`. An address that already has an account is
|
||||
shared with straight away; one that does not gets an invitation, which
|
||||
becomes the share the moment the account is created (201).
|
||||
|
||||
The notes this one points at with [[links]] are shared with the same person
|
||||
and the same role, so a link in a shared note leads somewhere. Only notes
|
||||
the sharer owns follow, and the response lists them.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
description: One of user_id or email.
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"204": { description: Shared }
|
||||
"200":
|
||||
description: Shared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
description: >
|
||||
Who it was shared with. Sent back because a caller that shared
|
||||
by address does not otherwise know who it reached.
|
||||
$ref: "#/components/schemas/Person"
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
follows:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||
"201":
|
||||
description: >
|
||||
Invited. The address has no account yet, so a link that creates one and
|
||||
the share together has been issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
invited: { $ref: "#/components/schemas/NoteInvite" }
|
||||
link:
|
||||
description: The signup link, shown so it can be passed on.
|
||||
type: string
|
||||
emailed:
|
||||
description: >
|
||||
False when the instance has no mail configured, which is when
|
||||
the link is the only way the invitation reaches anybody.
|
||||
type: boolean
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/notes/{noteID}/invites/{inviteID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Withdraw an invitation to a note before it is used.
|
||||
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: noteID
|
||||
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/notes/{noteID}/shares/{userID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
@@ -3337,6 +3661,247 @@ paths:
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/inbox/tidy:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Suggest how the loose tasks in the inbox could have been written.
|
||||
description: |
|
||||
Suggests, never writes. Each suggestion is the line the person would have
|
||||
typed — quick-add syntax, the same one parser as everywhere else — so it
|
||||
can be read and edited by whoever it is put to, rather than being a set of
|
||||
fields a model filled in that you take or leave.
|
||||
|
||||
The model is given the projects that exist and may name no other: a
|
||||
`#Project` it invented would create one by accident the first time
|
||||
somebody said yes.
|
||||
responses:
|
||||
"200":
|
||||
description: The suggestions, one per task, and none for a task already filed well.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
suggestions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/inbox/tidy/apply:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Write one accepted suggestion into the task it is about.
|
||||
description: |
|
||||
The line is parsed here rather than in the browser, so "tomorrow" means
|
||||
what it means everywhere else — and a suggestion edited by hand before it
|
||||
was accepted is read exactly like one that was not.
|
||||
|
||||
Only what the line actually says is applied. A missing date means "the
|
||||
line said nothing about a date", never "clear the one that is there". A
|
||||
project the model invented moves nothing.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id, line]
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
line: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: The task as it now stands.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/notes/{noteID}/actions:
|
||||
parameters:
|
||||
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Pull what somebody committed to out of a note.
|
||||
description: |
|
||||
Only things somebody committed to or was asked to do — a heading is not a
|
||||
task, a topic discussed is not a task. An empty array is a useful answer
|
||||
and a made-up list is not.
|
||||
|
||||
Each suggestion quotes the words it came from, so a wrong one can be seen
|
||||
through rather than merely being wrong.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
suggestions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/notes/{noteID}/actions/apply:
|
||||
parameters:
|
||||
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Create one accepted task from a note.
|
||||
description: |
|
||||
The note is not touched. Writing the link back into it would make the two
|
||||
point at each other, and a text a program edits without being asked is a
|
||||
text people stop trusting — so the task carries where it came from, as a
|
||||
`[[note title]]` in its description, and the note is left alone.
|
||||
|
||||
The note's own project is the default; a `#Project` in the line wins,
|
||||
because typing it is a choice and inheriting is a circumstance.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [line]
|
||||
properties:
|
||||
line: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/plan:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Whether a morning plan is sent, and at what hour.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Turn the morning plan on or off, or move the hour.
|
||||
description: |
|
||||
The hour is local to the person. Moving it clears the "already sent today"
|
||||
mark, so somebody who moves the plan from 07 to 16 gets it this afternoon
|
||||
rather than tomorrow — a setting whose effect you have to wait a day to
|
||||
see is a setting nobody dares turn on.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
hour: { type: integer, minimum: 0, maximum: 23 }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/plan/now:
|
||||
post:
|
||||
tags: [integrations]
|
||||
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: |
|
||||
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
|
||||
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. A morning with nothing to
|
||||
do is a good morning, and a notification saying "there is nothing" teaches
|
||||
people to stop opening notifications.
|
||||
responses:
|
||||
"200":
|
||||
description: The plan, and whether it was sent.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
sent: { type: boolean }
|
||||
|
||||
/api/v1/ai/ask:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Ask a question of your own notes and tasks.
|
||||
description: |
|
||||
Search finds what contains the words. This answers the question — "what
|
||||
did I promise Anders in August?" — which is not the same: the answer is
|
||||
spread over three notes and a task, and none of them contains the word
|
||||
"promise".
|
||||
|
||||
Two halves, and the order is the whole of the safety in it. The database
|
||||
finds the candidates with the search that already exists and that already
|
||||
only sees what this person may see. The model is given those and nothing
|
||||
else — it does not look anything up and cannot ask for more.
|
||||
|
||||
The answer carries what it rests on. A model that says "you did, on the
|
||||
14th" is only useful if you can open the note it read that in.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [question]
|
||||
properties:
|
||||
question: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: |
|
||||
The answer, and the passages it rests on — only those, not everything
|
||||
that was looked at. An empty answer means nothing matched.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
answer: { type: string }
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
kind: { type: string, enum: [note, task] }
|
||||
id: { type: string }
|
||||
title: { type: string }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -3658,6 +4223,69 @@ paths:
|
||||
task_id: { type: string }
|
||||
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
||||
|
||||
/inbound/hook/{token}:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Push a task into somebody's inbox from another program.
|
||||
description: |
|
||||
The token in the path is the whole credential — the caller is a shortcut on
|
||||
a phone or a script, and neither can sign in. A token here can do one
|
||||
thing: create a task in that person's inbox. An API token can do everything,
|
||||
which is why this is not one.
|
||||
|
||||
The body is read three ways, because those are the three you meet: JSON from
|
||||
a service, a form field from a webhook form, and plain text from a shortcut
|
||||
that just sends what was selected. The first line becomes the task and the
|
||||
rest sits underneath it, and the line goes through the quick-add parser — so
|
||||
"Ring til Anders i morgen p1 #Firma" means here what it means in the box at
|
||||
the top of the app.
|
||||
|
||||
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.
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
description: First line the task, the rest its description.
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
text: { type: string }
|
||||
title: { description: Same as text. }
|
||||
content: { description: Same as text. }
|
||||
note: { type: string }
|
||||
body: { description: Same as note. }
|
||||
description: { description: Same as note. }
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
text: { type: string }
|
||||
note: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
content: { type: string }
|
||||
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { description: Too many pushes; the address is rate limited. }
|
||||
|
||||
/oauth/gmail/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -3924,6 +4552,15 @@ components:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
HookURL:
|
||||
description: The address another program pushes to.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
@@ -4030,6 +4667,74 @@ components:
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
linked:
|
||||
description: >
|
||||
True when the person has this note because another shared note points
|
||||
at it, rather than because it was shared with them on its own. Such a
|
||||
grant is taken back by removing the link or the share it came from, not
|
||||
by removing it here.
|
||||
type: boolean
|
||||
via:
|
||||
description: The title of the shared note that points at this one.
|
||||
type: string
|
||||
|
||||
NoteFollow:
|
||||
type: object
|
||||
description: A note that is shared alongside another because that one links to it.
|
||||
properties:
|
||||
note_id: { type: string }
|
||||
title: { type: string }
|
||||
|
||||
DailyPlanSettings:
|
||||
type: object
|
||||
description: >
|
||||
One person's morning plan, and today's plan itself. One resource, because
|
||||
it is one thing: the plan is the resource and the setting is when it gets
|
||||
made.
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
hour:
|
||||
type: integer
|
||||
description: Local to the person, because seven o'clock is seven different places.
|
||||
last_sent:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
description: >
|
||||
One proposed line, in quick-add syntax, plus one sentence on why it looks
|
||||
like that. Nothing has happened yet — a suggestion becomes something when
|
||||
somebody says yes to it.
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
description: >
|
||||
The task the suggestion is about, when it is about one that already
|
||||
exists. Absent when the suggestion is a new task.
|
||||
line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
|
||||
why: { type: string }
|
||||
|
||||
NoteInvite:
|
||||
type: object
|
||||
description: >
|
||||
A share on a note that is waiting for an account. The address is the whole
|
||||
of who the person is until they sign up.
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
|
||||
Mailbox:
|
||||
type: object
|
||||
@@ -4519,12 +5224,22 @@ components:
|
||||
|
||||
Notification:
|
||||
type: object
|
||||
description: >
|
||||
Something somebody else did that you need to know about. `kind` says what
|
||||
happened and the client composes the sentence from it, so the bell follows
|
||||
the reader's language; `title` is the server's own Danish wording, used for
|
||||
Web Push, which has no dictionary to look words up in.
|
||||
properties:
|
||||
id: { type: string }
|
||||
actor_name: { type: string }
|
||||
project_id: { type: string }
|
||||
task_id: { type: string }
|
||||
kind: { type: string }
|
||||
note_id:
|
||||
description: Set on a notification about a note — a shared note somebody else edited.
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [assigned, note.changed, comment]
|
||||
title: { type: string }
|
||||
body: { type: string }
|
||||
read: { type: boolean }
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
One line in, a fully-formed task out.
|
||||
|
||||
```
|
||||
betal moms i morgen kl 10 p1 #Firma @regnskab
|
||||
file the VAT return tomorrow at 10am p1 #Accounts @tax
|
||||
```
|
||||
|
||||
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
|
||||
Firma project, labelled regnskab.
|
||||
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
|
||||
in the Accounts project, labelled tax.
|
||||
|
||||
The parts it understands are tinted underneath the text as you type, so you can see
|
||||
it being read before you commit to it.
|
||||
@@ -16,8 +16,8 @@ it being read before you commit to it.
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
|
||||
| `@regnskab` | Add that label. As many as you like. |
|
||||
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
|
||||
| `@tax` | Add that label. As many as you like. |
|
||||
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
||||
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
||||
| `mandag`, `på fredag` | The next such weekday. |
|
||||
|
||||
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 220 KiB |
@@ -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
|
||||
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
|
||||
|
||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||
|
||||
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -14,9 +16,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -395,22 +395,266 @@ back, do not congratulate, and do not offer productivity advice.`, language)
|
||||
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
}
|
||||
|
||||
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
|
||||
// tastet.
|
||||
//
|
||||
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
|
||||
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
|
||||
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
|
||||
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
|
||||
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
|
||||
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
|
||||
//
|
||||
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
|
||||
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
|
||||
type Suggestion struct {
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
|
||||
//
|
||||
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
|
||||
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
|
||||
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You tidy up a task inbox.
|
||||
|
||||
For each task you are given, write the line the person would have typed if they
|
||||
had filed it properly. Keep their own words for what the task is — you are filing
|
||||
it, not rewriting it.
|
||||
|
||||
The line may carry, in this syntax and no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, such as "i morgen", "on friday", "15/3"
|
||||
|
||||
Leave out anything the task does not tell you. A guessed deadline is worse than
|
||||
none: the person can add one, but they cannot see a wrong one you invented.
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
|
||||
saying what you read out of the task. Give one element per task, in the order you
|
||||
were given them, and leave a task out entirely if it is already filed well.`,
|
||||
known, language, today, language)
|
||||
|
||||
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
|
||||
return c.suggestions(ctx, system, prompt)
|
||||
}
|
||||
|
||||
// ActionsInNote trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
|
||||
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
|
||||
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
|
||||
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
|
||||
|
||||
Only things somebody committed to or was asked to do. A heading is not a task, a
|
||||
topic that was discussed is not a task, and an idea nobody agreed to is not a
|
||||
task. If the note contains none, reply with an empty array — that is a useful
|
||||
answer and a made-up list is not.
|
||||
|
||||
Write each one as the line the person would have typed, in %s, in this syntax and
|
||||
no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, only when the note actually gives one
|
||||
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why quotes the few words in the note that
|
||||
the task came from, so it can be checked.`, language, known, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Note:\n"+note)
|
||||
}
|
||||
|
||||
// DailyPlan skriver morgenens to-tre linjer.
|
||||
//
|
||||
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
|
||||
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
|
||||
// og en model, der tæller en liste, tæller den forkert af og til.
|
||||
//
|
||||
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
|
||||
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
|
||||
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You write somebody's morning note about their own day.
|
||||
|
||||
Write in %s. At most three short lines, no greeting, no sign-off, no
|
||||
encouragement. Say what to start with and why. Name at most one thing that could
|
||||
wait or be dropped, and only if something obviously can.
|
||||
|
||||
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
|
||||
recount them, do not contradict them, and do not list every task back.`,
|
||||
language, due, overdue)
|
||||
|
||||
return c.Complete(ctx, system, []Message{
|
||||
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
|
||||
})
|
||||
}
|
||||
|
||||
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
|
||||
//
|
||||
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
|
||||
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
|
||||
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
|
||||
// ikke lede efter kantede parenteser i en tekst.
|
||||
//
|
||||
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
|
||||
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
|
||||
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
|
||||
// var opfundet.
|
||||
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
|
||||
|
||||
Use only the passages given. If they do not answer the question, say so plainly —
|
||||
that is a useful answer, and a guess is not: the person is asking because they
|
||||
cannot remember, so they cannot tell an invented answer from a real one.
|
||||
|
||||
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
|
||||
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
|
||||
passages the answer actually rests on, and nothing else.`, language)
|
||||
|
||||
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
|
||||
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Answer string `json:"answer"`
|
||||
Sources []int `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
|
||||
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
|
||||
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
|
||||
// dag end en fejlmeddelelse ville være.
|
||||
return strings.TrimSpace(reply), nil, nil
|
||||
}
|
||||
return strings.TrimSpace(out.Answer), out.Sources, nil
|
||||
}
|
||||
|
||||
// TasksFromMail skriver én opgavelinje pr. mail.
|
||||
//
|
||||
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
|
||||
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
|
||||
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
|
||||
// lader være, hvis den ikke beder om nogen.
|
||||
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
|
||||
|
||||
For each numbered email, write the line the recipient would have typed for
|
||||
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
|
||||
name in it when it matters who it is about. Not a summary of the email: the
|
||||
action it asks for.
|
||||
|
||||
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
|
||||
email itself says so — "before friday" in the text is a date, an email that merely
|
||||
sounds urgent is not. Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "N. ..."} keeping the number the email was given, so the line can be
|
||||
put back on the right one. Leave an email out entirely if it asks for nothing.`,
|
||||
language, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
|
||||
}
|
||||
|
||||
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
|
||||
// der ikke er en linje.
|
||||
//
|
||||
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
|
||||
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
|
||||
// stykker, når den i virkeligheden var enig med dem.
|
||||
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Suggestion
|
||||
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
|
||||
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
|
||||
}
|
||||
|
||||
clean := make([]Suggestion, 0, len(out))
|
||||
for _, s := range out {
|
||||
s.Line = strings.TrimSpace(s.Line)
|
||||
s.Why = strings.TrimSpace(s.Why)
|
||||
if s.Line != "" {
|
||||
clean = append(clean, s)
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
|
||||
// den slags, der bliver rettet ét sted ud af fire.
|
||||
func languageOf(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "English"
|
||||
}
|
||||
return "Danish"
|
||||
}
|
||||
|
||||
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
|
||||
// omhyggeligt de bliver bedt om at lade være.
|
||||
func unfence(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
fence := strings.Index(reply, "```")
|
||||
if fence < 0 {
|
||||
return reply
|
||||
}
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
|
||||
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
|
||||
// bliver bedt om at lade være.
|
||||
func extractObject(reply string) string {
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '{')
|
||||
end := strings.LastIndexByte(reply, '}')
|
||||
if start >= 0 && end > start {
|
||||
return reply[start : end+1]
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractJSON pulls an array out of a reply that may have been wrapped in a code
|
||||
// fence or preceded by a sentence, which models do however firmly they are asked
|
||||
// not to.
|
||||
func extractJSON(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
|
||||
if fence := strings.Index(reply, "```"); fence >= 0 {
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
reply = strings.TrimSpace(rest[:end])
|
||||
}
|
||||
}
|
||||
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '[')
|
||||
end := strings.LastIndexByte(reply, ']')
|
||||
if start >= 0 && end > start {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
@@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||
// needs whatever theme the page is in.
|
||||
//
|
||||
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||
// this borrows a dependency rather than adding one.
|
||||
func TOTPQRSVG(uri string) (string, error) {
|
||||
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||
}
|
||||
b := code.Bounds()
|
||||
n := b.Dx()
|
||||
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||
const quiet = 4
|
||||
size := n + quiet*2
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
// Dark modules are black; the red channel is 0 for those and full for the
|
||||
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString(`"/></svg>`)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||
// code again before enrolment is confirmed.
|
||||
func TOTPURI(secret, issuer, account string) string {
|
||||
|
||||
@@ -38,6 +38,28 @@ type Config struct {
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// Edition decides which half of the program this instance serves.
|
||||
//
|
||||
// "full" is tasks, projects and notes together — what Verdande has always
|
||||
// been. "notes" mounts the notes half and the base underneath it, and leaves
|
||||
// the task routes unmounted: not hidden in the interface, absent from the
|
||||
// router, so an instance that is not a task manager cannot be asked to be one
|
||||
// through its API.
|
||||
//
|
||||
// Notes already stand nearly alone — they reference no task type anywhere in
|
||||
// the store, and the one link between the halves is a `note_links` row whose
|
||||
// kind happens to be "task". What is NOT separable is everything underneath
|
||||
// both: accounts, sessions, invites, sharing, notifications, attachments,
|
||||
// search. Hence one program with two faces rather than two programs: the
|
||||
// alternative is two copies of the base, and two copies of a thing is how the
|
||||
// copies start to disagree.
|
||||
//
|
||||
// Projects stay mounted in both. In the notes edition a project is a
|
||||
// notebook — the same column, the same sharing, a different word — because a
|
||||
// second grouping concept beside `note_shares` would be a second thing to keep
|
||||
// in step for a difference nobody can see.
|
||||
Edition string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
|
||||
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
|
||||
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
|
||||
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
|
||||
// som ikke burde.
|
||||
switch c.Edition {
|
||||
case EditionFull, EditionNotes:
|
||||
case "":
|
||||
c.Edition = EditionFull
|
||||
default:
|
||||
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
// The two faces of the program. Strings rather than a bool, because a bool named
|
||||
// `NotesOnly` answers one question and this one will be asked again — and a
|
||||
// third edition would mean changing every reading of it rather than adding a case.
|
||||
const (
|
||||
EditionFull = "full"
|
||||
EditionNotes = "notes"
|
||||
)
|
||||
|
||||
// NotesOnly is the one question the router asks. Written as a method so the
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
|
||||
// ProductName and ProductSlug are the two forms of the name the running instance
|
||||
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
|
||||
// manifest.
|
||||
//
|
||||
// Two editions of one program, two names — which is the whole reason the name is
|
||||
// read from the configuration rather than written into the templates. Verdande is
|
||||
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
|
||||
// and kept. An instance started without the task routes is a different product to
|
||||
// whoever uses it, and a product that calls itself by its sibling's name is not a
|
||||
// product, it is a flag somebody set.
|
||||
//
|
||||
// The lowercase form is deliberate and not a styling accident: the wordmark, the
|
||||
// repository and the window title are all lowercase, so the slug is the name and
|
||||
// ProductName is the one that gets a capital because it stands in prose.
|
||||
func (c *Config) ProductName() string {
|
||||
if c.NotesOnly() {
|
||||
return "Urd"
|
||||
}
|
||||
return "Verdande"
|
||||
}
|
||||
|
||||
// ProductSlug is ProductName lowercased, kept as its own method rather than as
|
||||
// strings.ToLower at the call sites: the two are the same transformation today and
|
||||
// a name that does not lowercase cleanly would otherwise be a bug in every caller
|
||||
// at once.
|
||||
func (c *Config) ProductSlug() string {
|
||||
if c.NotesOnly() {
|
||||
return "urd"
|
||||
}
|
||||
return "verdande"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
|
||||
// edition. That is the silent direction of this fault: an instance set up to be a
|
||||
// notes program would serve every task route, and the only way to notice would be
|
||||
// to spot that something works which should not.
|
||||
func TestUnknownEditionIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string // the edition we expect, or "" when Load must fail
|
||||
}{
|
||||
{"", EditionFull}, // unset is the full program, as it has always been
|
||||
{EditionFull, EditionFull},
|
||||
{EditionNotes, EditionNotes},
|
||||
{"note", ""}, // a letter short
|
||||
{"Notes", ""}, // the right word, the wrong case
|
||||
{"none", ""}, // plausible and meaning nothing
|
||||
} {
|
||||
t.Setenv("VERDANDE_EDITION", tc.value)
|
||||
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
|
||||
|
||||
got, err := Load()
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
|
||||
continue
|
||||
}
|
||||
if got.Edition != tc.want {
|
||||
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
|
||||
}
|
||||
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
|
||||
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
// message does not delete the task, completing the task does not unstar the
|
||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
||||
// The one thing verdande writes back is the star. A starred message becomes a
|
||||
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||
// else is touched. Completing the task does not reach back to the mail; the mail
|
||||
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
||||
//
|
||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
||||
// can do it — so the consent screen asks for more than it used to, and the trade
|
||||
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
||||
// than one that fills with stars nothing ever clears. Still not send, still not
|
||||
// delete — modify is labels, and the only label verdande removes is the one that
|
||||
// triggered the task.
|
||||
//
|
||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||
// The names below are aliases so that every call site — and this package's own
|
||||
@@ -27,8 +33,10 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||
// send, not delete. The single write is removing the STARRED label from a message
|
||||
// that has become a task; see the package comment for why that costs modify.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
||||
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// Unstar takes the star off the given messages — one call for all of them, the way
|
||||
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
||||
// not ten.
|
||||
//
|
||||
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
||||
// label would need one, and verdande removes none. batchModify answers 204 with no
|
||||
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
||||
// an error on Google's side and nothing worth making a round trip to hear.
|
||||
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
body := struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
||||
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
||||
}
|
||||
|
||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
||||
// falling back to the address when there is no display name.
|
||||
func SenderName(from string) string {
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||
// one the person reads on the consent screen. The flow itself is exercised in
|
||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.modify") {
|
||||
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
||||
}
|
||||
// Modify is the ceiling. Send, or the full account, would be a different promise
|
||||
// to the person on the consent screen than the one this feature needs.
|
||||
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
||||
if strings.Contains(Scope, wider) {
|
||||
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unstar removes the star from a batch in one call, and it addresses the messages
|
||||
// it was given and only the STARRED label.
|
||||
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody struct {
|
||||
IDs []string `json:"ids"`
|
||||
AddLabelIDs []string `json:"addLabelIds"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
||||
if err != nil {
|
||||
t.Fatalf("Unstar: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
||||
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
||||
}
|
||||
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
||||
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
||||
}
|
||||
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
||||
}
|
||||
if len(gotBody.AddLabelIDs) != 0 {
|
||||
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
||||
// there is nothing to say to it anyway.
|
||||
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Unstar(nil): %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("Unstar(nil) made a request; it should make none")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
||||
// body coming back. A write call — modifying labels on a message — answers with
|
||||
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
||||
// decoded, and only when the caller asked for one.
|
||||
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog.
|
||||
//
|
||||
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
|
||||
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
|
||||
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
|
||||
//
|
||||
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
|
||||
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
|
||||
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
|
||||
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
|
||||
// nogen andens noter.
|
||||
//
|
||||
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
|
||||
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
|
||||
|
||||
type askRequest struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
type askSourceJSON struct {
|
||||
Kind string `json:"kind"` // "note" eller "task"
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
|
||||
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
||||
var req askRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
question := strings.TrimSpace(req.Question)
|
||||
if question == "" {
|
||||
writeFieldErrors(w, map[string]string{"question": "required"})
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Ord for ord, ikke hele spørgsmålet på én gang.
|
||||
//
|
||||
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
|
||||
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
|
||||
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
|
||||
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
|
||||
// eneste gang.
|
||||
//
|
||||
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
|
||||
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
|
||||
// mellem spørgsmålet og de noter, der faktisk findes.
|
||||
notes, tasks, err := s.candidatesFor(r, user.ID, question)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search for the question", err)
|
||||
return
|
||||
}
|
||||
if len(notes) == 0 && len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"answer": "", "sources": []askSourceJSON{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
|
||||
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
|
||||
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
|
||||
passages := make([]string, 0, len(notes)+len(tasks))
|
||||
for _, n := range notes {
|
||||
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
|
||||
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
|
||||
firstWords(n.Body, 300))
|
||||
}
|
||||
for _, t := range tasks {
|
||||
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
|
||||
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
|
||||
if t.DueDate != "" {
|
||||
line += " (" + t.DueDate + ")"
|
||||
}
|
||||
if t.Description != "" {
|
||||
line += "\n" + firstWords(t.Description, 60)
|
||||
}
|
||||
passages = append(passages, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai ask", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
|
||||
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
|
||||
// svaret står på.
|
||||
cited := make([]askSourceJSON, 0, len(used))
|
||||
for _, n := range used {
|
||||
if n >= 1 && n <= len(sources) {
|
||||
cited = append(cited, sources[n-1])
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
|
||||
}
|
||||
|
||||
func itoaSource(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
||||
}
|
||||
|
||||
// candidatesFor henter det, spørgsmålet kunne handle om.
|
||||
//
|
||||
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
|
||||
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
|
||||
// note, der indeholder begge de bærende, er den, det handler om.
|
||||
//
|
||||
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
|
||||
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
|
||||
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
|
||||
terms := store.SearchTerms(question)
|
||||
|
||||
type scored struct {
|
||||
note store.Note
|
||||
task store.Task
|
||||
hits int
|
||||
}
|
||||
noteHits := map[string]*scored{}
|
||||
taskHits := map[string]*scored{}
|
||||
|
||||
for _, term := range terms {
|
||||
if len([]rune(term)) < 3 {
|
||||
continue
|
||||
}
|
||||
found, err := s.db.SearchNotes(r.Context(), term, 10)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range found {
|
||||
if at, ok := noteHits[n.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
noteHits[n.ID] = &scored{note: n, hits: 1}
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
|
||||
Search: term, Limit: 10, IncludeCompleted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if at, ok := taskHits[t.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
taskHits[t.ID] = &scored{task: t, hits: 1}
|
||||
}
|
||||
}
|
||||
|
||||
notes := make([]store.Note, 0, len(noteHits))
|
||||
for _, at := range noteHits {
|
||||
notes = append(notes, at.note)
|
||||
}
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
|
||||
})
|
||||
|
||||
tasks := make([]store.Task, 0, len(taskHits))
|
||||
for _, at := range taskHits {
|
||||
tasks = append(tasks, at.task)
|
||||
}
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
|
||||
})
|
||||
|
||||
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
|
||||
// værd — og hvad et menneske ville have gidet læse igennem selv.
|
||||
return capped(notes, 12), capped(tasks, 20), nil
|
||||
}
|
||||
|
||||
func capped[T any](in []T, n int) []T {
|
||||
if len(in) > n {
|
||||
return in[:n]
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// En mail, der bliver til en brugbar opgave.
|
||||
//
|
||||
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
|
||||
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
|
||||
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
|
||||
// mail for at slippe for.
|
||||
//
|
||||
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
|
||||
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
|
||||
// der tager fem minutter, er et, der overlapper med det næste.
|
||||
//
|
||||
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
|
||||
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
|
||||
|
||||
// mailForAI er én mail, som modellen får den at se.
|
||||
type mailForAI struct {
|
||||
From string
|
||||
Subject string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
|
||||
//
|
||||
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
|
||||
// en model, der springer en mail over, ellers ville flytte alle de følgende
|
||||
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
|
||||
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
|
||||
// grund: en forskydning på én er den slags fejl, ingen opdager.
|
||||
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
|
||||
if len(mails) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(mails))
|
||||
for i, m := range mails {
|
||||
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
|
||||
if m.Snippet != "" {
|
||||
line += "\n " + firstWords(m.Snippet, 40)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
byIndex := map[int]string{}
|
||||
for i, sug := range out {
|
||||
n, line := numberedLine(sug.Line)
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
byIndex[n-1] = line
|
||||
}
|
||||
return byIndex
|
||||
}
|
||||
|
||||
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
|
||||
//
|
||||
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
|
||||
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
|
||||
// ingenting, for projektet slås op blandt dem, der findes.
|
||||
//
|
||||
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
|
||||
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
|
||||
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
|
||||
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
task.Content = parsed.Content
|
||||
if parsed.Priority != 0 {
|
||||
task.Priority = parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
task.DueDate = parsed.DueDate
|
||||
}
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
task.ProjectID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject != "" {
|
||||
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Dagens plan: én besked om morgenen om det, der venter i dag.
|
||||
//
|
||||
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
|
||||
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
|
||||
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
|
||||
// at der lå tre ting og forfaldt i går.
|
||||
//
|
||||
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
|
||||
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
|
||||
// er præcis det, dette program findes for at slippe for.
|
||||
|
||||
const planScope = "dailyplan"
|
||||
|
||||
// planSettings er, hvad én person har bedt om.
|
||||
//
|
||||
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
|
||||
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
|
||||
// at det her er tændt for at kunne gøre sit arbejde.
|
||||
type planSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
|
||||
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
|
||||
// baggrundsjob ikke har.
|
||||
Hour int `json:"hour"`
|
||||
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
|
||||
// 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.
|
||||
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) {
|
||||
out := planSettings{Hour: 7}
|
||||
stored, err := s.db.UserSettings(ctx, userID, planScope)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
out.Enabled = v
|
||||
}
|
||||
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
|
||||
out.Hour = int(v)
|
||||
}
|
||||
if v, ok := stored["last_sent"].(string); ok {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||
"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) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
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)
|
||||
}
|
||||
|
||||
type planRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Hour *int `json:"hour"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req planRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Hour != nil {
|
||||
if *req.Hour < 0 || *req.Hour > 23 {
|
||||
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
|
||||
return
|
||||
}
|
||||
p.Hour = *req.Hour
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
|
||||
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
|
||||
// morgen — hvilket ligner, at indstillingen ikke virkede.
|
||||
p.LastSent = ""
|
||||
}
|
||||
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
|
||||
s.internal(w, r, "save plan settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
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
|
||||
// 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) {
|
||||
var req planNowRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
plan, err := s.buildPlan(r.Context(), user)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build plan", err)
|
||||
return
|
||||
}
|
||||
if plan == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||
return
|
||||
}
|
||||
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||
s.internal(w, r, "save plan", err)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// endnu i dag.
|
||||
//
|
||||
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
|
||||
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
|
||||
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
|
||||
// egen tidszone afgør, hvornår det er morgen hos dem.
|
||||
func (s *Server) DailyPlans(ctx context.Context) error {
|
||||
users, err := s.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range users {
|
||||
user, err := s.db.UserByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := s.planSettings(ctx, user.ID)
|
||||
if err != nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().In(userLocation(user.Timezone))
|
||||
today := now.Format("2006-01-02")
|
||||
if p.LastSent == today || now.Hour() < p.Hour {
|
||||
continue
|
||||
}
|
||||
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
|
||||
// sende tre planer, når den kommer op — de to af dem er om dage, der er
|
||||
// gået, og en plan for i forgårs er ikke en plan.
|
||||
if now.Hour() > p.Hour+3 {
|
||||
p.LastSent = today
|
||||
_ = s.savePlanSettings(ctx, user.ID, p)
|
||||
continue
|
||||
}
|
||||
|
||||
plan, err := s.buildPlan(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("build daily plan", "err", err, "user", user.ID)
|
||||
continue
|
||||
}
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
|
||||
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
|
||||
// være med at åbne beskeder.
|
||||
if plan != "" {
|
||||
s.notifyDirect(ctx, &store.Notification{
|
||||
UserID: user.ID, Kind: "daily.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
|
||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||
s.log.Warn("save plan settings", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlan skriver dagens plan.
|
||||
//
|
||||
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
|
||||
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
|
||||
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
|
||||
// står tallene alene, og det er stadig det, man havde brug for at vide.
|
||||
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
|
||||
loc := userLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
today := now.Format("2006-01-02")
|
||||
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
|
||||
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
|
||||
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
|
||||
// overskredet, er en plan, man holder op med at læse.
|
||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
DueFrom: today, DueBefore: today, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(overdue) == 0 && len(due) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain := plainPlan(len(due), len(overdue), user.Locale)
|
||||
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(due)+len(overdue))
|
||||
for _, t := range overdue {
|
||||
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
|
||||
}
|
||||
for _, t := range due {
|
||||
lines = append(lines, "- "+t.Content)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
|
||||
if err != nil {
|
||||
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
|
||||
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
|
||||
// dårligere handel end en besked uden en velskrevet sætning.
|
||||
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
|
||||
return plain, nil
|
||||
}
|
||||
return strings.TrimSpace(written), nil
|
||||
}
|
||||
|
||||
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
|
||||
//
|
||||
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
|
||||
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
|
||||
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
|
||||
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
|
||||
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
|
||||
values, err := s.db.UserSettings(ctx, user.ID, "ai")
|
||||
if err != nil {
|
||||
return ai.Config{}, err
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
|
||||
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
|
||||
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
|
||||
if err := s.db.CreateNotification(ctx, n); err != nil {
|
||||
s.log.Warn("create notification", "err", err)
|
||||
return
|
||||
}
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
go s.pushToUser(n.UserID, n.Title, n.Body, "")
|
||||
}
|
||||
|
||||
func planTitle(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "Today"
|
||||
}
|
||||
return "I dag"
|
||||
}
|
||||
|
||||
func overdueWord(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "was due"
|
||||
}
|
||||
return "forfaldt"
|
||||
}
|
||||
|
||||
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
|
||||
//
|
||||
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
|
||||
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
|
||||
// om nogen har lagt en API-nøgle ind.
|
||||
func plainPlan(due, overdue int, locale string) string {
|
||||
english := strings.HasPrefix(strings.ToLower(locale), "en")
|
||||
var parts []string
|
||||
if due > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(due)+" due today")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
|
||||
}
|
||||
}
|
||||
if overdue > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" overdue")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
||||
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
|
||||
// får dem udleveret som kendsgerninger.
|
||||
func TestTheDailyPlanCountsForItself(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "ring til anders i dag")
|
||||
ts.quickTask(t, "køb mælk i dag")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
// Uden en model står tallene alene, og det er stadig det, man havde brug for
|
||||
// at vide klokken syv.
|
||||
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
|
||||
t.Errorf("plan = %q", plan)
|
||||
}
|
||||
|
||||
// Og den lander i klokken som en besked.
|
||||
list, unread := notifications(t, ts)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "daily.plan" {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
|
||||
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
|
||||
// beskeder.
|
||||
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["sent"] != false {
|
||||
t.Errorf("der blev sendt en plan om ingenting: %v", out)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("beskeder = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
|
||||
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
|
||||
// ligner, at indstillingen ikke virkede.
|
||||
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
|
||||
"enabled": true, "hour": 7,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("slå planen til")
|
||||
}
|
||||
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
|
||||
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("flyt timen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["hour"] != float64(16) {
|
||||
t.Errorf("time = %v", out["hour"])
|
||||
}
|
||||
if out["last_sent"] != nil && out["last_sent"] != "" {
|
||||
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
|
||||
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// AI'ens forslag, og hvad der sker med dem.
|
||||
//
|
||||
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
|
||||
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
|
||||
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
|
||||
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
|
||||
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
|
||||
// skal have ret tit nok til at spare et par tastetryk.
|
||||
//
|
||||
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
|
||||
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
|
||||
// gennem den samme som alt andet.
|
||||
|
||||
// aiSuggestionJSON er ét forslag på vej til fladen.
|
||||
type aiSuggestionJSON struct {
|
||||
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
|
||||
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
|
||||
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
|
||||
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
|
||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return nil, false
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
|
||||
// lægger sin egen nøgle ind.
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
|
||||
return nil, false
|
||||
}
|
||||
return ai.New(cfg), true
|
||||
}
|
||||
|
||||
// projectNames er de projekter, modellen må nævne.
|
||||
//
|
||||
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
|
||||
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
|
||||
// nogen sagde ja til forslaget.
|
||||
func (s *Server) projectNames(r *http.Request) []string {
|
||||
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
|
||||
if err != nil {
|
||||
s.log.Warn("projects for ai", "err", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
continue
|
||||
}
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
|
||||
// skrives færdig.
|
||||
//
|
||||
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
|
||||
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
|
||||
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
|
||||
// spørgsmål og et ja pr. linje.
|
||||
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inbox, err := s.db.InboxID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{inbox}, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "list inbox", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
|
||||
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
|
||||
// må ikke kunne flytte alle de andres forslag én plads — derfor står
|
||||
// nummeret i teksten og bliver læst tilbage.
|
||||
lines := make([]string, 0, len(tasks))
|
||||
for i, t := range tasks {
|
||||
line := strconv.Itoa(i+1) + ". " + t.Content
|
||||
if t.Description != "" {
|
||||
line += " — " + firstWords(t.Description, 30)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai tidy inbox", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
n, line := numberedLine(sug.Line)
|
||||
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
|
||||
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
|
||||
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(tasks) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
type applyLineRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Line string `json:"line"`
|
||||
}
|
||||
|
||||
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
|
||||
// om.
|
||||
//
|
||||
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
|
||||
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
|
||||
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
|
||||
// der ikke blev rørt.
|
||||
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if strings.TrimSpace(req.Line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
|
||||
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
|
||||
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
|
||||
// slette noget.
|
||||
update := store.TaskUpdate{Content: &parsed.Content}
|
||||
if parsed.Priority != 0 {
|
||||
update.Priority = &parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
update.SetDue, update.DueDate = true, parsed.DueDate
|
||||
}
|
||||
if parsed.Recurrence != "" {
|
||||
update.RecurrenceRule = &parsed.Recurrence
|
||||
}
|
||||
if len(parsed.Labels) > 0 {
|
||||
update.Labels, update.SetLabels = parsed.Labels, true
|
||||
}
|
||||
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
|
||||
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
|
||||
s.internal(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
|
||||
}
|
||||
|
||||
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
|
||||
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
|
||||
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
|
||||
// bare at være forkert.
|
||||
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(n.Body) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai note actions", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for _, sug := range suggestions {
|
||||
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
// handleAICreateFromNote laver én accepteret opgave fra en note.
|
||||
//
|
||||
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
|
||||
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
|
||||
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
|
||||
// den kom fra — og noten kan findes på sin titel.
|
||||
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
|
||||
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
|
||||
// linjen vinder, fordi det er valgt frem for arvet.
|
||||
projectID := n.ProjectID
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from note", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
|
||||
//
|
||||
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
|
||||
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
|
||||
// model, der springer en opgave over, ville så flytte alle de følgende forslag
|
||||
// én plads, og så bliver "sæt dato på den her" sat på en anden.
|
||||
func numberedLine(s string) (int, string) {
|
||||
s = strings.TrimSpace(s)
|
||||
dot := strings.IndexByte(s, '.')
|
||||
if dot <= 0 || dot > 3 {
|
||||
return 0, s
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s[:dot] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, s
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, strings.TrimSpace(s[dot+1:])
|
||||
}
|
||||
|
||||
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
|
||||
// fylde den med en hel note.
|
||||
func firstWords(s string, n int) string {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) <= n {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
return strings.Join(fields[:n], " ") + " …"
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
|
||||
//
|
||||
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
|
||||
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
|
||||
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
|
||||
func fakeModel(t *testing.T, reply string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]string{"content": reply}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
|
||||
// på en adresse i det private net, og bootstrap-kontoen er en.
|
||||
func (ts *testServer) useModel(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "base_url": url, "model": "prøvemodel",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
|
||||
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
|
||||
// anden opgave end den, den blev skrevet om.
|
||||
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
|
||||
]`))
|
||||
|
||||
first := ts.quickTask(t, "noget som helst")
|
||||
second := ts.quickTask(t, "anders 12345678")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
sug := list[0].(map[string]any)
|
||||
if sug["task_id"] != second {
|
||||
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
|
||||
}
|
||||
if sug["line"] != "Ring til Anders i morgen p1" {
|
||||
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
|
||||
}
|
||||
if sug["why"] == "" {
|
||||
t.Error("forslaget siger ikke hvorfor")
|
||||
}
|
||||
_ = first
|
||||
}
|
||||
|
||||
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
|
||||
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
|
||||
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders i morgen p1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Ring til Anders" {
|
||||
t.Errorf("indhold = %v", out["content"])
|
||||
}
|
||||
if out["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", out["priority"])
|
||||
}
|
||||
if out["due_date"] == nil || out["due_date"] == "" {
|
||||
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
|
||||
// opgave hen, hvor ejeren ikke leder efter den.
|
||||
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d", resp.StatusCode)
|
||||
}
|
||||
if after["project_id"] != before["project_id"] {
|
||||
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
|
||||
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
|
||||
// stole på.
|
||||
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
|
||||
]`))
|
||||
|
||||
body := "# Møde med Anders\njeg sender prislisten fredag"
|
||||
noteID := ts.createNote(t, body)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
line := list[0].(map[string]any)["line"].(string)
|
||||
|
||||
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
|
||||
map[string]any{"line": line})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["content"] != "Send prisliste til Anders" {
|
||||
t.Errorf("opgave = %v", task["content"])
|
||||
}
|
||||
// Opgaven bærer, hvor den kom fra, som et notelink.
|
||||
if task["description"] != "[[Møde med Anders]]" {
|
||||
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
|
||||
}
|
||||
|
||||
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
if note["body"] != body {
|
||||
t.Errorf("noten blev rettet:\n%v", note["body"])
|
||||
}
|
||||
}
|
||||
|
||||
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
|
||||
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
|
||||
// noget".
|
||||
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
|
||||
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
|
||||
func (ts *testServer) quickTask(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("quickadd gav intet id: %v", out)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
|
||||
// En liste over alt, der blev kigget i, er ikke en henvisning.
|
||||
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
|
||||
|
||||
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
|
||||
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["answer"] != "Du lovede Anders prislisten fredag." {
|
||||
t.Errorf("svar = %v", out["answer"])
|
||||
}
|
||||
sources, _ := out["sources"].([]any)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("kilder = %v, want én", sources)
|
||||
}
|
||||
src := sources[0].(map[string]any)
|
||||
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
|
||||
t.Errorf("kilde = %v", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
|
||||
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
|
||||
// svaret var gættet.
|
||||
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
|
||||
map[string]any{"question": "kvadratrodenafenbanan"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d", resp.StatusCode)
|
||||
}
|
||||
if out["answer"] != "" {
|
||||
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
|
||||
}
|
||||
}
|
||||
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||
// instans", så den følger med uden en ekstra tur.
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Edition string `json:"edition"`
|
||||
}{n == 0, s.edition()})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
|
||||
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
|
||||
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
|
||||
const defaultPublishMin = 25
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
@@ -30,6 +35,33 @@ type beaconConfig struct {
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
|
||||
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
|
||||
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
|
||||
// kan man ikke se en udrulning i.
|
||||
LastVersion string `json:"last_version,omitempty"`
|
||||
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
|
||||
//
|
||||
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
|
||||
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
|
||||
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
|
||||
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
|
||||
// alt er sendt.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt int64 `json:"last_error_at,omitempty"`
|
||||
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
|
||||
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
|
||||
// det, der husker, at det er blevet sagt.
|
||||
NoticeAck bool `json:"notice_ack"`
|
||||
// PublishMin er gulvet under det offentliggjorte tal.
|
||||
//
|
||||
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
|
||||
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
|
||||
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
|
||||
// der ingenting frem for et lille tal, ingen kan tolke.
|
||||
PublishMin int `json:"publish_min"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
@@ -40,7 +72,9 @@ type beaconConfig struct {
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
cfg := beaconConfig{
|
||||
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["last_version"].(string); ok {
|
||||
cfg.LastVersion = v
|
||||
}
|
||||
if v, ok := stored["last_error"].(string); ok {
|
||||
cfg.LastError = v
|
||||
}
|
||||
if v, ok := stored["last_error_at"].(float64); ok {
|
||||
cfg.LastErrorAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["notice_ack"].(bool); ok {
|
||||
cfg.NoticeAck = v
|
||||
}
|
||||
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
|
||||
cfg.PublishMin = int(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
"last_version": cfg.LastVersion,
|
||||
"last_error": cfg.LastError,
|
||||
"last_error_at": cfg.LastErrorAt,
|
||||
"notice_ack": cfg.NoticeAck,
|
||||
"publish_min": cfg.PublishMin,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
PublishMin int `json:"publish_min"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
|
||||
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
|
||||
// det eneste spor, et tavst beacon ellers efterlader.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt string `json:"last_error_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
PublishMin: cfg.PublishMin,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.LastError != "" {
|
||||
out.LastError = cfg.LastError
|
||||
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
@@ -129,6 +194,7 @@ type beaconRequest struct {
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
PublishMin *int `json:"publish_min"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.PublishMin != nil {
|
||||
if *req.PublishMin < 0 {
|
||||
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
|
||||
return
|
||||
}
|
||||
cfg.PublishMin = *req.PublishMin
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err == nil && n < cfg.PublishMin {
|
||||
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
|
||||
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
|
||||
// læst som "færre bruger det nu", også når det i virkeligheden var en
|
||||
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
|
||||
// blive ramt.
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
|
||||
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
|
||||
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
|
||||
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
|
||||
// et døgn efter at være opdateret.
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
|
||||
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
|
||||
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
|
||||
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
|
||||
// savne en.
|
||||
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
|
||||
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
|
||||
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
|
||||
// siden om noget, der virker.
|
||||
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
cfg.LastVersion = Version
|
||||
cfg.LastError, cfg.LastErrorAt = "", 0
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
|
||||
|
||||
// handleBeaconNotice says whether the disclosure is still owed, and what it would
|
||||
// say.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
|
||||
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
|
||||
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
|
||||
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
|
||||
// dem, der ved, at der er noget at slå fra.
|
||||
//
|
||||
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
|
||||
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
|
||||
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
|
||||
// løfte, der er noget værd: en, læseren selv kan kontrollere.
|
||||
//
|
||||
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
|
||||
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
|
||||
// ikke kan svare på det.
|
||||
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pending": !cfg.NoticeAck,
|
||||
"enabled": cfg.Enabled,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"version": Version,
|
||||
"collector_url": cfg.Collector,
|
||||
})
|
||||
}
|
||||
|
||||
type beaconNoticeRequest struct {
|
||||
// Keep er en peger og ikke en bool, og det er hele pointen.
|
||||
//
|
||||
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
|
||||
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
|
||||
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
|
||||
// tilsvarende ende og er blevet bidt af den.
|
||||
Keep *bool `json:"keep"`
|
||||
}
|
||||
|
||||
// handleAckBeaconNotice records that the notice has been seen, and turns the
|
||||
// beacon off if that was the answer.
|
||||
//
|
||||
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
|
||||
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
|
||||
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
|
||||
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconNoticeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Keep == nil {
|
||||
writeFieldErrors(w, map[string]string{"keep": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
cfg.NoticeAck = true
|
||||
cfg.Enabled = *req.Keep
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
|
||||
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
|
||||
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent besked: %d", resp.StatusCode)
|
||||
}
|
||||
if notice["pending"] != true {
|
||||
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
|
||||
}
|
||||
if notice["enabled"] != true {
|
||||
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
|
||||
}
|
||||
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
|
||||
settings := beaconSettingsOf(t, ts)
|
||||
if notice["instance_id"] != settings["instance_id"] {
|
||||
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
|
||||
notice["instance_id"], settings["instance_id"])
|
||||
}
|
||||
if notice["collector_url"] != settings["collector_url"] {
|
||||
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
|
||||
notice["collector_url"], settings["collector_url"])
|
||||
}
|
||||
if notice["version"] == "" || notice["version"] == nil {
|
||||
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
|
||||
}
|
||||
}
|
||||
|
||||
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
|
||||
// ved næste login.
|
||||
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
|
||||
t.Fatalf("behold: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
|
||||
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
|
||||
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
s := beaconSettingsOf(t, ts)
|
||||
if s["enabled"] != false {
|
||||
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
|
||||
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
|
||||
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
|
||||
t.Errorf("en tom krop talte som et svar: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
|
||||
// svares der ingenting frem for et lille tal, ingen kan tolke.
|
||||
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
|
||||
"is_collector": true, "publish_count": true,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("bliv collector")
|
||||
}
|
||||
|
||||
// Instansen tæller sig selv, så der er én. Gulvet er højere.
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tælling: %d", resp.StatusCode)
|
||||
}
|
||||
if out["installs"] != nil {
|
||||
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
|
||||
}
|
||||
|
||||
// Sættes gulvet ned, står tallet der.
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
|
||||
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("sænk gulvet")
|
||||
}
|
||||
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
|
||||
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "github.com/kristianwind/verdande/internal/config"
|
||||
|
||||
// edition is what this instance tells the world it is.
|
||||
//
|
||||
// Never the empty string, even when the config was built by hand in a test and
|
||||
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||
// full program" from "this build forgot to say", and the first of those is the
|
||||
// answer that has always been true.
|
||||
func (s *Server) edition() string {
|
||||
if s.cfg.NotesOnly() {
|
||||
return config.EditionNotes
|
||||
}
|
||||
return config.EditionFull
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The notes edition is a different program, and this is what makes that true.
|
||||
//
|
||||
// A gate in a route tree is the easiest thing in the world to believe and the
|
||||
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||||
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||||
// So this does not check that a gate is *present*. It walks both routers and
|
||||
// compares the two sets of routes they actually serve.
|
||||
//
|
||||
// The difference is written down below. A route added later that differs between
|
||||
// the editions — in either direction — fails here until somebody says which half
|
||||
// it belongs to. That is the point: the list is the decision, and the walk is
|
||||
// what keeps the decision honest.
|
||||
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
full := routesOf(t, config.EditionFull)
|
||||
notes := routesOf(t, config.EditionNotes)
|
||||
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
//
|
||||
// And note what this list could NOT catch, because it is the shape of mistake
|
||||
// that outlived the first version of this test. The list went green at 29
|
||||
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||
// exports, both task imports, the project templates, the sections and six AI
|
||||
// task endpoints were all still served by the notes edition — because the test
|
||||
// compares the router against THIS LIST, and the list was written from the
|
||||
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||
// is-it-complete check.
|
||||
//
|
||||
// So when a route is added that one edition should not have, the question to
|
||||
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||
// all without the half it is being withheld from". Three of the ones above were
|
||||
// settled by reading rather than by the name: a template turned out to create
|
||||
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/sections/{sectionID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"DELETE /api/v1/templates/{templateID}",
|
||||
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /.well-known/caldav",
|
||||
"GET /api/v1/ai/plan",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/export/projects.zip",
|
||||
"GET /api/v1/export/projects/{projectID}.csv",
|
||||
"GET /api/v1/export/projects/{projectID}.ics",
|
||||
"GET /api/v1/export/tasks.ics",
|
||||
"GET /api/v1/feed",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/projects/{projectID}/sections",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/templates",
|
||||
"GET /api/v1/upcoming",
|
||||
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /ics/{token}",
|
||||
"OPTIONS /caldav/*",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/sections/{sectionID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/ai/inbox/tidy",
|
||||
"POST /api/v1/ai/inbox/tidy/apply",
|
||||
"POST /api/v1/ai/plan/now",
|
||||
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||
"POST /api/v1/feed/rotate",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/import/csv",
|
||||
"POST /api/v1/import/todoist",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/projects/{projectID}/sections",
|
||||
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
"POST /api/v1/tasks/{taskID}/comments",
|
||||
"POST /api/v1/tasks/{taskID}/complete",
|
||||
"POST /api/v1/tasks/{taskID}/move",
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
"POST /api/v1/templates",
|
||||
"POST /api/v1/templates/{templateID}/use",
|
||||
"POST /inbound/hook/{token}",
|
||||
"PROPFIND /caldav",
|
||||
"PROPFIND /caldav/{userID}",
|
||||
"PROPFIND /caldav/{userID}/{projectID}",
|
||||
"PUT /api/v1/ai/plan",
|
||||
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"REPORT /caldav/{userID}/{projectID}",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
|
||||
var unexpected, stillThere []string
|
||||
for r := range full {
|
||||
if notes[r] {
|
||||
if withheld[r] {
|
||||
stillThere = append(stillThere, r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !withheld[r] {
|
||||
unexpected = append(unexpected, r)
|
||||
}
|
||||
}
|
||||
// And the other direction: the notes edition must not grow a route the full
|
||||
// one lacks. A gate that adds is not a gate either.
|
||||
var onlyInNotes []string
|
||||
for r := range notes {
|
||||
if !full[r] {
|
||||
onlyInNotes = append(onlyInNotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unexpected)
|
||||
sort.Strings(stillThere)
|
||||
sort.Strings(onlyInNotes)
|
||||
for _, r := range unexpected {
|
||||
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||||
}
|
||||
for _, r := range stillThere {
|
||||
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||||
}
|
||||
for _, r := range onlyInNotes {
|
||||
t.Errorf("served only by the notes edition: %s", r)
|
||||
}
|
||||
|
||||
// Printed on the green as well as the red. "The editions differ correctly" over
|
||||
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||||
// them means anything — so the number is on screen either way.
|
||||
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||||
|
||||
// The half that stops this being a deletion rather than a gate: what the notes
|
||||
// edition is FOR has to still be there.
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"PATCH /api/v1/notes/{noteID}",
|
||||
"DELETE /api/v1/notes/{noteID}",
|
||||
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||
"POST /api/v1/notes/{noteID}/shares",
|
||||
"GET /api/v1/notes/{noteID}/shares",
|
||||
"POST /api/v1/notes/{noteID}/attachments",
|
||||
"POST /api/v1/notes/import",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/export/account",
|
||||
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routesOf builds a server at one edition and returns every route it serves.
|
||||
func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
out := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
out[method+" "+route] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(out) < 50 {
|
||||
// A walk that found almost nothing would make every comparison below pass.
|
||||
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The interface cannot gate itself on something it is not told, and it has to be
|
||||
// told before anybody is signed in — an instance's shape is not a permission.
|
||||
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||
for _, tc := range []struct{ edition, want string }{
|
||||
{config.EditionFull, "full"},
|
||||
{config.EditionNotes, "notes"},
|
||||
{"", "full"}, // a config built by hand is the program as it has always been
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if got := body["edition"]; got != tc.want {
|
||||
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
// And the field it has always had is still there: adding one must not
|
||||
// quietly replace the answer the sign-in screen depends on.
|
||||
if _, ok := body["needs_setup"]; !ok {
|
||||
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||
// so it is the one door that could still open onto the half the router just shut.
|
||||
//
|
||||
// Not a theoretical case. An instance converted from a full one still has the
|
||||
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want int
|
||||
}{
|
||||
{config.EditionFull, 1},
|
||||
{config.EditionNotes, 0},
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
userID, _ := me["id"].(string)
|
||||
if userID == "" {
|
||||
t.Fatalf("no user: %v", me)
|
||||
}
|
||||
|
||||
// Created through the store rather than the API: in the notes edition there
|
||||
// is no route to create one, and the row this is about is the row that is
|
||||
// already there.
|
||||
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||
found, _ := body["tasks"].([]any)
|
||||
if len(found) != tc.want {
|
||||
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||
}
|
||||
// The full edition's number is the control: a search that finds nothing at
|
||||
// all would satisfy the notes case for the wrong reason.
|
||||
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||
//
|
||||
// The same hole as the routes, through a different door — and it was still open
|
||||
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||
// to a notes instance would be told it can create_task, call it, get something
|
||||
// back, and tell the person their task was filed.
|
||||
//
|
||||
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||
// what a client actually sees, and the slice is unexported for the same reason.
|
||||
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||
|
||||
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
names := mcpToolNames(t, edition)
|
||||
|
||||
// Both editions: the shared tool, and the notes half.
|
||||
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||
if !names[want] {
|
||||
t.Errorf("%s edition does not offer %s", edition, want)
|
||||
}
|
||||
}
|
||||
for _, task := range taskTools {
|
||||
switch edition {
|
||||
case config.EditionFull:
|
||||
if !names[task] {
|
||||
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||
}
|
||||
default:
|
||||
if names[task] {
|
||||
t.Errorf("the notes edition offers %s over MCP", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d tools", edition, len(names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if resp == nil {
|
||||
t.Fatal("tools/list got no response")
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct{ Name string } `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||
}
|
||||
if len(parsed.Result.Tools) == 0 {
|
||||
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||
// leaves the query on its own and this list is a safety net rather than the only
|
||||
// record. It still earns its place: it covers the window between making the task
|
||||
// and the star coming off, and it is the whole of the dedup for a label trigger,
|
||||
// where the label is the person's and not ours to remove. A record on this side
|
||||
// has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
@@ -330,7 +332,16 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
projectID = inbox
|
||||
}
|
||||
|
||||
created := 0
|
||||
// Hentet først, skrevet bagefter. To gennemløb frem for ét, fordi modellen
|
||||
// skal se hele bunken på én gang: ét spørgsmål for femogtyve mails frem for
|
||||
// femogtyve spørgsmål er den samme oplysning til en femogtyvendedel af
|
||||
// ventetiden — og et gennemløb, der tager fem minutter, overlapper med det
|
||||
// næste.
|
||||
type fetched struct {
|
||||
id string
|
||||
msg gmail.Message
|
||||
}
|
||||
var pending []fetched
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
@@ -338,13 +349,27 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, fetched{id: id, msg: msg})
|
||||
}
|
||||
|
||||
forAI := make([]mailForAI, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
forAI = append(forAI, mailForAI{
|
||||
From: gmail.SenderName(f.msg.From), Subject: f.msg.Subject, Snippet: f.msg.Snippet,
|
||||
})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
for i, f := range pending {
|
||||
id, msg := f.id, f.msg
|
||||
|
||||
subject := strings.TrimSpace(msg.Subject)
|
||||
if subject == "" {
|
||||
@@ -365,6 +390,9 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// key exists to prevent.
|
||||
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her mail.
|
||||
s.applyMailLine(ctx, user, task, written[i], sender+": "+subject)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Somebody got there first — an earlier sweep, or another
|
||||
@@ -381,6 +409,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The star comes off every message this query returned that now has a task —
|
||||
// the ones made this run and the ones a run before it already made. Gated to the
|
||||
// triggers that are about the star: for a label trigger the star is the person's
|
||||
// own and not ours to touch, and dedup there rests on the seen list alone.
|
||||
//
|
||||
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
|
||||
// that would not come off. The likeliest reason it will not, right after this
|
||||
// ships, is a connection still holding the old read-only grant — the write is a
|
||||
// 403 until the account is reconnected, and this logs it rather than failing the
|
||||
// sync. It self-heals: the mail stays starred, stays in the next query, and is
|
||||
// tried again once the grant is there.
|
||||
if box.Trigger == "starred" || box.Trigger == "both" {
|
||||
unstar := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
unstar = append(unstar, id)
|
||||
}
|
||||
}
|
||||
if err := client.Unstar(ctx, unstar); err != nil {
|
||||
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
|
||||
}
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
@@ -442,8 +493,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// The one click that opens Google's consent screen already exists; what it needs
|
||||
// behind it is a registered client, and that is not something any app can conjure:
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||
// restricted scope, so an id shipped inside a public image would be both
|
||||
// extractable from it and unusable without Google's security review of every
|
||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||
// recreate the container to paste the result was not.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// A message that becomes a task has its star taken off — the write that the whole
|
||||
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
||||
// the way the IMAP side unflags what it imports.
|
||||
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
||||
var unstarred struct {
|
||||
hit bool
|
||||
body struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
}
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
||||
unstarred.hit = true
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &unstarred.body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
||||
writeTestJSON(w, map[string]any{
|
||||
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
||||
"payload": map[string]any{"headers": []map[string]string{
|
||||
{"name": "From", "value": "Anders <anders@example.dk>"},
|
||||
{"name": "Subject", "value": "Møde på fredag"},
|
||||
}},
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A live access token, so the run goes straight to the list without a refresh.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if created, _ := body["created"].(float64); created != 1 {
|
||||
t.Fatalf("created = %v, want 1", body["created"])
|
||||
}
|
||||
|
||||
if !unstarred.hit {
|
||||
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
||||
}
|
||||
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
||||
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
||||
}
|
||||
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllCSV is every project at once, one CSV per project in a zip.
|
||||
//
|
||||
// A zip rather than one wide CSV with a project column: Todoist's own export is a
|
||||
// file per project, so that is the shape an importer on the other side expects —
|
||||
// and the shape somebody can pick a single project out of without opening a
|
||||
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
|
||||
// rather than one long file.
|
||||
//
|
||||
// It exists because the per-project links are per project: fifteen projects meant
|
||||
// fifteen downloads to move your own tasks somewhere else, which is an export that
|
||||
// technically works and nobody uses.
|
||||
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Two projects can share a name — they are still two projects, and a zip with
|
||||
// one entry written twice loses one of them. Same guard as the notes export.
|
||||
used := map[string]int{}
|
||||
for _, project := range projects {
|
||||
exported, err := s.buildExport(r, user, &project)
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
|
||||
// better than a complete-looking file with a project missing from it.
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
|
||||
if err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllICS is every dated task in one calendar.
|
||||
//
|
||||
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
|
||||
// many things and every reader takes a file with hundreds of VTODOs. The project
|
||||
// travels with each task as its own field, so nothing is lost by merging them.
|
||||
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range projects {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
|
||||
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||
// takes, rebuilding the task tree from parent ids.
|
||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
|
||||
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
|
||||
// prøves her.
|
||||
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
// hookURL henter personens egen krog-adresse, som fladen viser den.
|
||||
func (ts *testServer) hookURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
|
||||
}
|
||||
url, _ := out["url"].(string)
|
||||
if !strings.Contains(url, "/inbound/hook/") {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
|
||||
// skal kunne kopieres ind i en genvej på en telefon.
|
||||
func hookPath(url string) string {
|
||||
return url[strings.Index(url, "/inbound/hook/"):]
|
||||
}
|
||||
|
||||
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
|
||||
// betyder det samme, som den ville betyde i feltet øverst i fladen.
|
||||
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
resp, out := anon.doRaw(t, "POST", path, "text/plain",
|
||||
"Ring til Anders p1\nhan ringede i går")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["content"] == "Ring til Anders" {
|
||||
found = task
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
|
||||
}
|
||||
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
|
||||
// teksten, og resten af kroppen står under opgaven.
|
||||
if found["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", found["priority"])
|
||||
}
|
||||
if found["description"] != "han ringede i går" {
|
||||
t.Errorf("beskrivelse = %v", found["description"])
|
||||
}
|
||||
}
|
||||
|
||||
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
|
||||
func TestTheHookReadsJSON(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"text": "Køb mælk"}`,
|
||||
`{"title": "Køb mælk"}`,
|
||||
`{"content": "Køb mælk"}`,
|
||||
} {
|
||||
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Køb mælk" {
|
||||
t.Errorf("%s gav %v", body, out["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
|
||||
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
|
||||
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
|
||||
// Alt andet er en maskine til at gætte tokens med.
|
||||
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
|
||||
// tilbage: adressen står i en genvej på en telefon eller i et script.
|
||||
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
old := hookPath(ts.hookURL(t))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skift: %d", resp.StatusCode)
|
||||
}
|
||||
fresh := hookPath(out["url"].(string))
|
||||
if fresh == old {
|
||||
t.Fatal("den nye URL er den gamle")
|
||||
}
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
|
||||
// fordi den ser ud som om integrationen virker.
|
||||
func TestAnEmptyPushIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
|
||||
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
|
||||
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
url := ts.hookURL(t)
|
||||
token := url[strings.LastIndex(url, "/")+1:]
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := anon.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("krog-tokenet gav adgang til API'et")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -155,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
|
||||
}
|
||||
|
||||
// --- en krog ind i indbakken --------------------------------------------------------
|
||||
|
||||
type hookURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleGetHookURL returns the personal address another program can push a task
|
||||
// to, minting the token on first ask.
|
||||
//
|
||||
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
|
||||
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
|
||||
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
|
||||
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
|
||||
// — fra en genvej på telefonen, et script, en anden tjeneste.
|
||||
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
// handleRotateHookURL mints a new one and retires the old.
|
||||
//
|
||||
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
|
||||
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
|
||||
// tilbage end tokenet selv.
|
||||
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, r, "generate hook token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||
s.internal(w, r, "set hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
func (s *Server) hookURL(token string) string {
|
||||
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
|
||||
}
|
||||
|
||||
type hookRequest struct {
|
||||
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
|
||||
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
|
||||
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
|
||||
// op.
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
// Note er det, der står under opgaven. `body` og `description` ligeså.
|
||||
Note string `json:"note"`
|
||||
Body string `json:"body"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (h hookRequest) line() string {
|
||||
for _, v := range []string{h.Text, h.Title, h.Content} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h hookRequest) note() string {
|
||||
for _, v := range []string{h.Note, h.Body, h.Description} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
|
||||
//
|
||||
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
|
||||
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
|
||||
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
|
||||
// den ene persons indbakke — hvor et API-token kan alt.
|
||||
//
|
||||
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
|
||||
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
|
||||
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
|
||||
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
|
||||
//
|
||||
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
|
||||
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
|
||||
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
|
||||
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
|
||||
if err != nil {
|
||||
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
|
||||
// kender forskel, er en maskine til at gætte tokens med.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
||||
return
|
||||
}
|
||||
|
||||
line, note := s.readHookBody(r)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"text": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
content := parsed.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = line
|
||||
}
|
||||
|
||||
projectID := ""
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: content, Priority: parsed.Priority,
|
||||
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from hook", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
s.log.Info("task created from hook", "user", user.ID)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
|
||||
}
|
||||
|
||||
// readHookBody er de tre måder, en linje tekst kommer ind ad.
|
||||
//
|
||||
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
|
||||
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
|
||||
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
|
||||
//
|
||||
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
|
||||
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
|
||||
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
|
||||
func (s *Server) readHookBody(r *http.Request) (line, note string) {
|
||||
const maxBody = 64 << 10
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
kind := r.Header.Get("Content-Type")
|
||||
|
||||
switch {
|
||||
case strings.Contains(kind, "application/json"):
|
||||
var req hookRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return req.line(), req.note()
|
||||
|
||||
case strings.Contains(kind, "application/x-www-form-urlencoded"):
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
req := hookRequest{
|
||||
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
|
||||
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
|
||||
}
|
||||
return req.line(), req.note()
|
||||
}
|
||||
|
||||
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
|
||||
// man har delt fra en anden app, ud — en overskrift og noget under.
|
||||
text := strings.TrimSpace(string(body))
|
||||
first, rest, _ := strings.Cut(text, "\n")
|
||||
return first, strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
||||
func mailToken(address string) string {
|
||||
if i := strings.LastIndex(address, "<"); i >= 0 {
|
||||
|
||||
@@ -173,13 +173,22 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Ét spørgsmål til modellen for hele gennemløbet, inden der bliver skrevet
|
||||
// noget. Er der ingen model, er kortet tomt, og hver opgave hedder afsender og
|
||||
// emne, som den altid har gjort.
|
||||
forAI := make([]mailForAI, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
forAI = append(forAI, mailForAI{From: msg.From, Subject: msg.Subject, Snippet: msg.Snippet})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
for i, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
// below has to be written for it: a run that created tasks and forgot how
|
||||
@@ -206,6 +215,11 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// one server, and two accounts will hand out the same small numbers.
|
||||
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her. Emnet bliver
|
||||
// stående i beskrivelsen: har den misforstået mailen, skal det oprindelige
|
||||
// kunne læses ved siden af.
|
||||
s.applyMailLine(ctx, user, task, written[i], content)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Already a task. Still count it as read, or last_uid never moves
|
||||
|
||||
@@ -23,7 +23,9 @@ import (
|
||||
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
||||
// and a description that only names the endpoint gives it nothing to decide with.
|
||||
func (s *Server) buildMCP() *mcp.Server {
|
||||
m := mcp.NewServer("verdande", "1")
|
||||
// Udgavens eget navn: det er sådan, instansen står i klientens
|
||||
// forbindelsesliste.
|
||||
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "list_projects",
|
||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}),
|
||||
}, s.mcpListProjects)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||
if !s.cfg.NotesOnly() {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
}
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
@@ -527,7 +536,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"links to a note by its title. A third, `@Navn`, shares the note with that " +
|
||||
"person and tells them — so only write one when the note is meant for them. " +
|
||||
"Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
|
||||
@@ -204,9 +204,152 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
// En ny note kan være den, en delt note hele tiden har peget på: linket er
|
||||
// skrevet som en titel, og titlen fandtes ikke før nu.
|
||||
//
|
||||
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
|
||||
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
|
||||
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
|
||||
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
|
||||
// nok til at nå at taste den første linje ind i den forrige note, som så blev
|
||||
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||
if strings.TrimSpace(n.Body) != "" {
|
||||
s.followLinks(r, n)
|
||||
s.shareWithMentioned(r, n, "")
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
// followLinks holder de udledte delinger i takt med teksten.
|
||||
//
|
||||
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
|
||||
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
|
||||
// samme sted hen for begge.
|
||||
//
|
||||
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
|
||||
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
|
||||
// gemning talte med, kunne den, man har delt en note med, skrive
|
||||
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
|
||||
//
|
||||
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
|
||||
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
|
||||
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
|
||||
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
|
||||
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
|
||||
//
|
||||
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
|
||||
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
|
||||
func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
||||
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
|
||||
return
|
||||
}
|
||||
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
|
||||
s.log.Error("sync linked shares", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
|
||||
//
|
||||
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
|
||||
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
|
||||
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
|
||||
//
|
||||
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
|
||||
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
|
||||
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
|
||||
//
|
||||
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
|
||||
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
|
||||
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
|
||||
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
|
||||
// kun besked til en, der kan se noten i forvejen.
|
||||
//
|
||||
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
|
||||
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
|
||||
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
|
||||
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
|
||||
// fortæller dem, at noten er blevet rettet.
|
||||
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
|
||||
told := map[string]bool{}
|
||||
if n == nil || n.CreatedBy == "" {
|
||||
return told
|
||||
}
|
||||
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
|
||||
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
|
||||
// deles med den, der har den.
|
||||
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
|
||||
if err != nil {
|
||||
s.log.Warn("people for mentions", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
|
||||
was := map[string]bool{}
|
||||
for _, id := range store.MentionsIn(before, people) {
|
||||
was[id] = true
|
||||
}
|
||||
fresh := []string{}
|
||||
for _, id := range store.MentionsIn(n.Body, people) {
|
||||
if !was[id] {
|
||||
fresh = append(fresh, id)
|
||||
}
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return told
|
||||
}
|
||||
|
||||
actor := userFrom(r.Context())
|
||||
owner := actor.ID == n.CreatedBy
|
||||
shared := false
|
||||
for _, id := range fresh {
|
||||
if !owner {
|
||||
continue
|
||||
}
|
||||
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
|
||||
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
|
||||
// skrev deres navn i teksten.
|
||||
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
|
||||
s.log.Warn("mention share role", "err", err, "note", n.ID)
|
||||
continue
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
|
||||
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
}
|
||||
if shared {
|
||||
// Og det, noten peger på, følger med delingen — samme vej som en deling
|
||||
// sat i panelet.
|
||||
s.followLinks(r, n)
|
||||
}
|
||||
|
||||
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
|
||||
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
|
||||
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
|
||||
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
canSee := map[string]bool{}
|
||||
for _, id := range audience {
|
||||
canSee[id] = true
|
||||
}
|
||||
for _, id := range fresh {
|
||||
if !canSee[id] {
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
|
||||
})
|
||||
told[id] = true
|
||||
}
|
||||
return told
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
@@ -242,6 +385,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
before := n.Body
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -249,13 +393,51 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
|
||||
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
|
||||
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
|
||||
// køre, når svaret kan være blevet et andet, og ellers ikke.
|
||||
wasLinking := store.LinksIn(before)
|
||||
wasTitled := n.Title
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
|
||||
// det, andres links peger på, uden at et eneste link er rørt.
|
||||
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
||||
s.followLinks(r, n)
|
||||
}
|
||||
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
|
||||
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
||||
// det ville lære folk at overse dem alle sammen.
|
||||
if before != n.Body {
|
||||
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
|
||||
// ikke også "rettede en note" om den samme gemning — det er én handling, og
|
||||
// den ene sætning siger mere end den anden.
|
||||
mentioned := s.shareWithMentioned(r, n, before)
|
||||
s.notifyNoteChanged(r, n, mentioned)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
func sameLinks(a, b []store.NoteLink) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
in := make(map[store.NoteLink]bool, len(a))
|
||||
for _, l := range a {
|
||||
in[l] = true
|
||||
}
|
||||
for _, l := range b {
|
||||
if !in[l] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
@@ -279,6 +461,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
|
||||
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
|
||||
s.followLinks(r, n)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
|
||||
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
|
||||
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
|
||||
|
||||
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "sofie@example.dk", "role": "editor"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
invited, _ := body["invited"].(map[string]any)
|
||||
if invited == nil || invited["email"] != "sofie@example.dk" {
|
||||
t.Fatalf("the response does not say who was invited: %v", body)
|
||||
}
|
||||
if invited["role"] != "editor" {
|
||||
t.Errorf("role = %v, want the one the owner chose", invited["role"])
|
||||
}
|
||||
// Uden en postserver er linket det eneste, der findes, og det skal stå i
|
||||
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
|
||||
if body["emailed"] != false {
|
||||
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
||||
}
|
||||
link, _ := body["link"].(string)
|
||||
if !strings.Contains(link, "token=") {
|
||||
t.Fatalf("link = %q, want one carrying a token", link)
|
||||
}
|
||||
|
||||
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
|
||||
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
pending, _ := panel["invites"].([]any)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("want one pending invite under the note, got %v", pending)
|
||||
}
|
||||
|
||||
// Og linket laver kontoen og delingen i ét.
|
||||
token := link[strings.Index(link, "token=")+len("token="):]
|
||||
fresh := newSignedOut(t, owner)
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||
"token": token, "name": "Sofie", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Noten er hendes at læse — og som medredaktør også at rette.
|
||||
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
|
||||
}
|
||||
if note["title"] != "Aftale om levering" {
|
||||
t.Errorf("the invited person got something else: %v", note)
|
||||
}
|
||||
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Og invitationen er brugt op: den står ikke længere og venter.
|
||||
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
if left, _ := after["invites"].([]any); len(left) != 0 {
|
||||
t.Errorf("the invite is still pending after it was accepted: %v", left)
|
||||
}
|
||||
shares, _ := after["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("want the accepted invite to be a share, got %v", shares)
|
||||
}
|
||||
}
|
||||
|
||||
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
|
||||
// invitation til at oprette en, som personen aldrig kan komme igennem.
|
||||
func TestSharingByEmailFindsAnExistingAccount(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
noteID := owner.createNote(t, "# Prisliste\n2026")
|
||||
|
||||
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "Sofie@Example.dk"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
person, _ := body["user"].(map[string]any)
|
||||
if person == nil || person["id"] != sofie {
|
||||
t.Fatalf("the response does not say who it reached: %v", body)
|
||||
}
|
||||
if _, invited := body["invited"]; invited {
|
||||
t.Error("an address with an account was invited rather than shared with")
|
||||
}
|
||||
|
||||
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || role != "viewer" {
|
||||
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
|
||||
}
|
||||
}
|
||||
|
||||
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
|
||||
// det samme, og kun det ene kan trækkes tilbage ad gangen.
|
||||
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first invite: status %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "ny@example.dk"})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
|
||||
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
pending, _ := panel["invites"].([]any)
|
||||
id := pending[0].(map[string]any)["id"].(string)
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
|
||||
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
|
||||
func TestOnlyTheOwnerCanInviteToANote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "fremmed@example.dk"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
|
||||
}
|
||||
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
|
||||
t.Fatalf("invites = %v (%v), want none", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// En invitation til én note kan ikke bruges til at rydde en anden notes
|
||||
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
|
||||
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
mine := owner.createNote(t, "# Min note\n1")
|
||||
other := owner.createNote(t, "# Min anden note\n2")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
|
||||
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatal("invite failed")
|
||||
}
|
||||
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
|
||||
if err != nil || len(invites) != 1 {
|
||||
t.Fatalf("invites = %v (%v)", invites, err)
|
||||
}
|
||||
|
||||
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
|
||||
}
|
||||
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
|
||||
t.Error("the invite was withdrawn through a note it does not belong to")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
|
||||
// noten, og den, der bliver nævnt, får besked om det.
|
||||
func TestMentioningSomebodySharesTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ret noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Delt, som læser.
|
||||
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || !has || role != "viewer" {
|
||||
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
|
||||
}
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
|
||||
// handling, og den ene sætning siger mere end den anden.
|
||||
list, unread := notifications(t, other)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
|
||||
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
|
||||
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
|
||||
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
|
||||
if list, _ := notifications(t, other); len(list) != 1 {
|
||||
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
}
|
||||
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
|
||||
// omtaler mere.
|
||||
list, _ := notifications(t, other)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
|
||||
}
|
||||
if firstOf(t, list)["kind"] != "note.changed" {
|
||||
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
|
||||
}
|
||||
}
|
||||
|
||||
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
|
||||
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
|
||||
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
editor := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
peterClient := owner.newUser(t, "peter@example.dk", "Peter")
|
||||
peter := userID(t, owner, "peter@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
|
||||
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("medredaktøren retter")
|
||||
}
|
||||
|
||||
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
|
||||
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
|
||||
}
|
||||
// Og Peter får ikke besked om en note, han ikke kan åbne.
|
||||
if list, _ := notifications(t, peterClient); len(list) != 0 {
|
||||
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
|
||||
}
|
||||
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
|
||||
if list, _ := notifications(t, owner); len(list) == 0 {
|
||||
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
|
||||
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
|
||||
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
|
||||
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || role != "editor" {
|
||||
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -77,12 +78,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list note shares", err)
|
||||
return
|
||||
}
|
||||
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
|
||||
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
|
||||
// med den her. To udregninger af det samme er to, der kan blive uenige.
|
||||
//
|
||||
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
|
||||
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
|
||||
// siden.
|
||||
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
via := map[string]string{}
|
||||
for _, l := range following {
|
||||
if l.NoteID == n.ID {
|
||||
via[l.UserID] = l.Via
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]noteShareJSON, 0, len(shares))
|
||||
already := map[string]bool{}
|
||||
for _, sh := range shares {
|
||||
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
|
||||
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
|
||||
// deling, den kom fra. En knap, der lover det første og gør det andet, er
|
||||
// værre end ingen knap.
|
||||
out = append(out, noteShareJSON{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
Linked: via[sh.User.ID] != "",
|
||||
Via: via[sh.User.ID],
|
||||
})
|
||||
already[sh.User.ID] = true
|
||||
}
|
||||
@@ -103,20 +129,86 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
|
||||
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
|
||||
// den samme indbakke, og ingen af dem til at trække tilbage.
|
||||
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note invites", err)
|
||||
return
|
||||
}
|
||||
pending := make([]noteInviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"shares": out,
|
||||
"candidates": cand,
|
||||
"invites": pending,
|
||||
"follows": followsOf(following, n.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
|
||||
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
|
||||
// følger med" er det samme, hvem det så end deles med.
|
||||
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
|
||||
seen := map[string]bool{}
|
||||
out := []followJSON{}
|
||||
for _, l := range all {
|
||||
if l.ViaID != noteID || seen[l.NoteID] {
|
||||
continue
|
||||
}
|
||||
seen[l.NoteID] = true
|
||||
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type followJSON struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
User personJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
|
||||
// fordi nogen har delt netop den. `via` er den note, der peger.
|
||||
Linked bool `json:"linked"`
|
||||
Via string `json:"via,omitempty"`
|
||||
}
|
||||
|
||||
type shareNoteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
// Email deles der med, når personen ikke er at finde i listen — enten fordi
|
||||
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
|
||||
// finde endnu.
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
|
||||
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
|
||||
// dukker op.
|
||||
type noteInviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
//
|
||||
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
|
||||
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
|
||||
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
|
||||
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
|
||||
//
|
||||
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
|
||||
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
|
||||
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
|
||||
// vent på at de opretter sig, find noten frem, del den.
|
||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareNoteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
@@ -140,25 +232,133 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
|
||||
me := userFrom(r.Context())
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
|
||||
if req.UserID == "" && email == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
|
||||
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
|
||||
// — samme regel som en projektinvitation følger.
|
||||
if req.UserID == "" {
|
||||
if !strings.Contains(email, "@") {
|
||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||
return
|
||||
}
|
||||
if email == store.NormalizeEmail(me.Email) {
|
||||
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
|
||||
return
|
||||
}
|
||||
existing, err := s.db.UserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
s.inviteToNote(w, r, n, email, role)
|
||||
return
|
||||
}
|
||||
req.UserID = existing.ID
|
||||
}
|
||||
|
||||
// The recipient must be a real account and not the sharer themselves; ShareNote
|
||||
// refuses the owner, and a made-up id should not reach it.
|
||||
if req.UserID == userFrom(r.Context()).ID {
|
||||
if req.UserID == me.ID {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
||||
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.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
s.notifyNoteShared(r, n, req.UserID)
|
||||
|
||||
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
||||
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
||||
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"follows": followsOf(followed, n.ID),
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
|
||||
// inviteToNote sends a link that creates an account and the share at once.
|
||||
//
|
||||
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
|
||||
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
|
||||
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
|
||||
// på den.
|
||||
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
|
||||
me := userFrom(r.Context())
|
||||
|
||||
// En anden invitation til den samme note i den samme indbakke er ikke en
|
||||
// invitation mere: det er to links, der gør det samme, og kun det ene kan
|
||||
// trækkes tilbage ad gangen.
|
||||
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
|
||||
if err != nil {
|
||||
s.internal(w, r, "pending note invite", err)
|
||||
return
|
||||
}
|
||||
if pending {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
|
||||
return
|
||||
}
|
||||
|
||||
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create note invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
|
||||
emailed := false
|
||||
if s.mail.Configured() {
|
||||
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
|
||||
s.log.Error("send note invite", "err", err, "to", email)
|
||||
} else {
|
||||
emailed = true
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
|
||||
"link": link,
|
||||
"emailed": emailed,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteNoteInvite withdraws an invitation before it is used.
|
||||
//
|
||||
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
|
||||
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
|
||||
// kan kun rækken, det hører til.
|
||||
func (s *Server) handleDeleteNoteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
|
||||
s.storeError(w, r, "delete note invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -177,5 +377,12 @@ func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "unshare note", err)
|
||||
return
|
||||
}
|
||||
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
|
||||
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
|
||||
// bliver stående, og en, der ikke kan, forsvinder.
|
||||
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
@@ -193,7 +193,104 @@ func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
|
||||
// De noter, en delt note peger på, følger med — og holder op igen.
|
||||
//
|
||||
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
|
||||
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
|
||||
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
|
||||
// må følge med, og vejen tilbage.
|
||||
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
|
||||
deep := owner.createNote(t, "# Leveringstider\nfire dage")
|
||||
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
|
||||
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
|
||||
|
||||
reachable := func(id string) bool {
|
||||
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
|
||||
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
|
||||
// til at overskue.
|
||||
follows, _ := out["follows"].([]any)
|
||||
if len(follows) != 2 {
|
||||
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
|
||||
}
|
||||
|
||||
if !reachable(linked) {
|
||||
t.Error("noten, den delte note peger på, fulgte ikke med")
|
||||
}
|
||||
if !reachable(deep) {
|
||||
t.Error("kæden stoppede efter ét led")
|
||||
}
|
||||
if reachable(apart) {
|
||||
t.Error("en note, ingen peger på, blev delt")
|
||||
}
|
||||
|
||||
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
|
||||
// samme sted hen for begge.
|
||||
senere := owner.createNote(t, "# Rabatter\nti procent")
|
||||
if reachable(senere) {
|
||||
t.Fatal("en helt ny note var delt, før noget pegede på den")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("kunne ikke skrive videre i den delte note")
|
||||
}
|
||||
if !reachable(senere) {
|
||||
t.Error("et link skrevet efter delingen fulgte ikke med")
|
||||
}
|
||||
|
||||
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unshare: status %d", resp.StatusCode)
|
||||
}
|
||||
if reachable(linked) || reachable(deep) || reachable(senere) {
|
||||
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
|
||||
}
|
||||
}
|
||||
|
||||
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
|
||||
//
|
||||
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
|
||||
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
|
||||
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
|
||||
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
|
||||
noteID := owner.createNote(t, "# Fælles\nintet link her")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("share as editor failed")
|
||||
}
|
||||
|
||||
// Hun skriver linket selv, i den note hun har fået lov at rette i.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("medredaktøren kunne ikke skrive")
|
||||
}
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
|
||||
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type notificationJSON struct {
|
||||
ActorName string `json:"actor_name,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
NoteID string `json:"note_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, n := range list {
|
||||
out = append(out, notificationJSON{
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
||||
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
|
||||
}
|
||||
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Detached from the request: a push round trip to Google or Mozilla has no
|
||||
@@ -102,6 +104,113 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
||||
}
|
||||
}
|
||||
|
||||
// notifyAssigned tells somebody a task is now theirs.
|
||||
//
|
||||
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
|
||||
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
|
||||
// hvad de lige har gjort.
|
||||
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
|
||||
if t == nil || t.AssigneeID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
|
||||
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyNoteShared tells somebody that a note is now theirs to read.
|
||||
//
|
||||
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
|
||||
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
|
||||
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
|
||||
//
|
||||
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
|
||||
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
|
||||
// ikke en ny note at læse.
|
||||
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
|
||||
if n == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// it — folded into one line per note while it is unread.
|
||||
//
|
||||
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
|
||||
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
|
||||
//
|
||||
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
||||
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
||||
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
||||
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
title := actor.Name + " rettede en note"
|
||||
body := n.Title
|
||||
for _, userID := range audience {
|
||||
if userID == actor.ID || except[userID] {
|
||||
continue
|
||||
}
|
||||
// Fold first, and only write a row when there was nothing to fold into.
|
||||
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
|
||||
// hvor klokken siger to.
|
||||
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
|
||||
if err != nil {
|
||||
s.log.Warn("fold note notification", "err", err, "note", n.ID)
|
||||
continue
|
||||
}
|
||||
if folded {
|
||||
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
|
||||
// op og siger nu et andet navn. Uden det her opdager en åben fane først
|
||||
// den sammenlagte rettelse ved næste genindlæsning.
|
||||
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.changed", Title: title, Body: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||
// which has the schedule but not the keys.
|
||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
@@ -152,7 +261,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
|
||||
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
|
||||
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
|
||||
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
|
||||
// stadig en besked.
|
||||
unread, err := s.db.UnreadNotificationCount(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Warn("unread count for push", "err", err)
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// notifications reads somebody's bell.
|
||||
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list notifications: status %d", resp.StatusCode)
|
||||
}
|
||||
list, _ := out["notifications"].([]any)
|
||||
unread, _ := out["unread"].(float64)
|
||||
return list, unread
|
||||
}
|
||||
|
||||
func firstOf(t *testing.T, list []any) map[string]any {
|
||||
t.Helper()
|
||||
if len(list) == 0 {
|
||||
t.Fatal("ingen beskeder")
|
||||
}
|
||||
n, _ := list[0].(map[string]any)
|
||||
return n
|
||||
}
|
||||
|
||||
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
|
||||
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
projectID, _ := project["id"].(string)
|
||||
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
|
||||
// medlem med det samme.
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
|
||||
t.Fatalf("invitér: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, task := owner.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"project_id": projectID, "content": "ring til revisor"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("opret opgave: %d", resp.StatusCode)
|
||||
}
|
||||
taskID, _ := task["id"].(string)
|
||||
|
||||
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
|
||||
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
|
||||
before, _ := notifications(t, other)
|
||||
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tildel: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
list, _ := notifications(t, other)
|
||||
if len(list) != len(before)+1 {
|
||||
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "assigned" {
|
||||
t.Errorf("kind = %v, want assigned", n["kind"])
|
||||
}
|
||||
if n["task_id"] != taskID {
|
||||
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
|
||||
}
|
||||
if n["actor_name"] != "Kristian" {
|
||||
t.Errorf("actor_name = %v", n["actor_name"])
|
||||
}
|
||||
|
||||
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
|
||||
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
|
||||
// ændring, gør — og så bliver klokken ubrugelig på en dag.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ret: %d", resp.StatusCode)
|
||||
}
|
||||
if now, _ := notifications(t, other); len(now) != len(before)+1 {
|
||||
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
|
||||
}
|
||||
|
||||
// Og den, der tildeler sig selv, får ingenting.
|
||||
me := userID(t, owner, "kristian@example.dk")
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
|
||||
}
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
|
||||
// så længe den ikke er læst.
|
||||
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
|
||||
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
|
||||
// nogen retter i den. Den tælles med i alt, hvad der følger.
|
||||
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
|
||||
firstOf(t, list)["kind"] != "note.shared" {
|
||||
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
|
||||
}
|
||||
|
||||
// Ejeren skriver. Sofie skal høre det.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
list, unread := notifications(t, other)
|
||||
if unread != 2 || len(list) != 2 {
|
||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
|
||||
len(list), unread)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
|
||||
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
|
||||
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
|
||||
// ens linjer i klokken.
|
||||
for i := 0; i < 3; i++ {
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten igen")
|
||||
}
|
||||
}
|
||||
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
|
||||
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
|
||||
len(list))
|
||||
}
|
||||
|
||||
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
|
||||
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatal("markér som læst")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten efter læsningen")
|
||||
}
|
||||
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
|
||||
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
|
||||
}
|
||||
|
||||
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
|
||||
}
|
||||
|
||||
// Og den anden vej: Sofie skriver, ejeren hører det.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("Sofie retter")
|
||||
}
|
||||
list, _ = notifications(t, owner)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
|
||||
}
|
||||
if firstOf(t, list)["actor_name"] != "Sofie" {
|
||||
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
|
||||
}
|
||||
|
||||
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("stjernemarkér")
|
||||
}
|
||||
if list, _ := notifications(t, other); len(list) != 3 {
|
||||
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// En note, ingen deler, er ingen at give besked.
|
||||
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
|
||||
noteID := owner.createNote(t, "# Min egen\nintet")
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
if list, _ := notifications(t, other); len(list) != 0 {
|
||||
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
|
||||
}
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
@@ -452,22 +452,100 @@ type memberJSON struct {
|
||||
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) {
|
||||
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 {
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
already := map[string]bool{}
|
||||
for _, m := range members {
|
||||
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 {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||
// 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 {
|
||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
||||
Emailed bool `json:"emailed"`
|
||||
}
|
||||
|
||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
||||
// anybody else gets a link that creates their account and their membership at once.
|
||||
// handleInvite shares a project with somebody.
|
||||
//
|
||||
// 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) {
|
||||
var req inviteRequest
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
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"})
|
||||
return
|
||||
}
|
||||
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Somebody who already has an account is added straight away: sending them
|
||||
// through a signup form they cannot complete would be a dead end.
|
||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
if existing.ID == project.OwnerID {
|
||||
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||
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")
|
||||
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)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
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 {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -66,6 +67,7 @@ type Server struct {
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
hookLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
@@ -76,6 +78,20 @@ type Server struct {
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
// The shell and the manifest with this edition's name in them, or nil to serve
|
||||
// the built files unchanged. Only the notes edition needs them; see shell.go.
|
||||
shell []byte
|
||||
manifest []byte
|
||||
|
||||
// Icon paths this edition serves from another file. Empty in the full edition.
|
||||
assets map[string]string
|
||||
|
||||
// The second door: /urd and its own web manifest. Built on every instance,
|
||||
// whatever the edition, because it is how the notes face is installed as its
|
||||
// own app rather than a property of the instance. See shell.go.
|
||||
urdShell []byte
|
||||
urdManifest []byte
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -85,7 +101,7 @@ type Server struct {
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
@@ -93,14 +109,60 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||
// on the one public write path that has none.
|
||||
mailLimiter: newLimiter(60, time.Minute),
|
||||
hookLimiter: newLimiter(60, time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
//
|
||||
// A failure here is logged rather than fatal, and that is a judgement about
|
||||
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
|
||||
// refuses to start, because serving task routes on an instance set up without
|
||||
// them is a different kind of wrong. A shell whose title says the sibling's
|
||||
// name is cosmetic — it must be loud, and it must not take the instance down.
|
||||
// What makes it loud is not this line: the end-to-end suite reads the served
|
||||
// title, so an anchor that falls out of app.html goes red there.
|
||||
if web != nil && cfg.NotesOnly() {
|
||||
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
|
||||
if err != nil {
|
||||
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
|
||||
"edition", cfg.Edition, "err", err)
|
||||
} else {
|
||||
s.shell, s.manifest = shell, manifest
|
||||
}
|
||||
|
||||
s.assets = resolveNotesAssets(web, func(name, alias string) {
|
||||
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
|
||||
"path", name, "wanted", alias)
|
||||
})
|
||||
}
|
||||
|
||||
// The second door, on every edition. A notes instance already calls itself urd
|
||||
// at the root; the door is still built there so the two addresses behave the
|
||||
// same, and so a single test does not have to know which edition it is in.
|
||||
if web != nil {
|
||||
urdShell, urdManifest, err := urdDoorAssets(web)
|
||||
if err != nil {
|
||||
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
|
||||
} else {
|
||||
s.urdShell, s.urdManifest = urdShell, urdManifest
|
||||
}
|
||||
}
|
||||
|
||||
if s.shell != nil {
|
||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||
} else {
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
}
|
||||
// The second argument is WebAuthn's display name, not its relying party id —
|
||||
// the id comes from BaseURL. So this is what the operating system's passkey
|
||||
// prompt calls the site, and the slug keeps it identical to what every existing
|
||||
// verdande instance already registered.
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
@@ -123,13 +185,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The same question as inside /api/v1, asked again here because four of the
|
||||
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
|
||||
// hook, and the well-known path CalDAV clients discover through. They are out
|
||||
// here because their callers cannot hold a session, not because they are any
|
||||
// less about tasks — and the first gate missed all four for exactly that
|
||||
// reason. It read as complete because the /tasks tree was covered.
|
||||
full := !s.cfg.NotesOnly()
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
//
|
||||
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
|
||||
if full {
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
//
|
||||
// Withheld rather than left to answer an empty collection: the well-known
|
||||
// path is a claim that a CalDAV server is here, and a client that believes
|
||||
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
|
||||
// broken calendar server on an instance that has none and needs none. Same
|
||||
// reasoning as the /.well-known/ 404 in serveWeb.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
}
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
@@ -143,6 +223,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/inbound/mail",
|
||||
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||
|
||||
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
|
||||
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
|
||||
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
|
||||
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
|
||||
//
|
||||
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
|
||||
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
|
||||
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
//
|
||||
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
|
||||
// i indbakken — så den findes ikke i noter-udgaven.
|
||||
if full {
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
}
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||
@@ -168,7 +267,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
|
||||
if full {
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
@@ -269,6 +371,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Everything past this point needs a complete login.
|
||||
r.Group(func(r chi.Router) {
|
||||
// Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i
|
||||
// fladen — fraværende fra routeren, så en instans, der ikke er et
|
||||
// opgaveprogram, heller ikke kan bedes om at være det gennem sit API.
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
@@ -278,17 +384,26 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/mcp", s.handleMCP)
|
||||
|
||||
r.Get("/today", s.handleToday)
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
if full {
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
}
|
||||
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
if full {
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
|
||||
// findes, er en indstillingsside, der tilbyder en adresse, intet
|
||||
// svarer på.
|
||||
if full {
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
}
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotifications)
|
||||
@@ -314,11 +429,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateComment)
|
||||
r.Delete("/", s.handleDeleteComment)
|
||||
})
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
if full {
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
}
|
||||
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
|
||||
r.Get("/hook-url", s.handleGetHookURL)
|
||||
r.Post("/hook-url/rotate", s.handleRotateHookURL)
|
||||
|
||||
// Personal API tokens. Behind requireSession rather than the ambient
|
||||
// auth: a token must not be able to mint or revoke another.
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
@@ -332,7 +452,28 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
if full {
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
}
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
if full {
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
}
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
r.Post("/ask", s.handleAIAsk)
|
||||
})
|
||||
|
||||
r.Get("/version", s.handleVersion)
|
||||
@@ -353,6 +494,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
// En invitation til en note er en deling, der venter på en konto.
|
||||
// Den kan tages tilbage indtil den bruges; se handleShareNote.
|
||||
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
@@ -410,33 +554,53 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
|
||||
// frem for at fejle, hvilket er den stille version af samme fejl.
|
||||
if full {
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
if full {
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
}
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||
// overskrifter i.
|
||||
if full {
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
}
|
||||
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
}
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
if full {
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
}
|
||||
r.Get("/people", s.handlePeople)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
@@ -452,14 +616,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireProject(store.RoleViewer))
|
||||
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
// Sektioner grupperer opgaver inde i et projekt, og det er
|
||||
// afgjort af skemaet frem for af navnet: `section_id` står på
|
||||
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
|
||||
// kan ikke indeholde noget.
|
||||
if full {
|
||||
r.Get("/sections", s.handleListSections)
|
||||
}
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
r.Get("/activity", s.handleActivity)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
if full {
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
}
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -467,8 +642,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateProject)
|
||||
r.Delete("/", s.handleDeleteProject)
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -490,6 +665,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
|
||||
// gruppe som resten: den, der kan slå beaconet fra, er den, der
|
||||
// skal have beskeden, og at spørge alle andre ville være at
|
||||
// stille et spørgsmål, de ikke kan svare på.
|
||||
r.Get("/beacon/notice", s.handleBeaconNotice)
|
||||
r.Post("/beacon/notice", s.handleAckBeaconNotice)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
@@ -528,32 +709,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
if full {
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -621,7 +806,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
|
||||
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -630,6 +815,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
name = "index.html"
|
||||
}
|
||||
|
||||
// The second door, before anything else touches the filesystem: /urd is not a
|
||||
// file, so the fallback below would hand back the other door's shell.
|
||||
if b := s.door(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
// This edition's own icon, where it has one. Before the Open rather than after,
|
||||
// unlike the shell below: these are real files, so there is no fallback to get
|
||||
// in the way, and resolveNotesAssets has already checked each one exists.
|
||||
if alias, ok := s.assets[name]; ok {
|
||||
name = alias
|
||||
}
|
||||
|
||||
f, err := s.web.Open(name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// A client-side route: hand back the shell and let the app route it.
|
||||
@@ -642,6 +843,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Only here is `name` settled, and that matters: every client-side route —
|
||||
// /noter and all the rest — arrives as a path with no file behind it and is
|
||||
// resolved to index.html by the fallback above. Asking before the fallback
|
||||
// would have renamed the shell for a request to "/" and handed back the built
|
||||
// one for every other route in the app.
|
||||
//
|
||||
// These two assets never reach the filesystem: they are the built files with
|
||||
// this edition's name in them, held in memory since startup. See shell.go.
|
||||
if b := s.renamed(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
// A zero modification time tells ServeContent to send no Last-Modified and
|
||||
// to answer no conditional request — right for a shell that must never be
|
||||
// cached, and the same thing the built shell gets below.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The product name is in the built frontend as a literal in four places, because
|
||||
// all four are read before any JavaScript runs: the window title and the meta
|
||||
// description in the HTML shell, and the two names in the web manifest that decide
|
||||
// what an installed PWA is called on a home screen.
|
||||
//
|
||||
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
||||
// first script executes, the manifest is fetched by the browser rather than by us,
|
||||
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
||||
// that renamed itself after loading would show the sibling's name first and correct
|
||||
// itself a moment later, on every load, in the tab and on the install prompt.
|
||||
//
|
||||
// So the server does it, once at startup, on the way out. One frontend build, two
|
||||
// names: the build is identical in both editions and the bytes served differ.
|
||||
//
|
||||
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
||||
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody files it and
|
||||
// nothing else notices.
|
||||
type rebrand struct {
|
||||
old, new string
|
||||
}
|
||||
|
||||
// shellRebrands returns the replacements for index.html.
|
||||
//
|
||||
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
||||
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
||||
// rest of the app reads the same three, so renaming them here and nowhere else
|
||||
// would set the theme from one key and read it from another, and the only symptom
|
||||
// is the white flash that script exists to prevent. This is why the replacements
|
||||
// are written out as whole anchored strings rather than as a replace of the word
|
||||
// "verdande".
|
||||
func shellRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + description + `" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
||||
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
||||
// form, so it is passed separately rather than derived.
|
||||
func manifestRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
||||
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
||||
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
||||
}
|
||||
}
|
||||
|
||||
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
||||
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
||||
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
||||
// about which program this is.
|
||||
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
||||
out := raw
|
||||
for _, p := range pairs {
|
||||
old := []byte(p.old)
|
||||
if bytes.Count(out, old) != 1 {
|
||||
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
||||
}
|
||||
out = bytes.Replace(out, old, []byte(p.new), 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
||||
// frontend and returns them renamed for this edition.
|
||||
//
|
||||
// Both or neither: an instance serving a renamed shell beside the sibling's
|
||||
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
||||
// at until it is on their home screen.
|
||||
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// The descriptions for the notes edition. Kept beside the replacements they feed
|
||||
// rather than in the locale files, because these two strings are read by a browser
|
||||
// and a home screen before any locale has been chosen — the shell is in Danish for
|
||||
// the same reason the built app.html is.
|
||||
const (
|
||||
notesShellDescription = "urd — noter, delt."
|
||||
notesManifestDescription = "Noter, delt."
|
||||
)
|
||||
|
||||
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
||||
//
|
||||
// Nil for everything in the full edition, which is what keeps that path exactly as
|
||||
// it was: one comparison per request against a nil slice.
|
||||
func (s *Server) renamed(name string) []byte {
|
||||
switch name {
|
||||
case "index.html":
|
||||
return s.shell
|
||||
case "manifest.webmanifest":
|
||||
return s.manifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFor names the type for the two assets served from memory.
|
||||
//
|
||||
// http.ServeContent sniffs from the extension, and it gets .html right and
|
||||
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
||||
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
||||
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||
// does not appear.
|
||||
func contentTypeFor(name string) string {
|
||||
// Any .webmanifest, not the one name — there are two of them now, and the
|
||||
// second one was served as HTML for about four minutes because this read
|
||||
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
|
||||
// with the wrong type is ignored silently, and the only symptom is an install
|
||||
// prompt that does not appear.
|
||||
if strings.HasSuffix(name, ".webmanifest") {
|
||||
return "application/manifest+json"
|
||||
}
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
// notesAssetAliases maps the icon paths the shell and the manifest already point
|
||||
// at to urd's own files. Both sets are in the binary; the server decides.
|
||||
//
|
||||
// Aliased rather than renamed in the manifest, and the reason is the third entry.
|
||||
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
|
||||
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
|
||||
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
|
||||
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
|
||||
// fixes all four at once, including the favicon in `app.html`, which is not in the
|
||||
// manifest either.
|
||||
var notesAssetAliases = map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}
|
||||
|
||||
// resolveNotesAssets returns the aliases that have a file behind them, and reports
|
||||
// the ones that do not.
|
||||
//
|
||||
// Resolved once at startup rather than per request, so a missing file is one loud
|
||||
// line in the log instead of a silent fallback on every page load. The fallback is
|
||||
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
|
||||
// absence turns into a broken-image placeholder on the Updates screen — which is
|
||||
// exactly how four releases of nolimit-views shipped with no icon — so serving the
|
||||
// wrong mark beats serving none.
|
||||
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, alias := range notesAssetAliases {
|
||||
if _, err := fs.Stat(web, alias); err != nil {
|
||||
missing(name, alias)
|
||||
continue
|
||||
}
|
||||
out[name] = alias
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The second door.
|
||||
//
|
||||
// One instance, one database, one login — and two things you can install. urd is
|
||||
// reached at /urd, which serves the same single-page app under its own identity:
|
||||
// its own title, its own mark, and its own web manifest.
|
||||
//
|
||||
// The manifest is the part that matters and the part that is easy to get wrong.
|
||||
// An operating system does not install a URL, it installs a *manifest*, and the
|
||||
// identity of an installed app is the manifest's `id`. Two manifests on one origin
|
||||
// with different ids are two installable apps; two URLs sharing one manifest are
|
||||
// one app with a bookmark.
|
||||
//
|
||||
// And the shell has to be served per door rather than patched by JavaScript,
|
||||
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
|
||||
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
|
||||
// manifest's icons entirely. A head rewritten after load is a head that may or may
|
||||
// not be the one iOS read.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
|
||||
// because those two being the same string is the whole mechanism.
|
||||
const urdDoorPath = "/urd"
|
||||
|
||||
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
|
||||
// this door is: what the tab says, what a crawler reads, which manifest the browser
|
||||
// fetches, and which icons it finds without the manifest.
|
||||
//
|
||||
// Note what is NOT in here, for the same reason as in shellRebrands above: the
|
||||
// three `verdande:` localStorage keys in the inline theme script. They are storage
|
||||
// rather than a name, the rest of the app reads them, and — now more than before —
|
||||
// the two doors share an origin and therefore share that storage. Renaming them
|
||||
// here would give the two apps different themes by accident and lose everybody's
|
||||
// setting on the way.
|
||||
func urdShellRebrands() []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>urd</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + notesShellDescription + `" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="manifest" href="/manifest.webmanifest" />`,
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// urdManifest builds the second door's manifest from the one the frontend build
|
||||
// produced, rather than writing a second one by hand.
|
||||
//
|
||||
// Transformed as JSON rather than by string replacement, which the shell above
|
||||
// does: a manifest needs keys the built one does not have — `id` and `start_url`
|
||||
// — so there is nothing to anchor a replacement on, and a hand-written copy would
|
||||
// drift on the fields the two genuinely share (the colours, the display mode, the
|
||||
// language).
|
||||
//
|
||||
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
|
||||
// out even though "/" is what the spec would default to from a start_url of
|
||||
// "/urd": an app whose scope did not cover the whole origin would open its own
|
||||
// links in a browser tab the first time somebody navigated out of /urd, and that
|
||||
// is a rule nobody should have to remember while reading this.
|
||||
func urdManifest(raw []byte) ([]byte, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
|
||||
}
|
||||
|
||||
m["id"] = urdDoorPath
|
||||
m["start_url"] = urdDoorPath
|
||||
m["scope"] = "/"
|
||||
m["name"] = "urd"
|
||||
m["short_name"] = "urd"
|
||||
m["description"] = notesManifestDescription
|
||||
|
||||
icons, ok := m["icons"].([]any)
|
||||
if !ok || len(icons) == 0 {
|
||||
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
icon, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is not an object", i)
|
||||
}
|
||||
src, _ := icon["src"].(string)
|
||||
// Every icon must be one this door has a file for. Refusing an unknown one
|
||||
// is the point: an icon added to the manifest and not to this map would
|
||||
// otherwise leave urd's install prompt showing Verdande's mark, which is
|
||||
// cosmetic enough that nobody files it.
|
||||
swapped, ok := map[string]string{
|
||||
"/icon.svg": "/urd-icon.svg",
|
||||
"/icon-192.png": "/urd-icon-192.png",
|
||||
"/icon-512.png": "/urd-icon-512.png",
|
||||
}[src]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
|
||||
}
|
||||
icon["src"] = swapped
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(out, '\n'), nil
|
||||
}
|
||||
|
||||
// urdDoorAssets builds the second door from the files the frontend build produced.
|
||||
//
|
||||
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
|
||||
// that manifest is not being served is an install prompt that silently falls back
|
||||
// to the other app's identity.
|
||||
func urdDoorAssets(web fs.FS) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, urdShellRebrands())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = urdManifest(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// door returns the bytes for the second door's two paths, or nil.
|
||||
//
|
||||
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
|
||||
// below: /urd is not a file and never will be, so it has to be answered before the
|
||||
// fallback to index.html gets to it — which would serve the other door's shell
|
||||
// under this door's address, with no error anywhere.
|
||||
func (s *Server) door(name string) []byte {
|
||||
switch name {
|
||||
case "urd":
|
||||
return s.urdShell
|
||||
case "urd.webmanifest":
|
||||
return s.urdManifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// serverWithWeb builds the real router around a frontend, which newTestServerWith
|
||||
// cannot do — it passes nil for the web filesystem, because every other test here
|
||||
// is about the API. The renaming only happens when there is something to rename.
|
||||
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
Edition: edition,
|
||||
}
|
||||
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
|
||||
}
|
||||
|
||||
// The shell as the frontend build produces it, cut down to the parts that matter
|
||||
// here: the two anchors that get renamed, and the inline theme script that must
|
||||
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
|
||||
// honest against the real file.
|
||||
const builtShell = `<!doctype html>
|
||||
<html lang="da" data-theme="dark">
|
||||
<head>
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('verdande:theme');
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const builtManifest = `{
|
||||
"name": "verdande",
|
||||
"short_name": "verdande",
|
||||
"description": "Opgaver og projekter, delt.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111312",
|
||||
"theme_color": "#111312",
|
||||
"lang": "da",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
func builtWeb() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
"_app/immutable/app.js": {Data: []byte("// app")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
|
||||
//
|
||||
// The product name and the localStorage prefix are the same word in app.html, and
|
||||
// they are not the same thing: the prefix is read back by the app itself, so
|
||||
// renaming it here and nowhere else sets the theme from one key and reads it from
|
||||
// another. The symptom is the white flash on load that the inline script exists to
|
||||
// prevent — which is to say, no error, nothing in a log, and nothing a test that
|
||||
// merely checked the title would see.
|
||||
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
|
||||
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
got := string(shell)
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`content="urd — noter, delt."`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// Named values rather than "no occurrences of verdande": the three keys are
|
||||
// what has to survive, so the assertion says the three keys.
|
||||
for _, want := range []string{
|
||||
`localStorage.getItem('verdande:theme')`,
|
||||
`localStorage.getItem('verdande:look')`,
|
||||
`look !== 'verdande'`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
|
||||
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
|
||||
}
|
||||
|
||||
gotManifest := string(manifest)
|
||||
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
|
||||
if !strings.Contains(gotManifest, want) {
|
||||
t.Errorf("the manifest does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
|
||||
// files that is easy to break and impossible to notice: the policy names a hash of
|
||||
// the inline script, and the renamed shell is what gets served. If a replacement
|
||||
// ever reached inside the <script>, the hash would change, the browser would refuse
|
||||
// to run it, and the page would be blank — in production only, because the policy
|
||||
// is not enforced for a page opened from a file.
|
||||
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
|
||||
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
before := scriptHashesIn([]byte(builtShell))
|
||||
after := scriptHashesIn(shell)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
|
||||
}
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
|
||||
// happy path. A replacement that silently matched nothing leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
|
||||
// rewrite has to say so rather than return what it managed.
|
||||
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
|
||||
cases := map[string]fstest.MapFS{
|
||||
"no title": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no description": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest name": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
|
||||
},
|
||||
"no index.html at all": {
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest at all": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
},
|
||||
}
|
||||
for name, web := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
|
||||
t.Error("renaming reported success over a shell it could not rename")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
|
||||
// frozen copy of a file that has moved on.
|
||||
//
|
||||
// The anchors are exact strings in web/src/app.html, and that file is edited for
|
||||
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
|
||||
// title line or rewrites the description breaks the rename and nothing in Go would
|
||||
// know, because the Go build graph has never heard of app.html.
|
||||
//
|
||||
// Note the caching trap that comes with reading a non-Go file from a test: `go
|
||||
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
|
||||
// both workflows; a local run needs it typed.
|
||||
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../web/src/app.html")
|
||||
if err != nil {
|
||||
t.Skipf("app.html is not reachable from here: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
|
||||
// Both sets: the edition's rename and the second door's. The door was added
|
||||
// after this test and its anchors were NOT covered, which is how its fixture
|
||||
// came to be missing two <link> lines the real file has — caught only because
|
||||
// applyRebrands refuses an anchor it cannot find.
|
||||
for what, pairs := range map[string][]rebrand{
|
||||
"the notes edition's rename": shellRebrands("urd", "d"),
|
||||
"the urd door": urdShellRebrands(),
|
||||
} {
|
||||
for _, pair := range pairs {
|
||||
if n := strings.Count(src, pair.old); n != 1 {
|
||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
|
||||
n, pair.old, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||
// somebody would rename them, and if they are renamed there they must be
|
||||
// renamed in stores.svelte.js in the same commit.
|
||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||
}
|
||||
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
|
||||
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
|
||||
}
|
||||
|
||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||
// is about the renamed path; this says the other edition does not go through it.
|
||||
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
|
||||
for _, edition := range []string{"full", "notes"} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, edition, builtWeb())
|
||||
if edition == "full" {
|
||||
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
|
||||
t.Error("the full edition renamed the shell")
|
||||
}
|
||||
return
|
||||
}
|
||||
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
|
||||
t.Fatal("the notes edition did not rename the shell")
|
||||
}
|
||||
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
|
||||
t.Error("the notes edition's shell does not say urd")
|
||||
}
|
||||
if srv.renamed("_app/immutable/app.js") != nil {
|
||||
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
|
||||
func webWithIcons() fstest.MapFS {
|
||||
web := builtWeb()
|
||||
for _, name := range []string{
|
||||
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
|
||||
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
|
||||
} {
|
||||
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
|
||||
}
|
||||
return web
|
||||
}
|
||||
|
||||
// TestEachEditionServesItsOwnMark.
|
||||
//
|
||||
// Four paths, named one by one rather than counted: the favicon in app.html, the
|
||||
// manifest's two, and the one Safari looks for by name. The last is the reason
|
||||
// this is an alias rather than a rewritten manifest — Safari does not read the
|
||||
// manifest at all.
|
||||
func TestEachEditionServesItsOwnMark(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want map[string]string
|
||||
}{
|
||||
{"notes", map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}},
|
||||
// The control, and it is the half that stops this being a swap rather than
|
||||
// a choice: the full edition must resolve nothing.
|
||||
{"full", map[string]string{}},
|
||||
} {
|
||||
t.Run(tc.edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, tc.edition, webWithIcons())
|
||||
if len(srv.assets) != len(tc.want) {
|
||||
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
|
||||
}
|
||||
for path, alias := range tc.want {
|
||||
if srv.assets[path] != alias {
|
||||
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
|
||||
}
|
||||
}
|
||||
// And nothing else: an alias on a build asset would serve one file's
|
||||
// bytes under another's hashed, immutable name.
|
||||
for path := range srv.assets {
|
||||
if _, ok := tc.want[path]; !ok {
|
||||
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMissingMarkIsReportedAndFallsBack.
|
||||
//
|
||||
// The fallback is deliberate and it is the only place in this file that prefers
|
||||
// carrying on to failing: an icon's absence is a broken-image placeholder on the
|
||||
// one screen somebody reads before deciding whether to press update, so the wrong
|
||||
// mark beats no mark. What must not happen is it being quiet.
|
||||
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
|
||||
web := webWithIcons()
|
||||
delete(web, "urd-icon-512.png")
|
||||
|
||||
var reported []string
|
||||
got := resolveNotesAssets(web, func(name, alias string) {
|
||||
reported = append(reported, name+" → "+alias)
|
||||
})
|
||||
|
||||
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
|
||||
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
|
||||
}
|
||||
if _, ok := got["icon-512.png"]; ok {
|
||||
t.Error("a path with no file behind it was aliased anyway")
|
||||
}
|
||||
if got["icon.svg"] != "urd-icon.svg" {
|
||||
t.Error("one missing icon took the other three with it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheUrdDoorIsItsOwnApp.
|
||||
//
|
||||
// An operating system does not install a URL, it installs a manifest, and the
|
||||
// identity of an installed app is that manifest's `id`. So this asserts the two
|
||||
// things that decide whether /urd becomes a second icon on a home screen or just a
|
||||
// bookmark into the first one — and it asserts them as named values, because an
|
||||
// empty manifest, verdande's manifest and urd's all parse as JSON.
|
||||
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
|
||||
srv := serverWithWeb(t, "full", webWithIcons())
|
||||
|
||||
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
|
||||
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
|
||||
}
|
||||
|
||||
shell := string(srv.door("urd"))
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
// iOS reads this one by name and has historically ignored the manifest's
|
||||
// icons entirely, which is why the shell is served per door rather than
|
||||
// patched by script after load.
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
} {
|
||||
if !strings.Contains(shell, want) {
|
||||
t.Errorf("the urd door's shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// The same trap as the edition shell: the two doors share an origin and
|
||||
// therefore share localStorage, so renaming a storage key here would give the
|
||||
// two apps different themes by accident.
|
||||
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
|
||||
t.Fatalf("the urd manifest is not JSON: %v", err)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
// id is the install identity. Without it, two manifests on one origin are
|
||||
// one app with two start urls.
|
||||
"id": "/urd",
|
||||
"start_url": "/urd",
|
||||
"scope": "/",
|
||||
"name": "urd",
|
||||
"short_name": "urd",
|
||||
} {
|
||||
if m[key] != want {
|
||||
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
|
||||
}
|
||||
}
|
||||
// And the shared half, to prove it was transformed rather than written out: a
|
||||
// hand-written copy would drift on exactly these.
|
||||
var built map[string]any
|
||||
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
|
||||
_ = json.Unmarshal(raw, &built)
|
||||
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
|
||||
if _, ok := built[key]; ok && m[key] != built[key] {
|
||||
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
|
||||
}
|
||||
}
|
||||
|
||||
icons, _ := m["icons"].([]any)
|
||||
if len(icons) == 0 {
|
||||
t.Fatal("the urd manifest has no icons")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
src, _ := entry.(map[string]any)["src"].(string)
|
||||
if !strings.HasPrefix(src, "/urd-") {
|
||||
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
|
||||
}
|
||||
}
|
||||
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
|
||||
}
|
||||
|
||||
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
|
||||
// added to the built manifest and not to urd's map would otherwise leave urd's
|
||||
// install prompt showing Verdande's mark, which nobody reports.
|
||||
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
|
||||
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
|
||||
if _, err := urdManifest(raw); err == nil {
|
||||
t.Error("urdManifest accepted an icon it has no file for")
|
||||
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
|
||||
t.Errorf("the error does not name the icon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
|
||||
// by the browser with no error anywhere; the only symptom is that the install
|
||||
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
|
||||
// second door was added.
|
||||
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
|
||||
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
|
||||
if got := contentTypeFor(name); got != "application/manifest+json" {
|
||||
t.Errorf("%s is served as %q", name, got)
|
||||
}
|
||||
}
|
||||
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
|
||||
t.Errorf("index.html is served as %q", got)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = req.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
|
||||
// ind i "det her er dit nu" giver besked og den anden ikke.
|
||||
s.notifyAssigned(r, t)
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
|
||||
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
|
||||
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
|
||||
// det hver gang nogen retter en overskrift.
|
||||
wasAssigned := ""
|
||||
if req.AssigneeID != nil {
|
||||
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
||||
wasAssigned = before.AssigneeID
|
||||
}
|
||||
}
|
||||
|
||||
u := store.TaskUpdate{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||
@@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||
s.notifyAssigned(r, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -483,6 +501,18 @@ type quickAddRequest struct {
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
|
||||
// ParentID makes the new task a sub-task of an existing one. It is what a
|
||||
// pasted list uses for its indented lines, and what the sub-task field at the
|
||||
// foot of a task uses — both want the same parsing of dates, #project and
|
||||
// @labels that a top-level task gets, so they come through here rather than
|
||||
// through a second, dumber path.
|
||||
//
|
||||
// A sub-task lives in its parent's project. Saying otherwise would describe a
|
||||
// task whose parent is somewhere else, which the list views cannot draw and
|
||||
// nobody means; a "#project" on an indented line is therefore not honoured,
|
||||
// and the caller is told so the word does not just vanish.
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -499,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A parent decides the project before anything else is read: a sub-task
|
||||
// somewhere other than its parent is not a shape this program has.
|
||||
parent := (*store.Task)(nil)
|
||||
if req.ParentID != "" {
|
||||
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
parent = found
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
@@ -514,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
// The parent has the last word, and the word it overrules is reported the same
|
||||
// way an unknown project is — the task is created either way.
|
||||
if parent != nil {
|
||||
if parsed.Project != "" && projectID != parent.ProjectID {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
projectID = parent.ProjectID
|
||||
}
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
@@ -552,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parent != nil {
|
||||
t.ParentID = parent.ID
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
if err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -285,6 +286,49 @@ func TestTodayAndUpcoming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The horizon is the list's, and it is asked for per request: seven days is the
|
||||
// default, thirty is what Kommende offers for "when does this month get busy", and
|
||||
// anything past the cap is trimmed rather than refused — a client that asks for a
|
||||
// year gets a month, not a 400.
|
||||
func TestUpcomingHorizon(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "om tyve dage",
|
||||
"due_date": userDate(t, 20),
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
count := func(query string) (int, int) {
|
||||
t.Helper()
|
||||
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
|
||||
days, _ := view["days"].([]any)
|
||||
var tasks int
|
||||
for _, d := range days {
|
||||
day, _ := d.(map[string]any)
|
||||
list, _ := day["tasks"].([]any)
|
||||
tasks += len(list)
|
||||
}
|
||||
return len(days), tasks
|
||||
}
|
||||
|
||||
if n, found := count(""); n != 7 || found != 0 {
|
||||
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
|
||||
}
|
||||
if n, found := count("?days=30"); n != 30 || found != 1 {
|
||||
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
|
||||
}
|
||||
if n, _ := count("?days=365"); n != 31 {
|
||||
t.Errorf("days=365 returned %d days, want it capped at 31", n)
|
||||
}
|
||||
if n, _ := count("?days=nonsense"); n != 7 {
|
||||
t.Errorf("an unreadable days returned %d days, want the default 7", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsAcrossProjects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -1519,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
|
||||
}
|
||||
|
||||
// The whole point of import and export together: what goes in comes back out.
|
||||
|
||||
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
|
||||
//
|
||||
// The line still goes through the same parser as a top-level one — a sub-task
|
||||
// with a date is an ordinary thing to paste, and a second, dumber creation path
|
||||
// for indented lines would be a second place for "i morgen" to mean something
|
||||
// different.
|
||||
func TestQuickAddSubTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Flytte garagen",
|
||||
})
|
||||
parentID, _ := parent["id"].(string)
|
||||
if parentID == "" {
|
||||
t.Fatalf("no parent: %v", parent)
|
||||
}
|
||||
|
||||
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Rydde hylderne p1", "parent_id": parentID,
|
||||
})
|
||||
if got := child["parent_id"]; got != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", got, parentID)
|
||||
}
|
||||
// The line was parsed, not stored raw: the priority marker is gone from the
|
||||
// text and present as a priority.
|
||||
if got := child["content"]; got != "Rydde hylderne" {
|
||||
t.Errorf("content = %v, want the line without its marker", got)
|
||||
}
|
||||
if got := child["priority"]; got != float64(1) {
|
||||
t.Errorf("priority = %v, want 1", got)
|
||||
}
|
||||
|
||||
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
|
||||
otherID, _ := other["id"].(string)
|
||||
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
|
||||
})
|
||||
if got := moved["project_id"]; got == otherID {
|
||||
t.Errorf("the sub-task left its parent's project")
|
||||
}
|
||||
// And it is said out loud rather than the word just disappearing from the
|
||||
// title — the failure this reports is exactly the one that used to be silent.
|
||||
if got := moved["unknown_project"]; got != "Andetsteds" {
|
||||
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// raw fetches a download — a zip or a calendar — as bytes.
|
||||
//
|
||||
// `do` decodes JSON, which these two are not: it would report an empty body and a
|
||||
// parse error where the interesting thing is the bytes themselves.
|
||||
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body, resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
// TestExportEverythingAtOnce covers the two whole-account exports.
|
||||
//
|
||||
// The per-project links worked and were unusable at any size: fifteen projects
|
||||
// meant fifteen downloads to move your own tasks somewhere else. These two are
|
||||
// the same data without the fifteen clicks.
|
||||
//
|
||||
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
|
||||
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
|
||||
// exactly the shape an export bug takes.
|
||||
func TestExportEverythingAtOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Two projects, so the zip has to hold more than the one that happens to be
|
||||
// first — a loop that exports only the last project passes a one-project test.
|
||||
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
|
||||
firstID, _ := first["id"].(string)
|
||||
secondID, _ := second["id"].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Skifte daek", "project_id": secondID,
|
||||
})
|
||||
|
||||
t.Run("csv zip holds one file per project", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
|
||||
if ct != "application/zip" {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a zip: %v", err)
|
||||
}
|
||||
|
||||
found := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
found[f.Name] = string(text)
|
||||
}
|
||||
if len(found) < 2 {
|
||||
t.Fatalf("want a file per project, got %d: %v", len(found), found)
|
||||
}
|
||||
|
||||
// The task has to be IN the file named after its own project. A zip with the
|
||||
// right filenames and every task in the wrong one is the failure this is for.
|
||||
var sawMoms, sawDaek bool
|
||||
for name, text := range found {
|
||||
// Filenames are lower-cased by ics.Filename, so the comparison is too —
|
||||
// asserting on the stored capitalisation would be asserting on a detail
|
||||
// the exporter is free to change.
|
||||
lower := strings.ToLower(name)
|
||||
if strings.Contains(text, "Betal moms") {
|
||||
sawMoms = true
|
||||
if !strings.Contains(lower, "firma") {
|
||||
t.Errorf("Betal moms landed in %q, not in Firma", name)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
sawDaek = true
|
||||
if !strings.Contains(lower, "hjem") {
|
||||
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawMoms || !sawDaek {
|
||||
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ics holds every dated task across projects", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
|
||||
if !strings.HasPrefix(ct, "text/calendar") {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
text := string(body)
|
||||
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
|
||||
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
|
||||
}
|
||||
if !strings.Contains(text, "Betal moms") {
|
||||
t.Errorf("the dated task is missing:\n%s", text)
|
||||
}
|
||||
// Undated tasks are deliberately left out — a VTODO with no date is a line
|
||||
// most readers drop. Asserted so the omission stays a decision.
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
t.Errorf("an undated task was exported:\n%s", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
||||
// hand on a device that cannot scan.
|
||||
QR string `json:"qr"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
qrSVG, err := auth.TOTPQRSVG(uri)
|
||||
if err != nil {
|
||||
s.internal(w, r, "render totp qr", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
||||
}
|
||||
|
||||
type totpConfirmResponse struct {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"github.com/kristianwind/verdande/internal/update"
|
||||
)
|
||||
|
||||
// Besked til administratorerne, når der er kommet en ny version.
|
||||
//
|
||||
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
|
||||
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
|
||||
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
|
||||
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
|
||||
//
|
||||
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
|
||||
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
|
||||
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
|
||||
|
||||
const updateScope = "update"
|
||||
|
||||
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
|
||||
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
|
||||
// slukket, opdager det ved den første time, den er vågen.
|
||||
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
|
||||
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
|
||||
}
|
||||
|
||||
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
|
||||
// prøves uden at spørge GitHub om noget.
|
||||
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
|
||||
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
|
||||
// blive til en besked ad bagvejen.
|
||||
if status.Disabled || !status.Available || status.Latest == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, updateScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
|
||||
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
|
||||
// noget, også slået fra.
|
||||
if last, _ := stored["notified_version"].(string); last == status.Latest {
|
||||
return nil
|
||||
}
|
||||
|
||||
users, err := s.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := updateTitle(status.Latest)
|
||||
body := firstLineOf(status.Notes)
|
||||
for _, u := range users {
|
||||
if !u.IsAdmin {
|
||||
continue
|
||||
}
|
||||
s.notifyDirect(ctx, &store.Notification{
|
||||
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
|
||||
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
|
||||
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
|
||||
"notified_version": status.Latest,
|
||||
})
|
||||
}
|
||||
|
||||
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
|
||||
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
|
||||
// `kind` — se KINDS i Notifications.svelte.
|
||||
func updateTitle(version string) string {
|
||||
return "verdande " + version + " er klar"
|
||||
}
|
||||
|
||||
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
|
||||
//
|
||||
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
|
||||
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
|
||||
func firstLineOf(notes string) string {
|
||||
for _, line := range strings.Split(notes, "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
return firstWords(line, 30)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/update"
|
||||
)
|
||||
|
||||
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
|
||||
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
|
||||
// der var noget at se efter.
|
||||
func TestANewVersionTellsTheAdministrator(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
status := update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
|
||||
}
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatalf("besked om ny version: %v", err)
|
||||
}
|
||||
|
||||
list, unread := notifications(t, ts)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "update.available" {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
|
||||
// har ingen ordbog at slå sætningen op i.
|
||||
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
|
||||
t.Errorf("titel = %q", title)
|
||||
}
|
||||
|
||||
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
|
||||
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
|
||||
// noget, også slået fra.
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 1 {
|
||||
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
|
||||
}
|
||||
|
||||
// Og den næste version er en ny ting at få at vide.
|
||||
status.Latest = "v0.46.0"
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 2 {
|
||||
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
|
||||
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
|
||||
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "sofie@example.dk", "Sofie")
|
||||
|
||||
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if list, _ := notifications(t, other); len(list) != 0 {
|
||||
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 1 {
|
||||
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
|
||||
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, status := range []update.Status{
|
||||
{Current: "v0.44.0", Disabled: true},
|
||||
{Current: "v0.44.0", Available: false},
|
||||
{Current: "v0.44.0", Available: true, Latest: ""},
|
||||
} {
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatalf("%v: %v", status, err)
|
||||
}
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
|
||||
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
|
||||
// klikkes.
|
||||
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := firstOf(t, mustNotifications(t, ts))
|
||||
for _, key := range []string{"note_id", "task_id", "project_id"} {
|
||||
if v, ok := n[key]; ok && v != "" {
|
||||
t.Errorf("%s = %v, want ingenting", key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustNotifications(t *testing.T, ts *testServer) []any {
|
||||
t.Helper()
|
||||
list, _ := notifications(t, ts)
|
||||
if len(list) == 0 {
|
||||
t.Fatal("ingen beskeder")
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -51,7 +51,8 @@ type upcomingDay struct {
|
||||
Tasks []taskJSON `json:"tasks"`
|
||||
}
|
||||
|
||||
// handleUpcoming returns the next seven days, one entry per day.
|
||||
// handleUpcoming returns the next `days` days, one entry per day, seven by default
|
||||
// and at most a month.
|
||||
//
|
||||
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
|
||||
// missing from the response because nothing is due would collapse the layout and
|
||||
@@ -65,10 +66,14 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
from := start.Format("2006-01-02")
|
||||
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
|
||||
|
||||
// The row limit follows the window rather than sitting at a constant, or a
|
||||
// month asked for with the same ceiling as a week would truncate the last days
|
||||
// silently — and a day cut off the end of the answer looks exactly like a day
|
||||
// with nothing due.
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
DueFrom: from,
|
||||
DueBefore: to,
|
||||
Limit: 1000,
|
||||
Limit: 200 * days,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "upcoming", err)
|
||||
@@ -201,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||
// ind til den halvdel, resten af programmet lige har lukket.
|
||||
var tasks []store.Task
|
||||
var err error
|
||||
if !s.cfg.NotesOnly() {
|
||||
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
|
||||
@@ -49,6 +49,14 @@ type Runner struct {
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af
|
||||
// serveren af samme grund som SendBeacon: opsætningen, tidszonen og
|
||||
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
|
||||
DailyPlans func(ctx context.Context) error
|
||||
|
||||
// NotifyOfUpdates tells the administrators when a newer version is out.
|
||||
NotifyOfUpdates func(ctx context.Context) error
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
@@ -89,6 +97,13 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
// Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme
|
||||
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
|
||||
// syv er syv forskellige steder.
|
||||
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
|
||||
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
|
||||
// seks timer, og beskeden sendes én gang pr. version.
|
||||
r.every(ctx, "updates", time.Hour, r.updateNotices)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -123,6 +138,20 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *Runner) updateNotices(ctx context.Context) error {
|
||||
if r.NotifyOfUpdates == nil {
|
||||
return nil
|
||||
}
|
||||
return r.NotifyOfUpdates(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) dailyPlans(ctx context.Context) error {
|
||||
if r.DailyPlans == nil {
|
||||
return nil
|
||||
}
|
||||
return r.DailyPlans(ctx)
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
|
||||
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *store.DB) *store.User {
|
||||
|
||||
@@ -24,13 +24,19 @@ import (
|
||||
type Sender struct {
|
||||
cfg config.SMTP
|
||||
log *slog.Logger
|
||||
// appName and baseURL appear in the body of every message.
|
||||
// appName and baseURL appear in every message. appName is the edition's own
|
||||
// name — an invite from an urd instance that said "verdande" would be a letter
|
||||
// to somebody else's inbox about a program they are not being invited to.
|
||||
//
|
||||
// It was a field with a value and no reader until 2026-10-07: the three places
|
||||
// that needed it had the word written out instead, and the comment above said
|
||||
// it appeared in every message. It does now.
|
||||
appName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
|
||||
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
|
||||
}
|
||||
|
||||
// Configured reports whether mail can actually be sent. Handlers use it to decide
|
||||
@@ -38,11 +44,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
||||
what := "verdande"
|
||||
what := s.appName
|
||||
if projectName != "" {
|
||||
what = "projektet “" + projectName + "”"
|
||||
}
|
||||
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
|
||||
}
|
||||
|
||||
// SendNoteInvite is the same letter about a single note.
|
||||
//
|
||||
// The title is in the mail because it is what makes the invitation mean anything —
|
||||
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
|
||||
// somebody can decide about, where "til en note" is one they have to click a link
|
||||
// to understand. It travels no further than the address the owner typed, which is
|
||||
// the address they chose to share the note with.
|
||||
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
|
||||
what := "en note"
|
||||
if noteTitle != "" {
|
||||
what = "noten “" + noteTitle + "”"
|
||||
}
|
||||
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
|
||||
}
|
||||
|
||||
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 %s", inviterName, s.appName)
|
||||
body := fmt.Sprintf(`Hej
|
||||
|
||||
%s har inviteret dig til %s.
|
||||
@@ -52,9 +77,9 @@ Opret din konto her:
|
||||
|
||||
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
|
||||
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
@@ -69,17 +94,17 @@ Du bad om en påmindelse om:
|
||||
|
||||
%s
|
||||
|
||||
— verdande
|
||||
`, name, task, link)
|
||||
— %s
|
||||
`, name, task, link, s.appName)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
||||
subject := "Nulstil din adgangskode i verdande"
|
||||
subject := "Nulstil din adgangskode i " + s.appName
|
||||
body := fmt.Sprintf(`Hej %s
|
||||
|
||||
Der er bedt om at nulstille adgangskoden til din verdande-konto.
|
||||
Der er bedt om at nulstille adgangskoden til din %s-konto.
|
||||
|
||||
Vælg en ny adgangskode her:
|
||||
%s
|
||||
@@ -88,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
|
||||
|
||||
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, name, link, humanDuration(ttl), s.baseURL)
|
||||
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// TestEveryMessageCarriesTheEditionsOwnName.
|
||||
//
|
||||
// These three letters are the only thing this program sends to an address its
|
||||
// operator does not control, and an invitation from an urd instance that says
|
||||
// "verdande" is a letter to a stranger about a program they are not being invited
|
||||
// to. The name was a field with a value and no reader until 2026-10-07 — the three
|
||||
// places that needed it had the word written out instead — so the assertion that
|
||||
// matters is not "it says urd" but "it does not say the other one".
|
||||
//
|
||||
// Measured through the unconfigured path on purpose: with no SMTP host, send()
|
||||
// logs the whole message at warn level and returns, so the subject and the body
|
||||
// can be read as the recipient would get them without a mail server in the test.
|
||||
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
|
||||
for _, tc := range []struct{ name, other string }{
|
||||
{"verdande", "urd"},
|
||||
{"urd", "verdande"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", tc.name,
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendPasswordReset: %v", err)
|
||||
}
|
||||
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
|
||||
t.Fatalf("SendReminder: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
|
||||
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
|
||||
}
|
||||
if !strings.Contains(got, tc.name) {
|
||||
t.Errorf("no message mentions %q", tc.name)
|
||||
}
|
||||
if strings.Contains(got, tc.other) {
|
||||
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
|
||||
}
|
||||
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
|
||||
// *subject* of the sentence rather than a signature: an invitation to the instance
|
||||
// itself, with no project, reads "has invited you to <program>".
|
||||
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", "urd",
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "inviteret dig til urd") {
|
||||
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,13 @@ type Payload struct {
|
||||
Body string `json:"body,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
|
||||
//
|
||||
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
|
||||
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
|
||||
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
|
||||
// er serverens, og serveren ved det i forvejen.
|
||||
Unread int `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
|
||||
}
|
||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
||||
}
|
||||
|
||||
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
|
||||
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
|
||||
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
|
||||
// ikke lykkedes.
|
||||
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
|
||||
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(with), `"unread":3`) {
|
||||
t.Errorf("payload = %s, want the unread count in it", with)
|
||||
}
|
||||
|
||||
without, err := json.Marshal(Payload{Title: "hej"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(without), "unread") {
|
||||
t.Errorf("payload = %s, want no count when there is none to send", without)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Invite struct {
|
||||
ID string
|
||||
Email string
|
||||
ProjectID string // empty for an invite to the instance rather than to a project
|
||||
NoteID string // set when the invite is to a single note
|
||||
Role Role
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
|
||||
})
|
||||
}
|
||||
|
||||
// CreateInvite issues an invite and returns the token for the emailed link.
|
||||
// projectID may be empty, which invites somebody to the instance rather than to a
|
||||
// particular project.
|
||||
// CreateInvite issues an invite to a project and returns the token for the emailed
|
||||
// link. projectID may be empty, which invites somebody to the instance rather than
|
||||
// to a particular project.
|
||||
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
||||
if !role.Valid() {
|
||||
return "", nil, fmt.Errorf("store: %q is not a role", role)
|
||||
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
|
||||
}
|
||||
|
||||
// CreateNoteInvite issues an invite to a single note.
|
||||
//
|
||||
// Same row, same token, same link as a project invite — the difference is only
|
||||
// what the person gets when they arrive, and that is settled in AcceptInvite. A
|
||||
// second table for "invited to a note" would have meant a second signup path to
|
||||
// keep in step with this one, and the two would drift the first time either was
|
||||
// touched.
|
||||
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
||||
if noteID == "" {
|
||||
return "", nil, errors.New("store: a note invite needs a note")
|
||||
}
|
||||
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
|
||||
}
|
||||
|
||||
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
|
||||
if !inv.Role.Valid() {
|
||||
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
|
||||
}
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
inv := &Invite{
|
||||
ID: NewID(),
|
||||
Email: NormalizeEmail(email),
|
||||
ProjectID: projectID,
|
||||
Role: role,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
inv.ID = NewID()
|
||||
inv.Email = NormalizeEmail(inv.Email)
|
||||
inv.CreatedAt = now
|
||||
inv.ExpiresAt = now.Add(ttl)
|
||||
|
||||
var project any
|
||||
if projectID != "" {
|
||||
project = projectID
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
|
||||
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
|
||||
string(inv.Role), auth.HashToken(token),
|
||||
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
||||
return nil, ErrInviteInvalid
|
||||
}
|
||||
var inv Invite
|
||||
var projectID sql.NullString
|
||||
var projectID, noteID sql.NullString
|
||||
var created, expires int64
|
||||
var accepted sql.NullInt64
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
|
||||
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
|
||||
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
|
||||
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
|
||||
Scan(&inv.ID, &inv.Email, &projectID, ¬eID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInviteInvalid
|
||||
}
|
||||
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
||||
}
|
||||
|
||||
inv.ProjectID = projectID.String
|
||||
inv.NoteID = noteID.String
|
||||
inv.CreatedAt = time.Unix(created, 0).UTC()
|
||||
inv.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return &inv, nil
|
||||
@@ -144,10 +156,16 @@ type PendingInvite struct {
|
||||
Email string
|
||||
ProjectID string
|
||||
ProjectName string
|
||||
Role Role
|
||||
InvitedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
// NoteID is set for an invitation to a single note. The note is named by its
|
||||
// id and never by its title: titles are sealed in the database, and a private
|
||||
// note's first line is not something an administrator's user list should
|
||||
// spell out. That there is an account on its way is the administrator's
|
||||
// business; what it was invited to read is not.
|
||||
NoteID string
|
||||
Role Role
|
||||
InvitedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ListPendingInvites returns invites that are neither accepted nor expired.
|
||||
@@ -159,7 +177,8 @@ type PendingInvite struct {
|
||||
// able to say which it is.
|
||||
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
|
||||
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
|
||||
COALESCE(i.note_id, ''), i.role,
|
||||
COALESCE(u.name, ''), i.created_at, i.expires_at
|
||||
FROM invites i
|
||||
LEFT JOIN projects p ON p.id = i.project_id
|
||||
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
||||
for rows.Next() {
|
||||
var p PendingInvite
|
||||
var created, expires int64
|
||||
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
|
||||
&p.InvitedBy, &created, &expires); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
|
||||
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcceptInvite marks the invite used and grants the membership it promised, in one
|
||||
// AcceptInvite marks the invite used and grants what it promised, in one
|
||||
// transaction. Splitting the two would allow a signup to fail partway and leave a
|
||||
// live invite behind, which is a link that still works after it has been used.
|
||||
//
|
||||
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
|
||||
// same link produce one membership and one failure rather than two memberships.
|
||||
// same link produce one grant and one failure rather than two.
|
||||
//
|
||||
// What is granted depends on what the invite was to: a project membership, a share
|
||||
// on a single note, or — when it carries neither — nothing beyond the account the
|
||||
// caller has just created.
|
||||
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
||||
return ErrInviteInvalid
|
||||
}
|
||||
|
||||
var projectID sql.NullString
|
||||
var projectID, noteID sql.NullString
|
||||
var role Role
|
||||
var invitedBy string
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
|
||||
Scan(&projectID, &role); err != nil {
|
||||
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
|
||||
Scan(&projectID, ¬eID, &role, &invitedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if noteID.Valid && noteID.String != "" {
|
||||
// created_by is the person who invited, not the person arriving: the row
|
||||
// records who did the sharing, the same as a share made from the panel.
|
||||
//
|
||||
// The note may have been deleted between the invite and the signup. The
|
||||
// foreign key would refuse the row and take the whole signup with it, so
|
||||
// the note is looked up first and a share on a note that is gone is
|
||||
// simply not made — the account is still worth creating.
|
||||
var alive int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
|
||||
noteID.String, userID).Scan(&alive); err != nil {
|
||||
return err
|
||||
}
|
||||
if alive == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
if !projectID.Valid || projectID.String == "" {
|
||||
return nil // an invite to the instance, with no project attached
|
||||
}
|
||||
@@ -240,6 +290,164 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// --- invitationer til én note ----------------------------------------------------
|
||||
|
||||
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
|
||||
// The email address is the whole of who they are — there is no account to name
|
||||
// them by, which is the entire reason the row exists.
|
||||
type NoteInvite struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role Role `json:"role"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// ListNoteInvites is who has been invited to a note and not yet taken it up.
|
||||
//
|
||||
// The panel needs them beside the people who *are* on the note: without this, an
|
||||
// invitation sent yesterday is invisible, and the owner sends it again — a second
|
||||
// link to the same inbox, and no way to take either back.
|
||||
//
|
||||
// Expired ones are left out, the same rule the administrator's list follows: an
|
||||
// invite past its date is not something anybody can act on.
|
||||
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, email, role, created_at, expires_at
|
||||
FROM invites
|
||||
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
|
||||
ORDER BY created_at`, noteID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []NoteInvite{}
|
||||
for rows.Next() {
|
||||
var i NoteInvite
|
||||
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()
|
||||
}
|
||||
|
||||
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
|
||||
// anything else.
|
||||
//
|
||||
// The note id is part of the WHERE rather than checked beforehand: the owner has
|
||||
// been established for *this* note, and one statement that can only touch this
|
||||
// note's rows cannot be talked into deleting a project invite by id.
|
||||
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
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
|
||||
// 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) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM invites
|
||||
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
|
||||
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// --- password resets ----------------------------------------------------------
|
||||
|
||||
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MentionsIn reads the people named with an @ out of a body, and returns their
|
||||
// ids in the order they are written.
|
||||
//
|
||||
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
|
||||
// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville
|
||||
// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der
|
||||
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
|
||||
// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke.
|
||||
//
|
||||
// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang,
|
||||
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
|
||||
//
|
||||
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
|
||||
// efternavn hængende bagefter — også når begge findes som konti.
|
||||
func MentionsIn(body string, people []Person) []string {
|
||||
if body == "" || len(people) == 0 {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(body)
|
||||
|
||||
byLength := make([]Person, len(people))
|
||||
copy(byLength, people)
|
||||
sort.SliceStable(byLength, func(i, j int) bool {
|
||||
return len(byLength[i].Name) > len(byLength[j].Name)
|
||||
})
|
||||
|
||||
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
|
||||
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
|
||||
// noten ville blive delt med en, der ikke står i den.
|
||||
claimed := make([]bool, len(body))
|
||||
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
|
||||
|
||||
for _, p := range byLength {
|
||||
name := strings.ToLower(strings.TrimSpace(p.Name))
|
||||
if name == "" || p.ID == "" {
|
||||
continue
|
||||
}
|
||||
needle := "@" + name
|
||||
for at := 0; ; {
|
||||
i := strings.Index(lower[at:], needle)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += at
|
||||
at = i + len(needle)
|
||||
|
||||
end := i + len(needle)
|
||||
if overlaps(claimed, i, end) || !isMention(body, i, end) {
|
||||
continue
|
||||
}
|
||||
for j := i; j < end; j++ {
|
||||
claimed[j] = true
|
||||
}
|
||||
if _, seen := found[p.ID]; !seen {
|
||||
found[p.ID] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(found))
|
||||
for id := range found {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
|
||||
return out
|
||||
}
|
||||
|
||||
func overlaps(claimed []bool, from, to int) bool {
|
||||
for i := from; i < to && i < len(claimed); i++ {
|
||||
if claimed[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isMention er de to kanter om navnet.
|
||||
//
|
||||
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
|
||||
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
|
||||
// efter snabel-a'et.
|
||||
//
|
||||
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
|
||||
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
|
||||
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
|
||||
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
|
||||
func isMention(body string, from, to int) bool {
|
||||
runes := []rune(body[:from])
|
||||
if len(runes) > 0 {
|
||||
prev := runes[len(runes)-1]
|
||||
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
rest := body[to:]
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
next := []rune(rest)
|
||||
if next[0] == 's' || next[0] == 'S' {
|
||||
next = next[1:]
|
||||
if len(next) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMentionsIn(t *testing.T) {
|
||||
people := []Person{
|
||||
{ID: "sofie", Name: "Sofie"},
|
||||
{ID: "sofie-jensen", Name: "Sofie Jensen"},
|
||||
{ID: "kristian", Name: "Kristian"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{"ingen omtale", "En helt almindelig note om noget", nil},
|
||||
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
|
||||
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
|
||||
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
|
||||
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
|
||||
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
|
||||
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
|
||||
// med den, der står i den, og ikke også med en, der ikke gør.
|
||||
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
|
||||
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
|
||||
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
|
||||
{"e-mailadresse", "skriv til hej@Sofie.dk", nil},
|
||||
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
|
||||
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
|
||||
{"ukendt navn", "@Ingen her", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := MentionsIn(tc.body, people)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
|
||||
// tomt svar frem for et gæt på, hvad et @ mon betød.
|
||||
func TestMentionsInWithoutPeople(t *testing.T) {
|
||||
if got := MentionsIn("@Sofie hej", nil); got != nil {
|
||||
t.Errorf("got %v, want nothing to resolve against", got)
|
||||
}
|
||||
}
|
||||