Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
84ca506961 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
1dc9751a33 | ||
|
|
b81f8f67cc | ||
|
|
f62ffc8d92 | ||
|
|
73ee93deab | ||
|
|
b2fcc0b104 | ||
|
|
8dc601d09a | ||
|
|
85e769a603 | ||
|
|
0363d5f422 | ||
|
|
05b7dac713 | ||
|
|
cfd382e3df | ||
|
|
bbcff40401 | ||
|
|
01001d0980 | ||
|
|
4ac7180cda | ||
|
|
e1683ef09d | ||
|
|
f89fb21502 | ||
|
|
ec147cd1ba | ||
|
|
1f9f14099b | ||
|
|
6cf63113df | ||
|
|
e24a0fa849 | ||
|
|
1c223a2c26 | ||
|
|
a9c90ee6f1 | ||
|
|
dc4a584d5f | ||
|
|
378980de0c | ||
|
|
04c7a52a5a | ||
|
|
52df528994 | ||
|
|
2f00246d6f | ||
|
|
acec77a20c | ||
|
|
59413a8b64 | ||
|
|
cf69cac410 | ||
|
|
b0998fe524 | ||
|
|
8dfab618f7 | ||
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 | ||
|
|
e94d731c30 | ||
|
|
0aa0ec2989 | ||
|
|
21b60b007a | ||
|
|
7066e2e06e | ||
|
|
2b76a172e8 | ||
|
|
15495255ef | ||
|
|
3b9b68f4b1 | ||
|
|
7dcd475036 | ||
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c | ||
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 | ||
|
|
7f8d925299 | ||
|
|
cdab817141 | ||
|
|
2e76ced5fa | ||
|
|
711fb4838b | ||
|
|
643b30555e | ||
|
|
91671a4f97 | ||
|
|
15be5d2165 | ||
|
|
77994eff09 | ||
|
|
b4038356f0 | ||
|
|
8a87cfeb44 | ||
|
|
3e52eea2da | ||
|
|
acd6784333 | ||
|
|
3e8d609316 | ||
|
|
fcd2443007 | ||
|
|
27a7b04ff3 | ||
|
|
b6cbc6cbc2 | ||
|
|
223a1df85e | ||
|
|
c85bdd5076 | ||
|
|
28825dc7cb | ||
|
|
c930e6163e | ||
|
|
20b8c12b4a | ||
|
|
8d5dd1f1c3 |
@@ -0,0 +1,46 @@
|
||||
name: Bug
|
||||
# Gitea kalder feltet `about`, hvor GitHub kalder det `description`, og Gitea
|
||||
# *kræver* det: en skabelon uden bliver afvist frem for at blive vist. Derfor
|
||||
# ligger skabelonerne her for sig frem for at være delt med `.github/`.
|
||||
about: Something does not work
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: And what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to reproduce it
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The image tag, or the commit you built from.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: how
|
||||
attributes:
|
||||
label: How are you running it
|
||||
options:
|
||||
- As a Rune in Yggdrasil Panel
|
||||
- Docker directly
|
||||
- From source
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Anything in the logs
|
||||
description: Please remove tokens and email addresses first.
|
||||
render: text
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security issue
|
||||
url: https://gitea.nolimit.dk/kw/verdande/src/branch/main/SECURITY.md
|
||||
about: Please report vulnerabilities by email, not as a public issue.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature
|
||||
about: Suggest something
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
# Absolut adresse, ikke GitHubs `../../blob/main/…`: Gitea lægger en fil
|
||||
# under `/src/branch/<gren>/`, så den relative form peger ingen steder her.
|
||||
value: |
|
||||
Please check the [non-goals](https://gitea.nolimit.dk/kw/verdande/src/branch/main/README.md#non-goals)
|
||||
first — some things are deliberately not being built.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The problem, not the solution. What does the current version make hard?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What you had in mind
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,273 @@
|
||||
name: CI
|
||||
|
||||
# Gitea-tvillingen til .github/workflows/ci.yml. Den prøver at nå samme resultat
|
||||
# ad en anden vej, og forskellen er hele pointen med filen:
|
||||
#
|
||||
# INGEN `uses:`. Gitea Actions kan læse GitHubs format, men `DEFAULT_ACTIONS_URL`
|
||||
# står ikke i app.ini på gitea.nolimit.dk, og standarden er github.com — så et
|
||||
# `uses: actions/checkout@v4` her ville blive hentet FRA GitHub, hver gang, også
|
||||
# selvom både koden, runneren og registret er vores egne. Et workflow, der bruger
|
||||
# markedspladsen, er derfor ikke uafhængigt af GitHub; det ser bare sådan ud.
|
||||
#
|
||||
# Prisen er, at værktøjskæden skal komme et sted fra. Den kommer fra jobbets
|
||||
# container, hvilket er billigere end det lyder: `container: golang:1.26` er
|
||||
# nøjagtig det, setup-go ellers ville bruge et trin på at installere.
|
||||
#
|
||||
# Alternativerne, hvis nogen senere vil skrive filen om: sæt DEFAULT_ACTIONS_URL
|
||||
# til gitea.com, som spejler de gængse handlinger — men det flytter kun
|
||||
# afhængigheden fra én andens server til en andens, og det er en indstilling for
|
||||
# HELE instansen, altså også for alt andet, der bygger der. Denne fil rører kun
|
||||
# dette repo.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Så en kørsel kan udløses uden at skulle opfinde en commit. Det er ikke
|
||||
# bekvemmelighed: uden den er den eneste måde at få CI til at køre igen mod
|
||||
# uændret kode at skubbe noget tomt, og en tom commit i historikken er en
|
||||
# løgn om, at der skete noget.
|
||||
workflow_dispatch:
|
||||
|
||||
# Ingen `concurrency:`-blok. GitHub-udgaven aflyser en kørsel, når en nyere
|
||||
# kommer, og det er stadig det rigtige at gøre — men understøttelsen kom sent til
|
||||
# Gitea Actions, og et felt, runneren ikke kender, er en fejl ved oversættelsen
|
||||
# frem for en ignoreret linje. Den kan sættes på, når instansen er en version,
|
||||
# der kan.
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go
|
||||
runs-on: ubuntu-latest
|
||||
# Containeren ER værktøjskæden. `-race` kræver en C-oversætter, og den er med
|
||||
# i det officielle golang-image — et slankere ville koste et apt-trin.
|
||||
container:
|
||||
image: golang:1.26
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# Håndrullet, fordi actions/checkout ville blive hentet fra GitHub.
|
||||
# Det er de samme fire greb, den handling tager: init, remote, en
|
||||
# fetch af præcis den commit, der udløste kørslen, og et checkout.
|
||||
#
|
||||
# Legitimationen sættes med -c på selve fetch-kommandoen frem for i
|
||||
# remotens adresse. En adresse med token i havner i .git/config, i
|
||||
# `git remote -v` og i enhver fejlbesked, der citerer den; en header
|
||||
# gør ikke.
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files are not gofmt'd:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# -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 ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# Structural validity only. That the spec matches the *router* is checked by
|
||||
# a Go test, which is the half that actually goes stale.
|
||||
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||
|
||||
e2e:
|
||||
name: End-to-end
|
||||
runs-on: ubuntu-latest
|
||||
# node frem for golang her: Playwrights browsere trækker et lag
|
||||
# Debian-pakker, som npm-imaget er vant til at have, mens Go til gengæld er
|
||||
# fire linjer at hælde ind. Den anden vej rundt er den dyre.
|
||||
container:
|
||||
image: node:22
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Go
|
||||
run: |
|
||||
set -eu
|
||||
# Versionen læses ud af go.mod frem for at stå her. To steder at rette
|
||||
# er ét for mange, og den slags glemmes lige indtil CI bygger med en
|
||||
# anden Go end udviklerne.
|
||||
version=$(awk '/^go /{print $2; exit}' go.mod)
|
||||
arch=$(dpkg --print-architecture)
|
||||
curl -fsSL "https://go.dev/dl/go${version}.linux-${arch}.tar.gz" | tar -xz -C /usr/local
|
||||
echo /usr/local/go/bin >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
- name: Test
|
||||
working-directory: web
|
||||
run: npm run test:e2e
|
||||
|
||||
# Ingen rapport at hente ned bagefter. GitHub-udgaven lægger
|
||||
# web/playwright-report/ op som et artefakt, og det trin ER en handling fra
|
||||
# markedspladsen — der er ikke en `run:`-form af det. Går suiten i stykker
|
||||
# her, står grunden i loggen frem for i en zip. Skal rapporten tilbage, er
|
||||
# det upload-artifact spejlet ind lokalt, ikke en linje mere i denne fil.
|
||||
|
||||
docker:
|
||||
name: Docker image builds
|
||||
runs-on: ubuntu-latest
|
||||
# Ingen `container:` her: jobbet skal bruge docker-klienten og buildx, og dem
|
||||
# har runnerens eget ubuntu-image. En golang- eller node-container ville have
|
||||
# sproget og mangle værktøjet.
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# 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.
|
||||
#
|
||||
# `type=cacheonly` frem for `--load`: resultatet skal ingen steder hen, og
|
||||
# at hente et image ind i daemonen for straks at glemme det er et halvt
|
||||
# minut hver kørsel. Én arkitektur er nok til at fange en Dockerfile, der
|
||||
# ikke bygger; de to rigtige bygges på et mærkat.
|
||||
- name: Build
|
||||
run: |
|
||||
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 .
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Release
|
||||
|
||||
# Samme udløser som GitHub-workflowet: et semver-tag, og ikke andet.
|
||||
#
|
||||
# Gitea Actions læser samme format som GitHub Actions, men det er *ikke* det
|
||||
# samme sted. Filen ligger derfor for sig frem for at være delt: der er intet
|
||||
# `secrets.GITHUB_TOKEN`, registret er et andet, og der er ingen Releases-side at
|
||||
# skrive til. En delt fil ville være fire betingede led, der hver kun gælder ét
|
||||
# af de to steder.
|
||||
#
|
||||
# Og som i ci.yml: ingen `uses:`. Handlinger hentes fra github.com, så længe
|
||||
# DEFAULT_ACTIONS_URL ikke er sat — se den lange forklaring der.
|
||||
on:
|
||||
push:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build and publish image
|
||||
# `ubuntu-latest` er runnerens egen etiket (`ovh-1`, docker-mode). Ingen
|
||||
# `container:`: jobbet skal bruge docker-klienten og buildx, og dem har
|
||||
# runnerens image.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: gitea.nolimit.dk
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# setup-qemu-action i rå form. Uden den bygger buildx kun værtens egen
|
||||
# arkitektur, og arm64 er ikke valgfrit: et hjemmelab er lige så tit en
|
||||
# Raspberry Pi eller en Apple silicon-VM som en x86-kasse.
|
||||
- name: Emulation for the other architecture
|
||||
run: docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
|
||||
|
||||
- name: Builder
|
||||
run: docker buildx create --use --name release-verdande 2>/dev/null || docker buildx use release-verdande
|
||||
|
||||
# `GITEA_TOKEN` er automatisk til stede i et Actions-job og har lov at
|
||||
# skrive pakker for den, der ejer repoet. Ingen hemmelighed at sætte op.
|
||||
# --password-stdin frem for --password: det sidste ville stå i processens
|
||||
# kommandolinje, som enhver på maskinen kan læse.
|
||||
- name: Log in to the registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: printf '%s' "$TOKEN" | docker login "$REGISTRY" -u "$GITHUB_ACTOR" --password-stdin
|
||||
|
||||
# metadata-action i rå form.
|
||||
#
|
||||
# v1.4.2 udgiver 1.4.2, 1.4, 1 og latest — så en rune, der er bundet til
|
||||
# :1, får rettelser uden nogensinde at krydse et hovedversionsskifte.
|
||||
#
|
||||
# En forudgivelse (v1.4.2-rc1) får KUN sit eget nøjagtige mærkat. At lade
|
||||
# en rc overskrive :latest eller :1 ville sende den ud til alle, der troede
|
||||
# de fulgte det stabile spor — og mærkatmønsteret ovenfor lukker dem ind.
|
||||
- name: Work out the tags
|
||||
id: tags
|
||||
run: |
|
||||
set -eu
|
||||
version=${GITHUB_REF_NAME#v}
|
||||
image="$REGISTRY/$GITHUB_REPOSITORY"
|
||||
args="--tag $image:$version"
|
||||
case "$version" in
|
||||
*-*) echo "Forudgivelse: kun $version." ;;
|
||||
*)
|
||||
minor=${version%.*}
|
||||
major=${version%%.*}
|
||||
args="$args --tag $image:$minor --tag $image:$major --tag $image:latest"
|
||||
;;
|
||||
esac
|
||||
echo "args=$args" >> "$GITHUB_OUTPUT"
|
||||
echo "Mærkater: $args"
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
set -eu
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
${{ steps.tags.outputs.args }} \
|
||||
--build-arg "VERSION=$GITHUB_REF_NAME" \
|
||||
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
|
||||
--label "org.opencontainers.image.version=$GITHUB_REF_NAME" \
|
||||
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
.
|
||||
# Ingen `cache-from: type=gha`. act_runner har en cache-tjeneste, der
|
||||
# svarer på det navn, men den er ikke prøvet her, og en kold bygning,
|
||||
# der tager nogle minutter længere, er billigere end en udgivelse, der
|
||||
# fejler på noget, som ikke har med bygningen at gøre.
|
||||
|
||||
# Ingen Release oprettes.
|
||||
#
|
||||
# GitHub-workflowet laver en Release, fordi opdateringsnotitsen i appen læser
|
||||
# `releases/latest` derfra. Gitea har sit eget Releases-begreb, men notitsen
|
||||
# peger på GitHub — så en release her ville ingen læse. Skal notitsen flyttes
|
||||
# med, er det `internal/update`, der skal have en ny adresse, og det er en
|
||||
# ændring for sig. (Bemærk også, at GitHub-udgavens softprops-handling taler
|
||||
# mod api.github.com og ikke kan genbruges her.)
|
||||
@@ -67,5 +67,9 @@ node_modules.nosync/
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
|
||||
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
|
||||
GITEA-FLYTNING.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data, and with the integrations that a
|
||||
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||
and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -102,7 +103,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 |
|
||||
|
||||
@@ -80,6 +80,16 @@ func run() error {
|
||||
log.Info("notes sealed", "count", sealed)
|
||||
}
|
||||
|
||||
// Names created before the app capitalised them on the way in — "andreas"
|
||||
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||
// a blemish, not a broken instance.
|
||||
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||
log.Error("normalize user names", "err", err)
|
||||
} else if fixed > 0 {
|
||||
log.Info("user names normalized", "count", fixed)
|
||||
}
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
|
||||
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
|
||||
| `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. |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
verdande keys its login rate limit and its audit log on the caller's address, so
|
||||
that address has to be one the caller cannot forge. A forwarded-for header is
|
||||
believed only when the machine that connected is a trusted proxy; a connection
|
||||
straight off the internet is keyed on the address it really came from.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
|
||||
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
|
||||
|
||||
!!! warning "A proxy that does not overwrite the header"
|
||||
The default is safe when your proxy replaces a client-supplied
|
||||
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
|
||||
do. If yours passes the client's own header through, narrow
|
||||
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
|
||||
choose the address they are rate-limited and logged as.
|
||||
|
||||
## Sessions and links
|
||||
|
||||
| Variable | Default | What it does |
|
||||
@@ -55,7 +74,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 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Saved filters
|
||||
|
||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
||||
Todoist's, so if you are coming from there you already know it.
|
||||
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||
feel familiar if you have used query-based filters in other task apps.
|
||||
|
||||
```
|
||||
today & p1
|
||||
|
||||
@@ -11,7 +11,8 @@ of. Which is what a to-do list is.
|
||||
|
||||
## What it is for
|
||||
|
||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data. And with the integrations a
|
||||
hosted product will not build for you: a real CalDAV server, an address you can
|
||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
@@ -22,15 +23,22 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
Firma, labelled regnskab. 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.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move it.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
||||
- **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.
|
||||
|
||||
@@ -48,7 +56,9 @@ columns.*
|
||||
|
||||

|
||||
|
||||
*What you have handed to somebody else, grouped by whoever has it.*
|
||||
*What you have handed to somebody else, grouped by whoever has it — and each row
|
||||
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||
meet it.*
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -805,6 +805,28 @@ paths:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/tasks/{taskID}/snooze:
|
||||
parameters:
|
||||
- { name: taskID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [tasks]
|
||||
summary: Snooze a task until a time, or wake it.
|
||||
description: |
|
||||
Parks the task at the bottom of its list, greyed, until `until` passes —
|
||||
nothing else about it moves. An empty `until` wakes it now.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add:
|
||||
post:
|
||||
tags: [tasks]
|
||||
@@ -1228,7 +1250,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.
|
||||
@@ -3072,6 +3094,69 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares:
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Who a note is shared with, and who it can be shared with.
|
||||
description: >
|
||||
Owner only. Returns the people the note is already shared with, each with
|
||||
their role, and the candidates the owner may add — everyone on the instance
|
||||
who is not already on the note.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shares:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteShare" }
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Share a note with a person, or change the role they hold.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"204": { description: Shared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares/{userID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Take a person's access to a note away again.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: userID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Unshared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/linking/{kind}/{targetID}:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3907,6 +3992,14 @@ components:
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
shared_with_me:
|
||||
type: boolean
|
||||
description: >
|
||||
True when the reader reached this note through a direct share rather
|
||||
than by owning it or its project. What puts it in the Delt med mig group.
|
||||
owner:
|
||||
allOf: [{ $ref: "#/components/schemas/Person" }]
|
||||
description: Who the note belongs to, present on a note shared with the reader.
|
||||
links:
|
||||
type: array
|
||||
description: What the body points at, read out of the text on the way in.
|
||||
@@ -3923,6 +4016,21 @@ components:
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Person:
|
||||
type: object
|
||||
description: A user, as much of one as a name and a colour.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
avatar_color: { type: string }
|
||||
|
||||
NoteShare:
|
||||
type: object
|
||||
description: One person's direct grant on a note.
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
|
||||
Mailbox:
|
||||
type: object
|
||||
description: One connected mailbox. The password and any token are never returned.
|
||||
|
||||
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
No such image: gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
The usual cause is a **private** package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
**The package has to be readable without credentials**, and the only way to know
|
||||
is to ask the registry for a token as a stranger:
|
||||
|
||||
```bash
|
||||
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
|
||||
```
|
||||
|
||||
A 200 with a `token` in it means anyone can pull, which means the panel can. A
|
||||
401 means it cannot, whatever the package page says.
|
||||
|
||||
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
|
||||
It looks like the honest test and is not. An empty config directory does not
|
||||
stop the machine's credential helper, so the pull is quietly authenticated
|
||||
with credentials you forgot you had — and reports success for an image no
|
||||
stranger can fetch. This cost a production instance an hour of downtime: the
|
||||
image was declared public on that evidence, the rune was pointed at it, and
|
||||
the host — which had never logged in — could not pull a thing.
|
||||
|
||||
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
|
||||
turned on refuses anonymous tokens for its registry too, and no per-package
|
||||
setting overrides it. Making the source repository public does not change it
|
||||
either; that was tried.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
@@ -37,8 +55,8 @@ To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
sudo docker login gitea.nolimit.dk -u kw
|
||||
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 227 KiB |
@@ -1,14 +1,16 @@
|
||||
# Self-hosting
|
||||
# With Docker
|
||||
|
||||
verdande is one static binary and one directory. There is no database server, no
|
||||
cache, no queue and no second process.
|
||||
Running it yourself outside a panel: one static binary and one directory. There is
|
||||
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||
|
||||
## Docker
|
||||
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
The image is published from a self-hosted Gitea and is readable without
|
||||
credentials — the package is not linked to a repository, so its visibility
|
||||
follows the account rather than the (private) source repo. If it is ever set
|
||||
back to private, `docker login gitea.nolimit.dk` before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,12 @@ type Config struct {
|
||||
// ProviderCompatible and useful for a proxy in front of the others.
|
||||
BaseURL string
|
||||
Model string
|
||||
// AllowPrivate lets BaseURL be on this machine or its private network.
|
||||
//
|
||||
// Set by the caller from who is asking, never read from storage: it is the
|
||||
// answer to "may this person reach the LAN", and that answer can change after
|
||||
// the address was saved. See the handler for who gets it and why.
|
||||
AllowPrivate bool
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
@@ -73,13 +79,17 @@ type Client struct {
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
dial := safedial.Client
|
||||
if cfg.AllowPrivate {
|
||||
dial = safedial.AllowPrivate
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
// Generous: a local model on modest hardware can take a while to produce
|
||||
// its first token, and the alternative is a timeout that only ever fires
|
||||
// for the people running their own.
|
||||
// safedial, not a plain client. `base_url` is typed into a field in the
|
||||
// interface, so it is a user-supplied address — and a server that fetches a
|
||||
// safedial by default. `base_url` is typed into a field in the interface,
|
||||
// so it is a user-supplied address — and a server that fetches a
|
||||
// user-supplied address is a way to ask it to reach things the caller
|
||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||
@@ -87,7 +97,13 @@ func New(cfg Config) *Client {
|
||||
// The check is on the resolved address rather than on the URL, because a
|
||||
// name answers whatever its owner says — and can answer differently the
|
||||
// second time, after a parse-time check has passed.
|
||||
http: safedial.Client(120 * time.Second),
|
||||
//
|
||||
// And then the exception, which is the whole reason this provider exists:
|
||||
// a model you run yourself IS on that network. Blocking it made the two
|
||||
// lines above true and the feature useless — the comment about a local
|
||||
// model being slow to its first token was written for a case the dialler
|
||||
// refused to reach. AllowPrivate is granted per request, not per address.
|
||||
http: dial(120 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TOTPStep returns the step a code matched, and it must be the same step across
|
||||
// the tolerance window — that number is what makes a code single-use, so a code
|
||||
// entered a little early or late has to spend the same step, not a new one.
|
||||
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
|
||||
secret, _, err := NewTOTPSecret("verdande", "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
|
||||
code := codeAt(t, secret, now)
|
||||
|
||||
want, err := TOTPStep(secret, code, now)
|
||||
if err != nil {
|
||||
t.Fatalf("the current code did not match: %v", err)
|
||||
}
|
||||
// Verified one step late — the code is still valid, and must report the step it
|
||||
// was issued at, not the step it was checked at.
|
||||
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("code rejected one step late: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
|
||||
}
|
||||
|
||||
if _, err := TOTPStep(secret, "000000", now); err == nil {
|
||||
t.Error("a wrong code returned a step")
|
||||
}
|
||||
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
|
||||
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
|
||||
}
|
||||
}
|
||||
|
||||
// codeAt produces the code an authenticator app would display at that moment,
|
||||
// using the same settings VerifyTOTP checks against.
|
||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
@@ -49,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 {
|
||||
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TOTPStep verifies a code and returns the counter of the step it matched, so the
|
||||
// caller can refuse to accept the same step twice.
|
||||
//
|
||||
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
|
||||
// window is not enough to stop a replay: a code seen in flight stays valid for the
|
||||
// rest of its period, and the fix is to record which step was spent and never
|
||||
// accept that step or an earlier one again. That needs the number, so this walks
|
||||
// the same window ValidateCustom would — one step either side — and reports the
|
||||
// counter of the match. The comparison is constant time for the same reason the
|
||||
// password check is: a code is a secret being checked against a computed value.
|
||||
func TOTPStep(secret, code string, now time.Time) (int64, error) {
|
||||
if secret == "" {
|
||||
return 0, ErrNoTOTPSecret
|
||||
}
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
|
||||
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
|
||||
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
|
||||
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
|
||||
Period: totpPeriod,
|
||||
Skew: 0,
|
||||
Digits: totpDigits,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
return at.Unix() / totpPeriod, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
|
||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||
// and the hashes to store. Without these, losing a device means losing the account,
|
||||
// and the only fix left is editing the database by hand.
|
||||
|
||||
@@ -7,6 +7,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -80,6 +81,26 @@ type Config struct {
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
// TrustedProxies are the peers whose forwarded-for header may be believed.
|
||||
//
|
||||
// The rate limiter and the audit log key on the caller's address, and that
|
||||
// address is only as honest as wherever it was read. Read it from a header that
|
||||
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
|
||||
// every request and to write someone else's IP into the log. So the header is
|
||||
// believed only when the machine that *connected* is one of these — a reverse
|
||||
// proxy or tunnel the operator put there — and ignored when the connection came
|
||||
// straight off the internet.
|
||||
//
|
||||
// The default is "the peer is on a private network", which is exactly the shape
|
||||
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
|
||||
// host or the docker bridge, so its address is loopback or RFC1918. A directly
|
||||
// exposed instance sees a public peer, trusts nothing, and keys on the real
|
||||
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
|
||||
// or "none" to trust no proxy at all.
|
||||
TrustedProxies []netip.Prefix
|
||||
// RealIPHeader is where the client address is read from, for a trusted peer.
|
||||
RealIPHeader string
|
||||
|
||||
Dev bool
|
||||
}
|
||||
|
||||
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
|
||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
|
||||
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
|
||||
// families' link-local. A proxy an operator stands up in front of this server is
|
||||
// on one of these; a stranger connecting directly is on none.
|
||||
var defaultTrustedProxies = mustPrefixes(
|
||||
"127.0.0.0/8", "::1/128",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"fc00::/7",
|
||||
"169.254.0.0/16", "fe80::/10",
|
||||
)
|
||||
|
||||
// parseTrustedProxies reads the override. Empty keeps the private-network default;
|
||||
// the literal "none" trusts no proxy at all, for an instance that is exposed
|
||||
// directly and must never believe a forwarded header.
|
||||
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultTrustedProxies, nil
|
||||
}
|
||||
if strings.EqualFold(raw, "none") {
|
||||
return nil, nil
|
||||
}
|
||||
var out []netip.Prefix
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
|
||||
// naming one proxy does not have to know CIDR notation to do it.
|
||||
if !strings.Contains(part, "/") {
|
||||
addr, err := netip.ParseAddr(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
|
||||
}
|
||||
out = append(out, p.Masked())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
out = append(out, netip.MustParsePrefix(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||
|
||||
@@ -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 <[email protected]>",
|
||||
// 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 {
|
||||
|
||||
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||
if err == nil {
|
||||
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||
// the step here makes it single-use: a second presentation of the same code,
|
||||
// or an earlier one, finds the step already gone.
|
||||
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||
if cerr != nil {
|
||||
s.internal(w, r, "consume totp step", cerr)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code has already been used")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
// different form while locked out of their account is a poor time to
|
||||
|
||||
@@ -372,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A TOTP code works once. A code seen in flight and replayed inside its window
|
||||
// must be refused the second time, or the second factor is only as good as the
|
||||
// thirty seconds an attacker has to reuse what they captured.
|
||||
func TestTOTPCodeIsSingleUse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
secret, _ := body["secret"].(string)
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// One code, captured once and presented twice.
|
||||
code := codeFor(t, secret)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// A second login, same code, same window.
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpapi
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// HSTS følger med, når instansen kører over HTTPS — og kun der.
|
||||
//
|
||||
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
|
||||
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
|
||||
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
|
||||
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
|
||||
func TestHSTSTracksHTTPS(t *testing.T) {
|
||||
overHTTP := newTestServer(t) // BaseURL er http://localhost
|
||||
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("HSTS sat over ren HTTP: %q", got)
|
||||
}
|
||||
|
||||
overHTTPS := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
})
|
||||
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
|
||||
t.Error("ingen HSTS over HTTPS")
|
||||
} else if !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("HSTS uden includeSubDomains: %q", got)
|
||||
}
|
||||
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
|
||||
// mikrofon og placering, som appen aldrig beder om.
|
||||
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
|
||||
}
|
||||
// COOP isolerer browserkonteksten og står på hvert svar.
|
||||
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
||||
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Et bilag må ikke kunne blive læst som et dokument.
|
||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||
|
||||
@@ -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.
|
||||
@@ -360,8 +362,19 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
Description: msg.Snippet + "\n\n" + msg.Link,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// The thread, not the message: a reply arriving later is the same
|
||||
// conversation, and a second task for it would be the duplicate this
|
||||
// key exists to prevent.
|
||||
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||
}
|
||||
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
|
||||
// assistant reading the same mailbox. Mark it seen and move on;
|
||||
// it is handled, which is all this loop wanted.
|
||||
seen[id] = true
|
||||
continue
|
||||
}
|
||||
s.log.Warn("gmail create task", "err", err)
|
||||
continue
|
||||
}
|
||||
@@ -370,6 +383,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.
|
||||
//
|
||||
@@ -431,8 +467,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 <[email protected]>"},
|
||||
{"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(), "[email protected]")
|
||||
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)
|
||||
|
||||
@@ -215,20 +215,31 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The address is checked where it is typed, as well as when it is dialled.
|
||||
// The address is checked where it is typed, as well as when it is dialled. A
|
||||
// scheme other than http or https is refused outright, for everybody —
|
||||
// file:// and gopher:// are not providers.
|
||||
//
|
||||
// safedial refuses a private destination at connect time and is the actual
|
||||
// wall; this is so the person entering it hears about it now rather than
|
||||
// discovering it later as a failed summary. A scheme other than http or https
|
||||
// is refused outright — file:// and gopher:// are not providers.
|
||||
// The private-network wall has one exception, and it is the point of the
|
||||
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
||||
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
||||
// safedial refuses exactly those, so the address the field suggested in its
|
||||
// own placeholder was an address the server would not accept.
|
||||
//
|
||||
// Admins only, because the wall is there for a real reason: a server that
|
||||
// fetches a user-supplied address lets the person supplying it reach what
|
||||
// they cannot reach themselves. An admin on a self-hosted instance already
|
||||
// owns the host and everything on its network, so this grants them nothing
|
||||
// new. An invited user is a different person, and for them it stays shut.
|
||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
if !user.IsAdmin {
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.BaseURL = url
|
||||
}
|
||||
@@ -265,11 +276,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
// Worked out per request rather than stored beside the address: it answers
|
||||
// "may this person reach the private network", and somebody who is no longer
|
||||
// an admin should stop being able to. A flag written at save time would
|
||||
// outlive the role that granted it.
|
||||
allowPrivate := false
|
||||
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
|
||||
allowPrivate = true
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
AllowPrivate: allowPrivate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -719,6 +719,86 @@ func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reported from use as "the AI settings are not saved" — the second time, and a
|
||||
// different cause than the one above.
|
||||
//
|
||||
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
|
||||
// address field suggests http://localhost:11434/v1. Both of those live on the
|
||||
// private network, which safedial refuses — so the address the interface proposed
|
||||
// was the address the server rejected, and the one provider that exists for
|
||||
// running your own model could not be configured at all.
|
||||
//
|
||||
// It looked like a save that did nothing, because the 422 came back on a field
|
||||
// with nowhere to show it. Two bugs wearing one coat.
|
||||
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// The three shapes a self-hosted model actually takes: another machine on the
|
||||
// LAN, the loopback name, and the loopback address.
|
||||
for _, addr := range []string{
|
||||
"http://192.168.1.150:8080/v1",
|
||||
"http://localhost:11434/v1",
|
||||
"http://127.0.0.1:1234/v1",
|
||||
} {
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["base_url"] != "http://127.0.0.1:1234/v1" {
|
||||
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
|
||||
}
|
||||
if body["provider"] != "compatible" {
|
||||
t.Errorf("provider = %v", body["provider"])
|
||||
}
|
||||
}
|
||||
|
||||
// The wall stays up for everybody else.
|
||||
//
|
||||
// An admin on a self-hosted instance already owns the host and its network, so a
|
||||
// private destination grants them nothing they did not have. An invited user is a
|
||||
// different person, and for them the address field would be a way to find out
|
||||
// what answers on the host's network.
|
||||
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
|
||||
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1",
|
||||
"base_url": "http://192.168.1.150:8080/v1",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("an invited user was allowed to point the AI at the private network")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("the refusal did not name the field it was about: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A scheme that is not http or https is refused for everybody, admin or not.
|
||||
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("file:// was accepted as a provider address")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("no error on base_url: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSettings(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
@@ -202,8 +202,20 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
Description: msg.Snippet,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// Host and uid together: a uid is only unique within one mailbox on
|
||||
// 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),
|
||||
}
|
||||
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
|
||||
// past it and every sweep from here on stops at the same message.
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -19,21 +19,38 @@ import (
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
// Access has three ways in, and a note may have any of them at once. A note with
|
||||
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||
// note in a project is readable by everyone who can read the project and writable
|
||||
// by an editor. And a note can be shared with a person directly, which grants that
|
||||
// person a role of its own. The effective role is the most any of the three allow.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||
// on a shared project or a shared note, may do the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
// The owner and the project path, exactly as before.
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
if n.CreatedBy == user.ID {
|
||||
return true
|
||||
}
|
||||
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
// A direct share grants its role on top of whatever the above allow.
|
||||
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||
return shareRole.AtLeast(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||
// change its text; giving away access is the owner's alone, the same shape a
|
||||
// project takes where members edit but only the owner invites.
|
||||
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
@@ -206,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||
// change the text; letting them also move the note would let them file
|
||||
// somebody else's note into a project the owner is not in — and the owner,
|
||||
// who is not a member there, would lose their own note. So the move is
|
||||
// owner-only, and then only into a project the owner may write to.
|
||||
if !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||
return
|
||||
}
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||
// — may change its text, but throwing away the owner's note is the owner's to
|
||||
// do. A project editor keeps the reach they have always had over a project's
|
||||
// notes; a direct share does not extend that far.
|
||||
mayDelete := s.ownsNote(r, n)
|
||||
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||
mayDelete = perr == nil
|
||||
}
|
||||
if n == nil || !mayDelete {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
@@ -280,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||
// the second, so two notes made in the same second have no order between them —
|
||||
// and then which of two same-titled notes gets the plain name and which gets
|
||||
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||
return notes[i].ID < notes[j].ID
|
||||
}
|
||||
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// markShared fills in the two fields the list needs to show a note somebody else
|
||||
// shared with the reader: that it is such a note, and whose it is.
|
||||
//
|
||||
// Done in one pass over the page rather than per note: one query asks which of
|
||||
// these ids are shared with the reader, and one asks for the owners' names and
|
||||
// colours. A note the reader owns is never "shared with me", however it was
|
||||
// reached — the group is other people's notes, not one's own.
|
||||
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
|
||||
me := userFrom(r.Context()).ID
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.CreatedBy != me {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
|
||||
if err != nil {
|
||||
// The list is worth more than the chip: a note without its "shared" mark
|
||||
// still reads and still opens. Log nothing here — the store already did.
|
||||
return notes
|
||||
}
|
||||
|
||||
owners := map[string]store.Person{}
|
||||
if len(shared) > 0 {
|
||||
ownerIDs := make([]string, 0, len(shared))
|
||||
for _, n := range notes {
|
||||
if shared[n.ID] && n.CreatedBy != "" {
|
||||
ownerIDs = append(ownerIDs, n.CreatedBy)
|
||||
}
|
||||
}
|
||||
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
|
||||
}
|
||||
|
||||
for i := range notes {
|
||||
if !shared[notes[i].ID] {
|
||||
continue
|
||||
}
|
||||
notes[i].SharedWithMe = true
|
||||
if p, ok := owners[notes[i].CreatedBy]; ok {
|
||||
owner := p
|
||||
notes[i].Owner = &owner
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// handleListNoteShares is who a note is shared with. Owner only: the list of people
|
||||
// who can see your note is itself something only you should see.
|
||||
func (s *Server) handleListNoteShares(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
|
||||
}
|
||||
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note shares", err)
|
||||
return
|
||||
}
|
||||
out := make([]noteShareJSON, 0, len(shares))
|
||||
already := map[string]bool{}
|
||||
for _, sh := range shares {
|
||||
out = append(out, noteShareJSON{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
})
|
||||
already[sh.User.ID] = true
|
||||
}
|
||||
|
||||
// The picker is filled from the same response, so the panel opens complete in
|
||||
// one round-trip. People already on the note are left out — they are in the list
|
||||
// above it, not the "add someone" menu.
|
||||
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
cand := make([]personJSON, 0, len(candidates))
|
||||
for _, p := range candidates {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
User personJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type shareNoteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareNoteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
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.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if role == "" {
|
||||
role = store.RoleViewer
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleEditor {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
// 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 {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); 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 {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUnshareNote takes a person's access away again.
|
||||
func (s *Server) handleUnshareNote(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.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
|
||||
s.internal(w, r, "unshare note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createNote makes a note as the current client and returns its id.
|
||||
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create note returned no id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func userID(t *testing.T, ts *testServer, email string) string {
|
||||
t.Helper()
|
||||
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("look up %s: %v", email, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||
// somebody, and a viewer can read it but not change it.
|
||||
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
// Before sharing, Sofie cannot see it at all.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||
notes, _ := list["notes"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range notes {
|
||||
n := raw.(map[string]any)
|
||||
if n["id"] == noteID {
|
||||
found = n
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the shared note is not in the recipient's list")
|
||||
}
|
||||
if found["shared_with_me"] != true {
|
||||
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||
}
|
||||
ownerObj, _ := found["owner"].(map[string]any)
|
||||
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||
}
|
||||
|
||||
// A viewer reads it.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||
}
|
||||
// But cannot change it.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An editor may change the text, but only the owner may delete the note or hand it
|
||||
// to somebody else.
|
||||
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
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 {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
// The editor writes.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The editor cannot re-share it: giving access is the owner's alone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||
}
|
||||
// The editor cannot delete it.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("an editor deleted the owner's note")
|
||||
}
|
||||
// The note still exists for the owner.
|
||||
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharing takes access away again, and only the owner can do it.
|
||||
func TestUnshareRemovesAccess(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Hemmelig")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
// A non-owner cannot unshare.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The owner can.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||
}
|
||||
// And access is gone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The share list is owner-only and offers the instance's other people as
|
||||
// candidates, minus those already on the note.
|
||||
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
owner.newUser(t, "[email protected]", "Anders")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Plan")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
shares, _ := out["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("shares = %d, want 1", len(shares))
|
||||
}
|
||||
cands, _ := out["candidates"].([]any)
|
||||
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||
names := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
names[c.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||
}
|
||||
if names["Sofie"] {
|
||||
t.Error("somebody already on the note is offered as a candidate")
|
||||
}
|
||||
if names["Kristian"] {
|
||||
t.Error("the owner is offered as a candidate to share with themselves")
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing refuses a made-up person and the owner themselves.
|
||||
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
me := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Note")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
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 {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
return out
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
target := "/projekt/" + projectID
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
Public: public, Private: private, Subject: subject,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||
s.log.Warn("mark push delivered", "err", delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Warn, not Debug.
|
||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||
}
|
||||
|
||||
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||
//
|
||||
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||
// what the old fallback, mailto:[email protected], was on an instance with
|
||||
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||
// the person waiting for the notification could tell.
|
||||
//
|
||||
// So a real address is used when there is one, and otherwise the instance's own
|
||||
// https address — which is a valid VAPID subject, points at something that exists,
|
||||
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||
// the https URL a 201.
|
||||
func (s *Server) vapidSubject() string {
|
||||
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||
return "mailto:" + from
|
||||
}
|
||||
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||
return s.cfg.BaseURL
|
||||
}
|
||||
return "mailto:[email protected]"
|
||||
}
|
||||
|
||||
// --- subscription endpoints -----------------------------------------------------------
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||
// every push to an Apple device was refused. With no real address, the instance's
|
||||
// own https URL is used instead — valid, real, and accepted.
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
from string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"a real address is used as-is", "[email protected]", "https://verdande.example.dk", "mailto:[email protected]"},
|
||||
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||
if got := s.vapidSubject(); got != tc.want {
|
||||
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
|
||||
// and does nothing at all when the peer is not trusted.
|
||||
//
|
||||
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
|
||||
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
|
||||
// new value on every request and gets a fresh rate-limit bucket each time, and
|
||||
// writes whatever address they like into the audit log. The one control that
|
||||
// exists for when a password is already gone is keyed on this address, so it has
|
||||
// to be an address the caller cannot choose.
|
||||
//
|
||||
// The gate is the peer. A header is read only when the machine that opened the
|
||||
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
|
||||
// put there. A connection straight off the internet is trusted for nothing, so its
|
||||
// header is ignored and it is keyed on the address it actually came from. Behind a
|
||||
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
|
||||
// do) the value is then the real client and cannot be forged from outside.
|
||||
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
trusted := cfg.TrustedProxies
|
||||
header := cfg.RealIPHeader
|
||||
if header == "" {
|
||||
header = "X-Forwarded-For"
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := trustedClientIP(r, trusted, header); ip != "" {
|
||||
// Port preserved off the original peer only for shape; downstream
|
||||
// clientIP() splits it away, and the forwarded value carries none.
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// trustedClientIP returns the forwarded client address if the peer may be believed,
|
||||
// or "" to leave RemoteAddr untouched.
|
||||
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
|
||||
peer := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||
peer = host
|
||||
}
|
||||
peerAddr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(peerAddr, trusted) {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := r.Header.Get(header)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// The leftmost entry is the original client. That entry is only as trustworthy
|
||||
// as the proxy that wrote it — but the trusted proxy is exactly the one that
|
||||
// replaces a client-supplied header, so on the deployments this is written for
|
||||
// it is the real client, and on a direct connection we never reach this line.
|
||||
first := raw
|
||||
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||
first = raw[:i]
|
||||
}
|
||||
first = strings.TrimSpace(first)
|
||||
if _, err := netip.ParseAddr(first); err != nil {
|
||||
return ""
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range trusted {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The address the rate limiter keys on must be one the caller cannot choose. A
|
||||
// forwarded header is believed only from a trusted proxy, and ignored from a
|
||||
// stranger connecting directly — the difference between a control and a suggestion.
|
||||
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A private peer is a proxy the operator put there: believe it.
|
||||
name: "trusted proxy is believed",
|
||||
remoteAddr: "10.1.2.3:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "203.0.113.9",
|
||||
},
|
||||
{
|
||||
// A public peer connected straight to us; its header is a guess.
|
||||
name: "public peer header ignored",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// No header from a trusted proxy leaves the peer as the answer.
|
||||
name: "trusted peer without header",
|
||||
remoteAddr: "127.0.0.1:5000",
|
||||
xff: "",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
// A spoofed value from a stranger cannot become the key.
|
||||
name: "spoof from outside does not win",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "127.0.0.1",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||
r.RemoteAddr = tc.remoteAddr
|
||||
if tc.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != tc.want {
|
||||
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "none" turns the trust off entirely, for an instance exposed directly.
|
||||
func TestRealIPTrustNone(t *testing.T) {
|
||||
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != "10.1.2.3" {
|
||||
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
// Not middleware.RealIP: that believes a forwarded header from anyone, which
|
||||
// lets a caller pick the address the rate limiter and audit log key on. This
|
||||
// one reads the header only from a trusted proxy — see realip.go.
|
||||
r.Use(realIP(cfg))
|
||||
r.Use(requestLogger(log))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
@@ -345,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
// Sharing a note with a person directly — its own access list, beside
|
||||
// the project path. Owner only; see notes_share_handlers.go.
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
@@ -543,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -665,6 +674,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy", s.csp)
|
||||
// The session lives in a __Host- cookie that only exists over HTTPS, so the
|
||||
// whole security model already assumes TLS. HSTS is what makes that
|
||||
// assumption hold against a network attacker: without it, the very first
|
||||
// navigation can be stripped to http:// and the cookie handed over in the
|
||||
// clear. Gated on the same condition as the cookie's Secure flag, so a
|
||||
// plain-HTTP dev instance is never told to demand a certificate it cannot
|
||||
// present. Two years, subdomains included; no preload directive, because
|
||||
// asking to be hard-coded into browsers is a decision the operator makes
|
||||
// for their domain, not one a self-hosted app makes for them.
|
||||
if s.secureCookies() {
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
// verdande uses none of the powerful browser features, so the safest policy
|
||||
// is to turn them all off: an injected element cannot ask for the camera,
|
||||
// the microphone or the location if the page itself has already declined
|
||||
// them. Named explicitly rather than with a wildcard because the wildcard
|
||||
// form of this header is not universally understood, and a directive a
|
||||
// browser ignores is not a control.
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
|
||||
"payment=(), usb=(), interest-cohort=()")
|
||||
// Isolate the browsing context: a window this page opened, or one that
|
||||
// opened it, cannot keep a handle across the origin boundary. verdande
|
||||
// never scripts another window, so there is nothing to lose and a whole
|
||||
// class of cross-window attacks to close.
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,23 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type taskJSON struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
if t.CompletedAt != nil {
|
||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.SnoozedUntil != nil {
|
||||
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type snoozeRequest struct {
|
||||
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||
// but when the task wants to be seen — its due date and everything else stay put.
|
||||
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req snoozeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
var until *time.Time
|
||||
if strings.TrimSpace(req.Until) != "" {
|
||||
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||
if perr != nil {
|
||||
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||
return
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||
s.storeError(w, r, "snooze task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
verb := "task.snoozed"
|
||||
if until == nil {
|
||||
verb = "task.unsnoozed"
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||
// it puts it back. The list order is what the row's dimming rides on.
|
||||
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||
aID, _ := a["id"].(string)
|
||||
|
||||
// Snooze Alfa two days out.
|
||||
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||
t.Error("the response does not carry snoozed_until")
|
||||
}
|
||||
|
||||
// Alfa now sorts after Bravo, though it was created first.
|
||||
order := func() []string {
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
var out []string
|
||||
for _, raw := range list["tasks"].([]any) {
|
||||
out = append(out, raw.(map[string]any)["content"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := order()
|
||||
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||
}
|
||||
|
||||
// Wake it, and it comes back above Bravo.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||
t.Errorf("waking left snoozed_until set: %q", s)
|
||||
}
|
||||
if got := order(); got[0] != "Alfa" {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
||||
|
||||
for _, t := range cal.Tasks {
|
||||
writeTodo(&b, t, cal.Domain)
|
||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
||||
if t.DueDatetime != nil && !t.Completed {
|
||||
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||
// VTODO — which is all of them: Apple Calendar and Google both drop a
|
||||
// subscribed to-do on the floor and show only events. A task with a clock
|
||||
// time becomes a timed event; one with only a date, an all-day event. Once
|
||||
// this was limited to timed tasks, so a date-only task — most of them —
|
||||
// simply never appeared in the calendar it was subscribed into.
|
||||
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
|
||||
writeEvent(&b, t, cal.Domain)
|
||||
}
|
||||
}
|
||||
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
|
||||
}
|
||||
|
||||
func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
||||
// sharing a UID would make a client treat them as two versions of one item.
|
||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(end))
|
||||
|
||||
if t.DueDatetime != nil {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
|
||||
} else {
|
||||
// A date with no time is an all-day event. DTEND is exclusive — a one-day
|
||||
// event ends on the following date — which is what keeps it a single day in
|
||||
// the grid rather than bleeding into the next.
|
||||
day := strings.ReplaceAll(t.DueDate, "-", "")
|
||||
writeLine(b, "DTSTART;VALUE=DATE:"+day)
|
||||
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
|
||||
}
|
||||
|
||||
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||
if t.Description != "" {
|
||||
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
||||
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
|
||||
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
|
||||
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
|
||||
// DTEND. A malformed date is returned as its own compacted form rather than
|
||||
// dropped, so a task never loses its event over a bad string.
|
||||
func nextDay(date string) string {
|
||||
d, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(date, "-", "")
|
||||
}
|
||||
return d.AddDate(0, 0, 1).Format("20060102")
|
||||
}
|
||||
|
||||
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||
// summary at that character in every client.
|
||||
|
||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
||||
|
||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||
// or a feed is useless in the calendar most people actually keep.
|
||||
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
||||
func TestDatedTasksBecomeEvents(t *testing.T) {
|
||||
out := Render(sample())
|
||||
|
||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
||||
strings.Count(out, "BEGIN:VEVENT"))
|
||||
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||
// date and no event.
|
||||
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
|
||||
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
|
||||
}
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
||||
t.Error("the event has no start")
|
||||
|
||||
// The timed one keeps its clock and its 45-minute duration.
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the timed event's start or duration is wrong")
|
||||
}
|
||||
// 45 minutes of stated duration.
|
||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the event's end does not reflect the task's duration")
|
||||
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||
// they sit on their own day rather than bleeding into the next.
|
||||
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
|
||||
t.Error("the date-only event is not a single all-day event")
|
||||
}
|
||||
// A recurring date-only task carries its rule onto the event.
|
||||
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||
t.Error("the recurring task's event has no RRULE")
|
||||
}
|
||||
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||
if !strings.Contains(out, "UID:[email protected]") {
|
||||
|
||||
@@ -24,17 +24,58 @@
|
||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||
// wall around the inside.
|
||||
//
|
||||
// With one named exception, for Tailscale — see tailnets below.
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tailnets are Tailscale's own ranges, and they are not "the inside".
|
||||
//
|
||||
// A tailnet is not a network you happen to be on; it is one every member had to
|
||||
// authenticate to join. A verdande whose host is on one reaches its peers on
|
||||
// purpose, and the operator who joined it meant to.
|
||||
//
|
||||
// Half of this already worked, by luck rather than intent: Go's IsPrivate covers
|
||||
// RFC1918 and not the carrier-grade NAT range Tailscale borrows, so 100.x
|
||||
// addresses passed the wall without anybody deciding they should. The IPv6 half
|
||||
// did not — fd7a:115c:a1e0::/48 sits inside fc00::/7 and was refused. The same
|
||||
// machine was reachable or not depending on which family its address had, which
|
||||
// is the kind of difference nobody debugs on purpose.
|
||||
//
|
||||
// Naming both makes the allowance deliberate, and writes down its cost: on an
|
||||
// instance with people you have invited, this is a network they can now ask the
|
||||
// server to reach. That is the trade, and it is the operator's to make.
|
||||
var tailnets = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("fd7a:115c:a1e0::/48"),
|
||||
}
|
||||
|
||||
// IsTailscale reports whether an address belongs to a tailnet.
|
||||
func IsTailscale(ip net.IP) bool {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Unmap first: ParseIP gives a v4 address as 4-in-6, and a v4 prefix does not
|
||||
// contain a v6-shaped address however equal the two look when printed.
|
||||
addr = addr.Unmap()
|
||||
for _, n := range tailnets {
|
||||
if n.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||
// deliberately does not say which address it resolved to: the answer is itself
|
||||
// the thing an attacker is fishing for.
|
||||
@@ -46,8 +87,15 @@ func (e *ErrPrivate) Error() string {
|
||||
|
||||
// Blocked reports whether an address must not be dialled.
|
||||
func Blocked(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// Before the private check, not after: a tailnet's IPv6 range is unique-local,
|
||||
// so asking IsPrivate first would refuse it.
|
||||
if IsTailscale(ip) {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
|
||||
@@ -53,3 +53,47 @@ func TestAllowPrivateStillReaches(t *testing.T) {
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Tailscale skal virke, og på begge familier.
|
||||
//
|
||||
// IPv4-halvdelen slap allerede igennem, men ved et tilfælde: Gos IsPrivate
|
||||
// dækker RFC1918 og ikke det CGNAT-område, Tailscale låner. IPv6-halvdelen gjorde
|
||||
// ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
|
||||
// tilgængelig eller ej alt efter, hvilken adressefamilie man ramte den på.
|
||||
//
|
||||
// Testen står her for at holde begge dele fast: at 100.x er tilladt med vilje og
|
||||
// ikke af held, og at fd7a: ikke ryger tilbage bag muren, næste gang nogen rydder
|
||||
// op i prædikatet.
|
||||
func TestTailnetIsNotTheInside(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"100.64.0.0", "100.100.100.100", "100.127.255.255",
|
||||
"fd7a:115c:a1e0::1", "fd7a:115c:a1e0:ab12:3456:7890:abcd:ef01",
|
||||
} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en tailnet-adresse", s)
|
||||
}
|
||||
if !IsTailscale(net.ParseIP(s)) {
|
||||
t.Errorf("IsTailscale(%s) = false", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Undtagelsen skal være så smal, som den er navngivet. Naboerne til området
|
||||
// er ikke Tailscale, og en anden unique-local-adresse er stadig indenfor.
|
||||
for _, s := range []string{
|
||||
"100.63.255.255", // lige under 100.64.0.0/10
|
||||
"100.128.0.0", // lige over
|
||||
"fd00::1", // unique-local, men ikke Tailscales
|
||||
"fd7b:115c:a1e0::1",
|
||||
"192.168.1.150",
|
||||
} {
|
||||
if IsTailscale(net.ParseIP(s)) {
|
||||
t.Errorf("IsTailscale(%s) = true, men den ligger uden for området", s)
|
||||
}
|
||||
}
|
||||
// Og de af dem, der er indenfor, skal stadig være det.
|
||||
for _, s := range []string{"fd00::1", "fd7b:115c:a1e0::1", "192.168.1.150"} {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false — muren er væk for mere end Tailscale", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Kvasir: what an assistant did, and what it acted on.
|
||||
--
|
||||
-- Two things, and they are separate on purpose: one is about the outside world,
|
||||
-- and would be worth having even if Kvasir never existed.
|
||||
|
||||
-- 1. Where a task came from, out there.
|
||||
--
|
||||
-- Nothing today can answer "has this mail already become a task?". A second
|
||||
-- reader of the same mailbox — another assistant, a rerun of the same sweep,
|
||||
-- a person — makes a second task, and the only sign is the duplicate itself.
|
||||
-- That has happened in practice, twice in one run.
|
||||
--
|
||||
-- Namespaced rather than a bare id: `gmail:thread:18f2c…`, `imap:<host>:<uid>`,
|
||||
-- `ics:<uid>`. A Gmail thread id and an IMAP uid look nothing alike until the
|
||||
-- day they collide, and a namespace costs one colon. It also means the next
|
||||
-- source needs no column — which is the point of a key rather than a foreign
|
||||
-- key. There is no messages table to point at, and this has to work without one.
|
||||
ALTER TABLE tasks ADD COLUMN source_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- The index is the interlock, not an optimisation. Two agents reading the same
|
||||
-- inbox must be unable to make two tasks, and "must be unable" is a constraint
|
||||
-- rather than a convention somebody remembers to follow.
|
||||
--
|
||||
-- Scoped to the author: two people on one instance connect their own mailboxes,
|
||||
-- and the same thread reaching both of them is two tasks, correctly. Partial
|
||||
-- because `created_by` is ON DELETE SET NULL — a task outlives its author, and
|
||||
-- rows that have lost theirs must not start colliding with each other.
|
||||
CREATE UNIQUE INDEX tasks_once_per_source
|
||||
ON tasks (created_by, source_key)
|
||||
WHERE source_key <> '' AND created_by IS NOT NULL;
|
||||
|
||||
-- 2. Kvasir's ledger.
|
||||
--
|
||||
-- The dial that lets an assistant act on its own is only bearable because of
|
||||
-- this table. Two questions have to be answerable at any moment: what did it do
|
||||
-- today, and how do I undo it. Neither is answerable from the tasks themselves.
|
||||
--
|
||||
-- A suggestion and a completed action are the SAME ROW in different states. That
|
||||
-- is the whole design: "suggest" and "act" are not two code paths, they are one
|
||||
-- path that writes a different `state`. Accepting a suggestion moves it to done;
|
||||
-- undoing an action moves it to undone. Nothing has to be written twice, and the
|
||||
-- feed reads the same either way.
|
||||
--
|
||||
-- `before_json` is the undo. Not a diff and not a description — the fields as
|
||||
-- they were, so putting them back needs no reasoning about what changed. It is
|
||||
-- also the honest record: a task edited by hand afterwards will not match, and
|
||||
-- an undo that would overwrite newer work should say so rather than win.
|
||||
CREATE TABLE kvasir_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
|
||||
-- 'file', 'rewrite', 'split', 'nudge', 'prioritise'. Free text rather than a
|
||||
-- CHECK: a new capability must not need a migration to be recorded, and an
|
||||
-- unknown kind in the feed is a row nobody rendered, not a broken database.
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
|
||||
-- One sentence, written when the action was decided. Stored rather than
|
||||
-- rebuilt: it says what Kvasir believed at the time, and the project it
|
||||
-- names may since have been renamed or deleted.
|
||||
summary TEXT NOT NULL,
|
||||
-- Why it thought so. Shown on request, because a suggestion you cannot
|
||||
-- interrogate is one you either trust blindly or ignore entirely.
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
|
||||
before_json TEXT NOT NULL DEFAULT '{}',
|
||||
after_json TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
-- suggested → done | dismissed, and done → undone.
|
||||
state TEXT NOT NULL CHECK (state IN ('suggested', 'done', 'dismissed', 'undone')),
|
||||
|
||||
created_at INTEGER NOT NULL,
|
||||
decided_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- The feed asks "what is waiting for me, newest first", and the corrections
|
||||
-- Kvasir learns from ask "what did I get wrong lately" — both read by user and
|
||||
-- order by time.
|
||||
CREATE INDEX kvasir_actions_by_user ON kvasir_actions (user_id, created_at DESC);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
|
||||
-- verifier must reject a code that has already been accepted, or a code observed in
|
||||
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
|
||||
-- for the rest of its thirty-second window.
|
||||
--
|
||||
-- totp_last_step records the counter of the most recently accepted code (Unix time
|
||||
-- divided by the period). A login accepts a code only when its step is strictly
|
||||
-- greater, so the same code cannot be spent twice and time cannot run backwards.
|
||||
-- Null means no code has been accepted yet, which is every account until its owner
|
||||
-- next signs in with one.
|
||||
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Sharing a note with a person, rather than only by filing it in a shared project.
|
||||
--
|
||||
-- Until now a note was shared exactly one way: put it in a project, and everyone
|
||||
-- who could read the project could read the note. That 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" — it makes you stand up a project to hand over a single page.
|
||||
--
|
||||
-- A note_shares row is that direct grant: this note, this person, this role. It
|
||||
-- sits alongside the project path, not instead of it — a note can be loose, filed,
|
||||
-- shared with people, or any combination, and access is the most that any of those
|
||||
-- allow. Only the note's owner (its created_by) writes these rows.
|
||||
--
|
||||
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
|
||||
-- is one idea of what a role means in this program and not two.
|
||||
CREATE TABLE note_shares (
|
||||
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
||||
-- One share per person per note: sharing again changes the role, it does not
|
||||
-- add a second row that the first would then quietly disagree with.
|
||||
PRIMARY KEY (note_id, user_id)
|
||||
);
|
||||
|
||||
-- "Which notes are shared with me" is asked on every visit to the notes page, to
|
||||
-- build the Delt med mig group and to widen what the list may show. It is a lookup
|
||||
-- by user_id, so that is what carries the index.
|
||||
CREATE INDEX note_shares_by_user ON note_shares (user_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
|
||||
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
|
||||
-- then comes back on its own — no job wakes it, because "snoozed" is simply
|
||||
-- snoozed_until being still in the future, read against the clock each time.
|
||||
--
|
||||
-- Null means not snoozed, which is every task until somebody snoozes one.
|
||||
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
|
||||
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"andreas": "Andreas",
|
||||
"Andreas": "Andreas",
|
||||
"anne mette": "Anne Mette",
|
||||
" kristian ": "Kristian",
|
||||
"anne mette": "Anne Mette",
|
||||
"McDonald": "McDonald", // an existing inner capital is kept
|
||||
"óscar": "Óscar", // Unicode initial
|
||||
"æblegaard": "Æblegaard",
|
||||
"": "",
|
||||
" ": "",
|
||||
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
|
||||
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeName(in); got != want {
|
||||
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserCapitalisesName(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := &User{Email: "[email protected]", Name: "andreas", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
got, err := db.UserByEmail(ctx, "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.Name != "Andreas" {
|
||||
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUserNamesBackfill(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An account as it looked before the app capitalised names: made properly,
|
||||
// then set back to lower case behind the store's back to stand in for an old row.
|
||||
u := &User{Email: "[email protected]", Name: "Andreas", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
|
||||
t.Fatalf("downgrade name: %v", err)
|
||||
}
|
||||
|
||||
fixed, err := db.NormalizeUserNames(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if fixed != 1 {
|
||||
t.Fatalf("fixed = %d, want 1", fixed)
|
||||
}
|
||||
got, _ := db.UserByID(ctx, u.ID)
|
||||
if got.Name != "Andreas" {
|
||||
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
|
||||
}
|
||||
|
||||
// Idempotent: nothing left to do on a second pass.
|
||||
again, err := db.NormalizeUserNames(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize again: %v", err)
|
||||
}
|
||||
if again != 0 {
|
||||
t.Errorf("second pass fixed = %d, want 0", again)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||
// may do. The person is carried whole rather than by id, because every caller of
|
||||
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||
type NoteShare struct {
|
||||
User Person `json:"user"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||
// has already checked that they may.
|
||||
//
|
||||
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||
// confuse the access check.
|
||||
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||
if !role.Valid() || role == RoleOwner {
|
||||
return errors.New("store: a note can be shared as viewer or editor")
|
||||
}
|
||||
var createdBy sql.NullString
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if createdBy.String == userID {
|
||||
return errors.New("store: a note is already the owner's")
|
||||
}
|
||||
_, err := db.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, userID, role, byUserID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||
// error: the end state the caller asked for — this person cannot see this note —
|
||||
// is the same either way.
|
||||
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||
// name so the list under a note does not reshuffle every time it is opened.
|
||||
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.name, u.avatar_color, s.role
|
||||
FROM note_shares s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.note_id = ?
|
||||
ORDER BY lower(u.name)`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []NoteShare
|
||||
for rows.Next() {
|
||||
var s NoteShare
|
||||
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||
// role could not be read — so a caller does not have to treat "no share" as an
|
||||
// error.
|
||||
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||
var role Role
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !role.Valid() {
|
||||
// A role the database holds but this build does not understand is no access,
|
||||
// never a guess — the same rule project roles follow.
|
||||
return "", false, nil
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
|
||||
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||
// the user. It answers the Delt med mig question in one query rather than one per
|
||||
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||
// world to mark a single page.
|
||||
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
if len(noteIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
args := make([]any, 0, len(noteIDs)+1)
|
||||
args = append(args, userID)
|
||||
for _, id := range noteIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -37,6 +37,17 @@ type Note struct {
|
||||
// set by a caller: it is derived, and a caller that set it would be describing
|
||||
// a note whose text says something else.
|
||||
Links []NoteLink `json:"links,omitempty"`
|
||||
|
||||
// SharedWithMe is true when the person reading the list reached this note
|
||||
// through a direct share rather than by owning it or being in its project. It
|
||||
// is what puts the note in the Delt med mig group and shows whose note it is.
|
||||
// Derived per request, so it is never stored and never set by a caller.
|
||||
SharedWithMe bool `json:"shared_with_me,omitempty"`
|
||||
|
||||
// Owner is who the note belongs to — its created_by — as a name and a colour,
|
||||
// for the chip on a note shared with the reader. Set only when it helps the
|
||||
// list say something; nil the rest of the time.
|
||||
Owner *Person `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// NoteLink is one reference from a note to something else.
|
||||
@@ -263,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
|
||||
UNION
|
||||
SELECT project_id FROM project_members WHERE user_id = ?
|
||||
)
|
||||
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||
)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
|
||||
}
|
||||
|
||||
// NotesLinking answers the backwards question: what points at this thing.
|
||||
|
||||
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
||||
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||
// silently unsubscribing somebody's phone.
|
||||
// MarkPushDelivered records that a push went through: the failure count goes back
|
||||
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
||||
// few times and then recovered would keep those failures on its record and be one
|
||||
// flaky night away from the ten that delete it — even though it is working.
|
||||
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
||||
time.Now().Unix(), endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||
if permanent {
|
||||
// Not scoped by user, and that is right here: this is the push service
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One mail, one task — however many readers there are.
|
||||
//
|
||||
// Nothing could answer "has this already become a task?" before, so a second
|
||||
// reader of the same mailbox made a second task. That is not hypothetical: a
|
||||
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
|
||||
// separate assistant reading the same inbox does the same thing every sweep.
|
||||
func TestOneMailBecomesOneTask(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &User{Email: "[email protected]", Name: "Kristian", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inbox := newProject(t, db, user.ID)
|
||||
|
||||
const thread = "gmail:thread:18f2cabc"
|
||||
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||
if err := db.CreateTask(ctx, first, nil); err != nil {
|
||||
t.Fatalf("first task: %v", err)
|
||||
}
|
||||
|
||||
// The same thread again — a rerun, or somebody else's assistant.
|
||||
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||
err := db.CreateTask(ctx, second, nil)
|
||||
if !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
|
||||
}
|
||||
|
||||
// And the question that makes the other agent safe.
|
||||
id, err := db.TaskIDBySource(ctx, user.ID, thread)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != first.ID {
|
||||
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
|
||||
}
|
||||
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
|
||||
t.Errorf("an unseen thread answered %q, %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A task somebody typed has no source, and there can be any number of those.
|
||||
// The index is partial for exactly this reason — without the WHERE clause the
|
||||
// second hand-written task in the database would be refused.
|
||||
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &User{Email: "[email protected]", Name: "Kristian", PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inbox := newProject(t, db, user.ID)
|
||||
|
||||
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
|
||||
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
|
||||
t.Fatalf("task %d (%s): %v", i, name, err)
|
||||
}
|
||||
}
|
||||
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
|
||||
t.Errorf("an empty source key answered %q, %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people on one instance, each with their own mailbox. The same thread id
|
||||
// reaching both of them is two tasks, correctly — the key is scoped to whoever
|
||||
// the mail arrived for.
|
||||
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
|
||||
db := openTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var ids []string
|
||||
for _, email := range []string{"[email protected]", "[email protected]"} {
|
||||
u := &User{Email: email, Name: email, PasswordHash: "x"}
|
||||
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inbox := newProject(t, db, u.ID)
|
||||
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
|
||||
SourceKey: "gmail:thread:delt"}
|
||||
if err := db.CreateTask(ctx, task, nil); err != nil {
|
||||
t.Fatalf("%s: %v", email, err)
|
||||
}
|
||||
ids = append(ids, task.ID)
|
||||
}
|
||||
if ids[0] == ids[1] {
|
||||
t.Error("the two people got the same task")
|
||||
}
|
||||
}
|
||||
|
||||
// A project to hang the tasks on. The tests are about the source key, not about
|
||||
// where a task lives.
|
||||
func newProject(t *testing.T, db *DB, ownerID string) string {
|
||||
t.Helper()
|
||||
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
|
||||
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
return p.ID
|
||||
}
|
||||
@@ -29,9 +29,19 @@ type Task struct {
|
||||
AssigneeID string
|
||||
CompletedAt *time.Time
|
||||
CompletedBy string
|
||||
// SnoozedUntil, when set and still in the future, parks the task at the bottom
|
||||
// of its list, greyed, until it passes. Nil means the task is not snoozed.
|
||||
SnoozedUntil *time.Time
|
||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||
CreatedBy string
|
||||
// SourceKey names the thing out there this task was made from, namespaced:
|
||||
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
|
||||
//
|
||||
// It exists so that the second reader of a mailbox — another assistant, a
|
||||
// rerun, a person — can ask "has this already become a task?" and get an
|
||||
// answer. CreateTask returns ErrDuplicate rather than making a second one.
|
||||
SourceKey string
|
||||
SortOrder float64
|
||||
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||
// that there is something underneath it, and how much of it is left. Counted
|
||||
@@ -212,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
@@ -306,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
|
||||
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -383,7 +393,32 @@ func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
return "t.completed_at DESC, t.id DESC"
|
||||
}
|
||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
// A snoozed task sinks below everything else — the CASE is 1 while it is still
|
||||
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
|
||||
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
|
||||
// strftime returns would sort under every number.
|
||||
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
|
||||
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
}
|
||||
|
||||
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
|
||||
// snooze changes; the due date, the time, everything else is left alone — a snooze
|
||||
// is about when you want to see it, not when it is due.
|
||||
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
|
||||
var v any
|
||||
if until != nil {
|
||||
v = until.Unix()
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
v, time.Now().Unix(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (Task, error) {
|
||||
@@ -393,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
// stays; it loses its author.
|
||||
var createdBy sql.NullString
|
||||
var dueDatetime, completedAt sql.NullInt64
|
||||
var duration sql.NullInt64
|
||||
var duration, snoozedUntil sql.NullInt64
|
||||
var created, updated int64
|
||||
|
||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||
&recurrence, &assignee, &completedAt, &completedBy,
|
||||
&createdBy, &t.SortOrder, &created, &updated,
|
||||
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||
if err != nil {
|
||||
return t, err
|
||||
@@ -425,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
v := int(duration.Int64)
|
||||
t.DurationMin = &v
|
||||
}
|
||||
if snoozedUntil.Valid {
|
||||
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||
t.SnoozedUntil = &v
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return t, nil
|
||||
@@ -452,21 +491,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||
recurrence_rule, assignee_id, created_by, sort_order,
|
||||
recurrence_rule, assignee_id, created_by, source_key, sort_order,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||
t.SortOrder, now.Unix(), now.Unix())
|
||||
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
// The one broken constraint that is not a bug: this source has already
|
||||
// become a task. The caller skips it rather than making a duplicate,
|
||||
// which is the entire reason the index exists.
|
||||
if isUniqueViolation(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
||||
})
|
||||
}
|
||||
|
||||
// TaskIDBySource answers "has this mail already become a task?" — the question
|
||||
// nothing could answer before, and the one that stops two assistants reading the
|
||||
// same inbox from making two of everything. Empty id and no error means no.
|
||||
//
|
||||
// Deleted tasks count. A task that was made from a mail and then thrown away is
|
||||
// an answer — remaking it on the next sweep would be the same duplicate wearing
|
||||
// a different hat.
|
||||
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
|
||||
if sourceKey == "" {
|
||||
return "", nil
|
||||
}
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
|
||||
userID, sourceKey).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
||||
var max sql.NullFloat64
|
||||
var err error
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// NormalizeName trims a display name and capitalises the first letter of every
|
||||
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
|
||||
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
|
||||
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
|
||||
// first letter of a word is ever raised, never lowered.
|
||||
//
|
||||
// A name is the person's to spell; the one thing the instance insists on is that
|
||||
// it does not start in lower case, because a column of lower-case first names
|
||||
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
|
||||
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
|
||||
func NormalizeName(name string) string {
|
||||
fields := strings.Fields(name)
|
||||
for i, f := range fields {
|
||||
r := []rune(f)
|
||||
r[0] = unicode.ToUpper(r[0])
|
||||
fields[i] = string(r)
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
||||
//
|
||||
// The Inbox is created here rather than lazily on first use because every other
|
||||
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
|
||||
// of them end up racing to create the second one.
|
||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
u.Name = NormalizeName(u.Name)
|
||||
if u.ID == "" {
|
||||
u.ID = NewID()
|
||||
}
|
||||
@@ -205,9 +227,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
|
||||
// Person is the public half of a user: what somebody else is allowed to see when
|
||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||
type Person struct {
|
||||
ID string
|
||||
Name string
|
||||
AvatarColor string
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
|
||||
// that somebody a note is about to be shared with actually exists, without loading
|
||||
// the whole account.
|
||||
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
|
||||
var p Person
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Person{}, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// UsersForSharing is everyone on the instance except the caller — the people a note
|
||||
// can be handed to.
|
||||
//
|
||||
// Deliberately wider than ListPeople, which is the project collaborators. The whole
|
||||
// point of sharing a note directly is to do it without first standing up a shared
|
||||
// project, so the picker cannot be limited to people you already share a project
|
||||
// with. An instance's accounts are a closed, invited set to begin with — there is
|
||||
// no open signup — so listing them to each other is not disclosure, it is the
|
||||
// address book the feature needs.
|
||||
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
people := []Person{}
|
||||
for rows.Next() {
|
||||
var p Person
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people = append(people, p)
|
||||
}
|
||||
return people, rows.Err()
|
||||
}
|
||||
|
||||
// PeopleByIDs looks up several users at once.
|
||||
@@ -432,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||
// field. Neither is is_admin — an account cannot promote itself.
|
||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||
name = NormalizeName(name)
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||
name, timezone, locale, time.Now().Unix(), userID)
|
||||
@@ -444,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeUserNames capitalises the first letter of every existing user's name,
|
||||
// for the accounts created before the app started doing it on the way in.
|
||||
//
|
||||
// Idempotent: a name already normalised is left untouched, so it costs one pass
|
||||
// of names at each start and writes nothing once it has caught up. Like SealNotes
|
||||
// it runs at boot rather than as a migration, because the rule lives in Go —
|
||||
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
|
||||
//
|
||||
// `updated_at` is not touched: fixing the spelling of a name the person chose is
|
||||
// the instance tidying up after itself, not the person editing their profile.
|
||||
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type pending struct{ id, name string }
|
||||
var todo []pending
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if n := NormalizeName(name); n != name {
|
||||
todo = append(todo, pending{id, n})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(todo) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, p := range todo {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(todo), nil
|
||||
}
|
||||
|
||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||
@@ -466,6 +580,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
|
||||
// fresh. It answers false when the step has already been spent — the same code, or
|
||||
// an earlier one, being presented a second time.
|
||||
//
|
||||
// The guard is in the statement, not in a read-then-write: two logins racing with
|
||||
// the same stolen code must not both find the old value and both proceed. The row
|
||||
// moves forward only when the new step is strictly greater, so exactly one of them
|
||||
// updates a row and the other sees nothing change.
|
||||
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET totp_last_step = ? WHERE id = ?
|
||||
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
|
||||
step, userID, step)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// InboxID returns the user's Inbox project.
|
||||
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
||||
var id string
|
||||
|
||||
@@ -44,8 +44,8 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Self-hosting: self-hosting.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
- Using it:
|
||||
- Signing in: signing-in.md
|
||||
|
||||
@@ -15,12 +15,37 @@ gameskill:
|
||||
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||
# silent — the server's settings page simply does not offer them.
|
||||
version: 4
|
||||
# Bumpet til 6: v5 flyttede imaget til vores egen Gitea, og det virkede ikke.
|
||||
# En rune opdaterer ikke sig selv — panelet skriver rune-rækken fra en
|
||||
# UI-handling, så ændringen her kræver **Runes → verdande → Update**, og
|
||||
# katalog-listen caches ti minutter, så to forsøg lige efter hinanden kan begge
|
||||
# give den gamle version.
|
||||
version: 6
|
||||
icon: "app"
|
||||
|
||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
|
||||
# directly rather than through /bin/sh, which does not exist in there.
|
||||
# GHCR, ikke vores egen Gitea. v5 prøvede det sidste og tog instansen ned.
|
||||
#
|
||||
# Panelets `PullImage` sætter aldrig `RegistryAuth`, så registret SKAL kunne
|
||||
# læses uden legitimation. GHCR kan: dens token-endepunkt svarer 200 og udsteder
|
||||
# en pull-token til hvem som helst. Gitea på gitea.nolimit.dk kan ikke —
|
||||
# `/v2/token?scope=repository:kw/verdande:pull` svarer 401, også efter at repoet
|
||||
# blev gjort offentligt. Instansen kræver login på alt, registret inklusive, og
|
||||
# det er en indstilling for hele instansen frem for for denne pakke.
|
||||
#
|
||||
# Sådan ser det ud, når det går galt, for det tog en time at finde: panelet
|
||||
# river containeren ned for at anvende runen, pullet fejler, `pullImageRetry`
|
||||
# kasserer fejlen, og `ContainerCreate` slår derefter op LOKALT og siger
|
||||
# `No such image`. Serveren står som "running" uden container_id, og udadtil er
|
||||
# det en 502. Fejlen nævner aldrig, at det var et login, der manglede.
|
||||
#
|
||||
# Sådan afgør man det, uden at blive narret: spørg registrets token-endepunkt
|
||||
# med `curl` og se efter en 200 med et token i. Test det ALDRIG med
|
||||
# `docker --config <tom-mappe> pull` — en tom config stopper ikke maskinens
|
||||
# credential-helper, så pullet bliver autentificeret uden at se sådan ud, og
|
||||
# svaret er præcis det modsatte af sandheden.
|
||||
docker:
|
||||
image: "ghcr.io/kristianwind/verdande:latest"
|
||||
data_path: /data
|
||||
|
||||
@@ -856,8 +856,11 @@
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
Self-host it
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
||||
As a Rune
|
||||
</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
With Docker
|
||||
</a>
|
||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
@@ -928,13 +931,33 @@
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/board.webp"
|
||||
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
|
||||
src="screenshots/calendar-light.webp"
|
||||
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Board view — one column per section.</figcaption>
|
||||
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/notes-shared-light.webp"
|
||||
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/board.webp"
|
||||
alt="A project in board view: one column per section, with task cards showing due dates, labels, an arrow to whoever a task is delegated to, and a snoozed card greyed at the foot of its column."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Board view — one column per section. A delegated card carries an arrow to whoever has it; a snoozed one greys at the foot.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -989,12 +1012,12 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/delegated.webp"
|
||||
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
||||
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them, each row carrying an arrow to that person."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
||||
<figcaption>Waiting on others — grouped by who has it, each marked with an arrow to them.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -1050,8 +1073,8 @@
|
||||
<h3>Repeats, and says so in words</h3>
|
||||
<p>
|
||||
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||
screen. Reminders are absolute or relative, and go out to the devices
|
||||
you have allowed them on.
|
||||
screen. Reminders are absolute or relative, and go out as Web Push to
|
||||
an iPhone or a Mac you have allowed them on.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -1063,12 +1086,38 @@
|
||||
written about it. Archive what is done with it; the bin is for mistakes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Share a note, or a page</h3>
|
||||
<p>
|
||||
Hand a single note to a person — to read or to edit — without standing up
|
||||
a project for it. Notes other people share with you gather under
|
||||
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||
instead and everyone who can read the project reads the note.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>How long it takes</h3>
|
||||
<p>
|
||||
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||
foot of a task to make it longer or shorter, or drag its body to another
|
||||
day or time — the length comes along.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Waiting on others</h3>
|
||||
<p>
|
||||
What you have handed to somebody else, on its own page, grouped under
|
||||
the person who has it. The thing a shared list stops being able to tell
|
||||
you the moment it is shared.
|
||||
the person who has it — and marked where you meet it too, with an arrow
|
||||
out of your hands, so a list reads at a glance as which tasks are yours
|
||||
and which you are waiting on.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Snooze it out of the way</h3>
|
||||
<p>
|
||||
Park a task to this evening, tomorrow, next week, or a moment you pick.
|
||||
It greys and sinks to the bottom of the list until then, and comes back
|
||||
by itself — without touching when it is actually due.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1188,6 +1237,23 @@
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<ul class="lately">
|
||||
<li>
|
||||
<span class="ver">0.41</span>
|
||||
A task you have delegated is marked where you meet it, not only on the
|
||||
"Waiting on others" page. A task can be snoozed to the bottom of the list
|
||||
until a moment you choose, and comes back by itself.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.41</span>
|
||||
Web Push reminders reach an iPhone and a Mac again, and every dated task
|
||||
now shows up in a calendar you have subscribed to — not only the timed ones.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.41</span>
|
||||
The calendar draws each task to its length: drag the foot to resize, or the
|
||||
body to move it. A single note can be shared with one person, and a
|
||||
<code>[[note]]</code> is a link you follow inline.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.34</span>
|
||||
Sections can be dragged into the order the work actually happens in. The
|
||||
@@ -1199,21 +1265,6 @@
|
||||
or drag in, a source view, and a copy button on code blocks. Search
|
||||
stopped answering by date and started answering by relevance.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.32</span>
|
||||
A calendar of its own as a menu item, with a read-only Google calendar
|
||||
that can be laid over it.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.31</span>
|
||||
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
||||
task — not only Gmail.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.30</span>
|
||||
An installation count that says exactly what it sends, and can be turned
|
||||
off in one click.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 40 KiB |
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
|
||||
// takes. The link is made at display time — the stored title is still plain text.
|
||||
await box.fill('læs https://example.com/artikel i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
const row = page.locator('.row').filter({ hasText: 'læs' });
|
||||
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
|
||||
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||
|
||||
// Clicking the plain part of the title still opens the task; only the link is
|
||||
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
|
||||
await row.locator('.content').click({ position: { x: 3, y: 3 } });
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -127,6 +150,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('Alfa i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
// Snooze it to tomorrow from its drawer.
|
||||
await page.getByText('Alfa', { exact: true }).click();
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByRole('button', { name: 'I morgen' }).click();
|
||||
|
||||
// The row greys — it carries the snoozed class and a "slumret til" mark.
|
||||
const alfa = page.locator('.row', { hasText: 'Alfa' });
|
||||
await expect(alfa).toHaveClass(/snoozed/);
|
||||
await expect(alfa.locator('.snooze-mark')).toBeVisible();
|
||||
|
||||
// The drawer now offers to wake it, and waking takes the grey off.
|
||||
await drawer.getByRole('button', { name: 'Væk nu' }).click();
|
||||
await expect(alfa).not.toHaveClass(/snoozed/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -196,6 +245,38 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
|
||||
await page.getByLabel('Menu', { exact: true }).selectOption('large');
|
||||
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
|
||||
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||
|
||||
const attrs = () =>
|
||||
page.evaluate(() => [
|
||||
document.documentElement.dataset.menuSize,
|
||||
document.documentElement.dataset.textSize
|
||||
]);
|
||||
expect(await attrs()).toEqual(['large', 'xl']);
|
||||
|
||||
// They live in localStorage like the theme, so they survive a reload — and are
|
||||
// applied before first paint, so the picker comes back showing the saved choice.
|
||||
await page.reload();
|
||||
expect(await attrs()).toEqual(['large', 'xl']);
|
||||
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||
|
||||
// Put them back, so the shared account does not leak a size into other tests.
|
||||
await page.getByLabel('Menu', { exact: true }).selectOption('default');
|
||||
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
|
||||
expect(await attrs()).toEqual([undefined, undefined]);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The PWA's own files. Both of the bugs found last in this project were of this
|
||||
* kind — a manifest promising icons that were never generated — and they are
|
||||
@@ -217,11 +298,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||
|
||||
// The service worker is what Web Push needs; without it a subscription cannot
|
||||
// be created at all.
|
||||
const worker = await request.get('/sw.js');
|
||||
// The service worker is what Web Push needs — and, since it also caches, what
|
||||
// carries the app through a short network drop. SvelteKit serves it under this
|
||||
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||
const worker = await request.get('/service-worker.js');
|
||||
expect(worker.ok()).toBeTruthy();
|
||||
expect(await worker.text()).toContain("addEventListener('push'");
|
||||
// The source is minified, so the quotes around the event names are whatever the
|
||||
// minifier chose — match either kind rather than one it might change under us.
|
||||
const source = await worker.text();
|
||||
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||
* "you are offline" page.
|
||||
*
|
||||
* This is the whole point of the service worker's cache — not offline authoring,
|
||||
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||
* side of the same drop; this covers the read side.
|
||||
*/
|
||||
test.describe('med service worker', () => {
|
||||
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||
// test is about the worker, so it turns it back on for its own context.
|
||||
test.use({ serviceWorkers: 'allow' });
|
||||
|
||||
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||
// First load, online: the worker installs, and once it controls the page the
|
||||
// shell and the reads it serves are in the cache.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// Wait until the worker actually controls this page. Until it does, a reload
|
||||
// still goes to the network and the test proves nothing — the first load
|
||||
// registers the worker but the page that triggered the install is not itself
|
||||
// controlled until it activates and claims.
|
||||
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// A controlled reload, still online: now the navigation and its API reads pass
|
||||
// through the worker and land in the cache, ready for the drop.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// The network is gone.
|
||||
await context.setOffline(true);
|
||||
try {
|
||||
await page.reload();
|
||||
// The shell came from the cache — the sidebar is here, not a browser error
|
||||
// page — and so did the task list the app renders into it.
|
||||
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||
} finally {
|
||||
// Never leave the shared context offline for whatever test runs next.
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -290,9 +425,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
// The name is a link to the group's own page; the chevron beside it is a
|
||||
// The name is a link to the group's own page; the folder icon beside it is a
|
||||
// separate button that folds. Two intentions, two targets — the single one they
|
||||
// used to share could only ever serve the smaller.
|
||||
// used to share could only ever serve the smaller, and neither wears an arrow.
|
||||
//
|
||||
// `exact` on the link, because a group's name is a user-chosen word that can
|
||||
// turn up inside another control's name, and Playwright matches an accessible
|
||||
@@ -302,19 +437,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
// A group's name must be readable in full, and must start where a project's
|
||||
// name starts.
|
||||
// A group's name must be readable in full, and must line up with the other
|
||||
// headings — not with the projects filed under it.
|
||||
//
|
||||
// Both halves are here because both were once broken at the same time and for
|
||||
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||
// sat before the name, pushing a group 22px further in than the projects it
|
||||
// contains — so a group read as if it were filed under them. Every assertion
|
||||
// in this file passed through both, because both buttons still worked.
|
||||
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
|
||||
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
|
||||
// as "ARBE". The alignment half changed meaning with the redesign: a group now
|
||||
// carries a section icon just like "Projekter" above it, and belongs to that
|
||||
// heading tier rather than to the project rows below.
|
||||
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||
const link = el.querySelector('h2 a');
|
||||
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||
// The last child of the link is the name itself; the dot is a span before it.
|
||||
// The leading mark is the section icon in its own fold button, the same
|
||||
// folder/funnel/tag glyph the system headings carry — a group and a section
|
||||
// read identically, and the icon sits before the name, not inside the link.
|
||||
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
|
||||
return {
|
||||
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||
left: name.left,
|
||||
@@ -326,17 +462,17 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||
|
||||
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||
// mark, and it is the only thing in them that means the same on both sides.
|
||||
// Comparing the group's inner link with the project's outer box reported the
|
||||
// row's own padding as a misalignment.
|
||||
const projectLeft = await sidebar
|
||||
.getByRole('link', { name: 'Regnskab' })
|
||||
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||
// A group is a heading, not a project row: it belongs to the same tier as
|
||||
// "Projekter" and the other system sections — icon, bold name, no arrow — and
|
||||
// starts where they start, not where the projects filed under it do. Measured
|
||||
// icon to icon, the one mark both headings share.
|
||||
const sectionLeft = await sidebar
|
||||
.getByRole('heading', { name: 'Projekter' })
|
||||
.evaluate((el) => el.querySelector('.head-icon').getBoundingClientRect().left);
|
||||
expect(
|
||||
Math.abs(shape.left - projectLeft),
|
||||
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||
).toBeLessThanOrEqual(2);
|
||||
Math.abs(shape.left - sectionLeft),
|
||||
`gruppen starter ${Math.round(shape.left - sectionLeft)}px fra sektionsoverskrifterne`
|
||||
).toBeLessThanOrEqual(4);
|
||||
|
||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||
// that heading is a drop target at all: with every project filed away there
|
||||
@@ -359,18 +495,13 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
'false'
|
||||
);
|
||||
|
||||
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
||||
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
||||
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
||||
// ingenting at male henover den med.
|
||||
const chevron = await sidebar
|
||||
.locator('.folder-head .chevron-button')
|
||||
.evaluate((el) => ({
|
||||
background: getComputedStyle(el).backgroundColor,
|
||||
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
||||
}));
|
||||
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
||||
// The fold control is the bare folder icon now — no chevron, and no --surface
|
||||
// field behind it, so a folded group leaves no pale square beside its name on
|
||||
// the sunken bar.
|
||||
const foldBg = await sidebar
|
||||
.locator('.folder-head .fold-icon')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||
|
||||
@@ -386,11 +517,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
// button on click.
|
||||
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
const painted = await sidebar
|
||||
.locator('.folder-head .group-dot')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
||||
await expect(sidebar.getByRole('link', { name: 'Arbejde', exact: true })).toBeVisible();
|
||||
|
||||
// The heading itself is *not* indented — only what is filed under it. A heading
|
||||
// that starts further in than the rows above it says the heading is inside
|
||||
@@ -404,10 +531,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
||||
|
||||
// A project inside a group starts further in than a row outside one. Measured
|
||||
// rather than asserted on a class, because indentation is a fact about where
|
||||
// the row's contents land — and measured at the *content* edge, not the box:
|
||||
// the indent is padding, so the element's own left edge does not move.
|
||||
// A project sits at the same left margin whether or not it is in a group — one
|
||||
// straight edge down the sidebar, with the bold heading doing the grouping the
|
||||
// indent used to. Measured at the *content* edge, not the box, because what the
|
||||
// eye follows is where the text lands.
|
||||
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
||||
const contentLeft = (el) =>
|
||||
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
||||
@@ -415,7 +542,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
||||
sidebar.locator('.views a').first().evaluate(contentLeft)
|
||||
]);
|
||||
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
|
||||
expect(
|
||||
Math.abs(inside - outside),
|
||||
'et projekt i en gruppe står ikke i flugt med de faste visninger'
|
||||
).toBeLessThan(2);
|
||||
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Regnskab' })
|
||||
@@ -489,8 +619,13 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
||||
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
||||
await expect(page.locator('.toast')).toHaveCount(0);
|
||||
|
||||
// A load that fails says so, and offers to try again.
|
||||
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
||||
// A load that fails with a server error says so at once and offers to try again.
|
||||
// A 500 is the server answering, not a dropped connection, so it is not a blip to
|
||||
// wait out — it is not retried, and the message is immediate. (A dropped
|
||||
// connection now retries for a few seconds first; that is the self-heal test.)
|
||||
await page.route('**/api/v1/projects/*', (r) =>
|
||||
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
||||
@@ -501,6 +636,52 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
||||
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A save made during a short drop is not lost to it.
|
||||
*
|
||||
* This is the whole of what "offline" means here: not a cache to work in with no
|
||||
* server, but a save that rides out a few seconds of no connection and goes through
|
||||
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
|
||||
* no reverted change once it heals. A drop that outlasts the retry window is a real
|
||||
* outage and takes the normal error path; that is not this test.
|
||||
*/
|
||||
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the app to finish loading before cutting the line — `goto` resolves at
|
||||
// the load event, but the data load runs after hydration, and aborting that would
|
||||
// test a cold start, not a blip mid-use.
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await expect(box).toBeVisible();
|
||||
|
||||
// The app is loaded and online. Now the connection drops — every call fails, the
|
||||
// way it does in a tunnel, rather than one endpoint erroring.
|
||||
let down = true;
|
||||
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
|
||||
|
||||
// A task captured during the drop. Its save fails on the network and starts
|
||||
// retrying rather than giving up.
|
||||
await box.fill('under et udfald');
|
||||
await box.press('Enter');
|
||||
|
||||
// The quiet pill, once the retry has lasted long enough to be worth showing.
|
||||
await expect(page.locator('.netstatus')).toBeVisible();
|
||||
|
||||
// The drop ends. The retry goes through on its own and the pill leaves — no
|
||||
// error, nothing toasted as failed.
|
||||
down = false;
|
||||
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
|
||||
await expect(page.locator('.toast')).toHaveCount(0);
|
||||
|
||||
// And the save landed: an undated quick capture goes to the Inbox, and there it
|
||||
// is, saved across the drop rather than lost to it.
|
||||
await page.getByRole('link', { name: /Indbakke/ }).click();
|
||||
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task can be dragged onto another day, and onto another project.
|
||||
*
|
||||
@@ -558,15 +739,35 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
||||
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
||||
// hours of every day they disagree about what today is.
|
||||
await page.goto('/upcoming');
|
||||
|
||||
// Taller than the default window, and it is not cosmetic: the month grid is
|
||||
// six rows, and in 720 px the last one is cut off by the fold. A synthetic
|
||||
// drag drops at the target's centre without the auto-scroll a real pointer
|
||||
// gets from the browser — so a target below the fold is never hit.
|
||||
//
|
||||
// That made this test depend on the date. The task sits on tomorrow, and when
|
||||
// tomorrow is a Sunday, the day after is the first cell of the next row, down
|
||||
// in the cut-off part. It passed for months and failed the morning tomorrow
|
||||
// happened to be a Sunday, on a commit that had touched nothing near a
|
||||
// calendar.
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
|
||||
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
// The neighbouring cell is taken from the grid, not worked out from the date.
|
||||
// The day after tomorrow is not always in the month on screen — at the end of
|
||||
// a short month it falls outside it entirely — and the cell next door always
|
||||
// is. Falls back to the cell before, for the one day a month when there is no
|
||||
// cell after.
|
||||
const dates = await page
|
||||
.locator('.day[data-date]')
|
||||
.evaluateAll((cells) => cells.map((cell) => cell.dataset.date));
|
||||
const at = dates.indexOf(from);
|
||||
const to = dates[at + 1] ?? dates[at - 1];
|
||||
|
||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
||||
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
|
||||
@@ -856,6 +1057,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const andreas = await context.newPage();
|
||||
await andreas.goto(link);
|
||||
// Typed in lower case on purpose: the instance capitalises a name on the way
|
||||
// in, so from here on this person is "Andreas" everywhere they are shown.
|
||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||
@@ -874,7 +1077,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await page.reload();
|
||||
await projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
await page.getByLabel('Rolle for Andreas').selectOption('editor');
|
||||
|
||||
await andreas.reload();
|
||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||
@@ -887,11 +1090,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
||||
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
|
||||
// And not on a task that is nobody's.
|
||||
await page.getByLabel('Ny opgave').fill('min egen');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
@@ -1182,8 +1385,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
||||
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||
await expect(band.locator('.event')).toHaveCount(1);
|
||||
//
|
||||
// Named, not counted. A raw `.tevent` count here assumes this day holds nothing
|
||||
// but this test's own events — and it does not: the suite shares one database,
|
||||
// so an earlier test that drags a task across a month boundary can leave a timed
|
||||
// task on this very day, and a timed task is a `.tevent` too. Counting them all
|
||||
// made this pass alone and fail in the full run, on whatever date the arithmetic
|
||||
// happened to land the two tests on the same cell. Asserting the meeting stayed
|
||||
// and the task left is the thing the test actually means, and it is true however
|
||||
// full somebody else left the day.
|
||||
await expect(cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' })).toHaveCount(1);
|
||||
await expect(cell.locator('.tevent').filter({ hasText: 'vande blomster' })).toHaveCount(0);
|
||||
await expect(band.locator('.event').filter({ hasText: 'Feriedag' })).toHaveCount(1);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
@@ -1195,6 +1408,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
||||
* thing this view was before anything was laid over it — and a Google account that
|
||||
* is not there must not take it down with it.
|
||||
*/
|
||||
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// A timed task today at ten, half an hour long. Today, so the week opens on it
|
||||
// without paging, and ten is inside the hours the grid draws by default.
|
||||
const today = await page.evaluate(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
const id = await page.evaluate(async (day) => {
|
||||
const r = await fetch('/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
content: 'Møde med Anders',
|
||||
due_date: day,
|
||||
due_time: '10:00',
|
||||
duration_min: 30
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}, today);
|
||||
|
||||
await page.goto('/kalender');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
const chip = page
|
||||
.locator(`[data-date="${today}"] .tevent.task`)
|
||||
.filter({ hasText: 'Møde med Anders' });
|
||||
await expect(chip).toBeVisible();
|
||||
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
|
||||
|
||||
// Drag the foot of the task down a good stretch. It should grow, both on screen
|
||||
// and in what the server stores — a resize is a change of length, not of time.
|
||||
const box = await chip.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async (tid) => {
|
||||
const r = await fetch('/api/v1/tasks/' + tid, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).duration_min;
|
||||
}, id)
|
||||
)
|
||||
.toBeGreaterThan(30);
|
||||
|
||||
// And the chip is taller than it was, so the length is something you can see.
|
||||
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
|
||||
|
||||
// The time did not move: it is still a ten o'clock task.
|
||||
await expect(chip.locator('.at')).toHaveText('10:00');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/kalender');
|
||||
@@ -1346,9 +1622,18 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
// Into the group, then onto its page — which is what the heading is a link to
|
||||
// now. The chevron beside it still folds; they are two intentions and the old
|
||||
// single target could only serve the smaller one.
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Drejebænk' })
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
//
|
||||
// The drop is retried until the project actually nests under the group: a native
|
||||
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||
// reason that has nothing to do with what the test is about.
|
||||
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||
await expect(async () => {
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Drejebænk' })
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||
}).toPass({ timeout: 10000 });
|
||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||
@@ -1799,7 +2084,12 @@ test('en passkey kan registreres og logges ind med', async ({ browser, browserNa
|
||||
// Now sign out and back in with it. No email typed: the device knows which
|
||||
// account its key belongs to, which is also why this page cannot be used to
|
||||
// find out who has an account here.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
||||
// Signing out asks first now: one click arms it, a second within a few seconds
|
||||
// does it. The whole account row is the button; its label changes to the prompt.
|
||||
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
|
||||
await signOut.click();
|
||||
await expect(signOut).toContainText('Tryk igen for at logge ud');
|
||||
await signOut.click();
|
||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||
// Playwright matches an accessible name loosely unless told otherwise.
|
||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||
@@ -2410,8 +2700,9 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
await page.keyboard.type('Fælles aftale om leveringen');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Shared by being filed in a project. Not an access list of its own.
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
|
||||
// Filing it in a project — the controls live in the Del popover in the footer.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||
|
||||
// And it is really there, not merely labelled: the project's own page shows it.
|
||||
@@ -2421,7 +2712,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
// Taken back out, it is the author's alone again.
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
||||
await expect(page.getByText('din igen')).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||
@@ -2430,6 +2722,68 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Somebody to share with: a second account on the instance, made the ordinary
|
||||
// way. The candidate list is drawn when the note opens, so they must exist
|
||||
// before it does.
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill('[email protected]');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
|
||||
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const sofie = await sofieCtx.newPage();
|
||||
await sofie.goto(link);
|
||||
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
|
||||
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await sofie.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// The owner writes a note and hands it to Sofie, as a reader.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Ferieplan for hele holdet');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Sharing lives in a popover behind the Del button in the footer.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
const share = page.locator('.sharepanel');
|
||||
await expect(share).toBeVisible();
|
||||
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||
|
||||
// She is on the note now, with the role she was given.
|
||||
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('select')).toHaveValue('viewer');
|
||||
|
||||
// And it reaches her: her own notes list grows a Delt med mig group with it in.
|
||||
//
|
||||
// Navigated on the origin the invite signed her in on. VERDANDE_BASE_URL is
|
||||
// localhost while the suite drives 127.0.0.1, and a session cookie set on one
|
||||
// host is not sent to the other — a bare "/noter" would land her logged out.
|
||||
const noterURL = new URL('/noter', link).href;
|
||||
await sofie.goto(noterURL);
|
||||
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
|
||||
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
|
||||
// A viewer's note carries no star or archive control — it is not hers to put away.
|
||||
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
|
||||
await expect(sofieRow.locator('.star')).toHaveCount(0);
|
||||
|
||||
// Taken back, it leaves her list.
|
||||
await row.getByRole('button', { name: 'Fjern adgang' }).click();
|
||||
await expect(row).toBeHidden();
|
||||
await sofie.goto(noterURL);
|
||||
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
|
||||
|
||||
await sofieCtx.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
@@ -2467,6 +2821,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// One note to point at, then another to write the link in.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Rejseplan til Berlin');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Mine planer');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Se ');
|
||||
await page.keyboard.type('[[Rejse');
|
||||
|
||||
// The picker offers the note by title, and choosing it turns the text into a
|
||||
// real inline link — the title, not the brackets.
|
||||
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
|
||||
await expect(inline).toBeVisible();
|
||||
|
||||
// It survives the round-trip through Markdown and a reload.
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(900);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||
|
||||
// And it is a link you can follow: clicking it opens the note it names.
|
||||
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
|
||||
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
@@ -2890,10 +3285,11 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
||||
await page.keyboard.type('Tagsten og lægter');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
|
||||
// nogen har valgt det bagefter.
|
||||
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
||||
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
|
||||
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||
expect(shared).toContain('Tagprojekt');
|
||||
|
||||
// Og den står på projektets egen side.
|
||||
@@ -2959,6 +3355,96 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Kvartalsplan');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// The Arkivér button in the note's footer puts it away: it leaves the list.
|
||||
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||
|
||||
// It is in the archive, where the same button now offers to bring it back.
|
||||
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
|
||||
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||
|
||||
// And back in the ordinary list.
|
||||
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Titel');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
|
||||
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
|
||||
// med at være kode.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('kode linje');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('almindelig tekst');
|
||||
|
||||
// Koden står i sin blok; teksten efter står uden for den.
|
||||
await expect(ed.locator('pre')).toHaveText('kode linje');
|
||||
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
|
||||
|
||||
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('linje et');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('linje to');
|
||||
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
|
||||
|
||||
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
|
||||
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
|
||||
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
|
||||
|
||||
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Rundtur');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('docker pull');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('og så videre i almindelig skrift');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(1200);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
@@ -3032,7 +3518,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
// The selection bar's Arkivér, not the open note's own in the footer.
|
||||
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||
@@ -3366,6 +3853,35 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page
|
||||
await expect(page.locator('h3.group')).toHaveCount(0);
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
|
||||
// Rækkerne skal kunne *ses*, ikke bare findes.
|
||||
//
|
||||
// Da listen blev delt i grupper, blev hver gruppe sit eget `<ul>` — og dermed
|
||||
// sit eget flex-element med `overflow-y: auto`. Flex-elementer skrumper som
|
||||
// udgangspunkt, og et element, der skrumper og klipper, viser ingenting: alle
|
||||
// overskrifter stod med deres tal, og der var ikke en eneste note under nogen af
|
||||
// dem. Med tre grupper og en høj skærm var der plads nok til at det ikke skete,
|
||||
// hvilket er hvorfor prøven herover ikke fangede det.
|
||||
//
|
||||
// Et lavt vindue fremtvinger presset. Tolv hundrede noter i tyve måneder gør det
|
||||
// samme i virkeligheden.
|
||||
await page.setViewportSize({ width: 1100, height: 380 });
|
||||
await page.waitForTimeout(300);
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: 'Gruppe to' }).first(),
|
||||
'rækkerne blev klippet væk, da der ikke var plads'
|
||||
).toBeVisible();
|
||||
// Og rulningen ligger på kassen om grupperne, ikke på hver liste for sig.
|
||||
const scroller = await page
|
||||
.locator('.list')
|
||||
.evaluate((el) => getComputedStyle(el).overflowY);
|
||||
expect(scroller, 'kassen om grupperne ruller ikke').toBe('auto');
|
||||
const inner = await page
|
||||
.locator('.list ul')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).overflowY);
|
||||
expect(inner, 'den enkelte liste ruller stadig for sig').toBe('visible');
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
|
||||
await row.hover();
|
||||
await row.getByRole('button', { name: /Arkivér/i }).click();
|
||||
|
||||
@@ -51,6 +51,12 @@ export default defineConfig({
|
||||
|
||||
use: {
|
||||
baseURL,
|
||||
// Off by default. The app now ships a caching service worker, and once it
|
||||
// controls a page Playwright's page.route() no longer sees the fetches it
|
||||
// makes — so a test that forces a 500 to check the error path gets the real
|
||||
// 200 through the worker instead, and reads as a broken feature. The one test
|
||||
// that needs the real worker turns it back on for its own context.
|
||||
serviceWorkers: 'block',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
// Pinned, because the app reads both. The sign-up form sends
|
||||
|
||||
@@ -73,6 +73,11 @@ const SHOTS = [
|
||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||
// The new work, and in the light theme, which is what most people want to look
|
||||
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||
// somebody shared into the "Shared with me" group.
|
||||
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
|
||||
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
// Scrolled, because the themes are the second panel on the page and a picture
|
||||
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function openSharedNote(page) {
|
||||
// The note Mette shared in, so the "Shared with me" group and its owner chip are
|
||||
// what the picture is of rather than something the group happens to sit above.
|
||||
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showThemes(page) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||
@@ -340,6 +352,23 @@ async function handOver(ids) {
|
||||
await api(`/tasks/${ids.detail}/comments`, {
|
||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||
});
|
||||
|
||||
// A note of Mette's own, handed to the owner — so their Notes page has a
|
||||
// "Shared with me" group with a real note in it, marked as hers. This is the
|
||||
// other half of sharing: not the project path, but a page passed to a person.
|
||||
const note = await api('/notes', {
|
||||
body: `# Packaging quotes
|
||||
|
||||
Three back, one still to come:
|
||||
|
||||
- **Nordfelt** — 79 øre a bag at 2,000, plates included
|
||||
- **Kolding Karton** — 84 øre, faster, kinder about small runs
|
||||
- Bruun still owes me a number
|
||||
|
||||
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
|
||||
});
|
||||
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
|
||||
|
||||
return { colleague: me.id };
|
||||
}
|
||||
|
||||
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
const home = await api('/project-groups', { name: 'Home' });
|
||||
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
|
||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||
|
||||
// A handful given a time and a length, so the calendar's week draws them to
|
||||
// scale rather than as a row of identical blocks — a stand-up, a long
|
||||
// planning block, a call, a review. Spread across the day and the days, so
|
||||
// the picture is a week that has a shape.
|
||||
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
|
||||
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
|
||||
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
|
||||
|
||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||
@@ -441,6 +483,17 @@ async function seed({ colleague, noteTitle }) {
|
||||
const made = {};
|
||||
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
||||
|
||||
// One task snoozed, so the board and the list show a card parked at the bottom
|
||||
// of its section — greyed, with a "snoozed until" mark — set aside rather than
|
||||
// forgotten, and without touching when it is due.
|
||||
const snoozeUntil = (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 2);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
})();
|
||||
await api(`/tasks/${made['Write the job advert'].id}/snooze`, { until: snoozeUntil });
|
||||
|
||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||
// a feature that does not work.
|
||||
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
||||
@@ -560,6 +613,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
||||
await api('/users', { email: '[email protected]' });
|
||||
|
||||
return {
|
||||
owner: me.id,
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
|
||||
/* The sidebar's own text size, its own knob (Settings → Appearance) so the menu
|
||||
can be made easier to read without dragging the whole list with it. Default is
|
||||
a step up from the old --text-sm the nav used, at medium weight, so the menu
|
||||
items read as menu items. [data-menu-size] on <html> overrides it. */
|
||||
--menu-size: 0.9375rem;
|
||||
|
||||
/* Spacing on a 4px grid. */
|
||||
--s1: 0.25rem;
|
||||
--s2: 0.5rem;
|
||||
@@ -204,6 +210,52 @@
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
|
||||
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
|
||||
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
|
||||
than sharing --surface-raised with the list. The accent turns amber, and the
|
||||
selected menu item, selected note and section headings each get a fallback token
|
||||
so this theme can hit its colours without touching any other. */
|
||||
[data-theme='charcoal'] {
|
||||
--ground: #1e1e1e;
|
||||
--surface: #2a3033;
|
||||
--surface-raised: #242b2e; /* the note list */
|
||||
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
|
||||
--line: #34393b;
|
||||
--line-strong: #414749;
|
||||
|
||||
--ink: #ffffff;
|
||||
--ink-muted: #dcdcdc;
|
||||
--ink-faint: #9ba0a2;
|
||||
|
||||
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
|
||||
dark text rides on top of it. */
|
||||
--accent: #ffce35;
|
||||
--accent-hover: #ffd75a;
|
||||
--accent-sunken: #ffce35;
|
||||
--accent-ink: #1e1e1e;
|
||||
|
||||
/* Per-element, and only here — every other theme falls back to the shared token
|
||||
in the var() calls that read these. */
|
||||
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
|
||||
--note-on-bg: #a78c26; /* a selected note in the list — gold */
|
||||
--note-on-ink: #1e1e1e; /* dark text on the gold */
|
||||
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
|
||||
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
|
||||
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Papir — warm light, closer to paper than to a screen. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
@@ -369,9 +421,13 @@ button {
|
||||
søgefelt. */
|
||||
}
|
||||
|
||||
/* A translucent wash of the accent, not a solid block and not the old near-invisible
|
||||
tint. --accent-sunken sat a hair off the editor's own surface in dark, so a
|
||||
selection could not be seen; a solid accent could, but read as a heavy slab. Mixing
|
||||
the accent part-way into transparent lays a clear green film over the text while it
|
||||
keeps its own colour — visible on white and on the dark surfaces alike. */
|
||||
::selection {
|
||||
background: var(--accent-sunken);
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
||||
@@ -516,3 +572,41 @@ button {
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Body text size — its own control in Settings → Appearance, for reading the list
|
||||
and the notes larger (or smaller) without touching the menu. It re-scales the
|
||||
shared type tokens the same way a look does; placed after the looks so that an
|
||||
explicit size choice wins the tokens they both set. The menu has its own knob
|
||||
below and does not move with this one. */
|
||||
[data-text-size='small'] {
|
||||
--text-base: 0.875rem;
|
||||
--text-sm: 0.75rem;
|
||||
--text-lg: 1rem;
|
||||
}
|
||||
|
||||
[data-text-size='large'] {
|
||||
--text-base: 1.0625rem;
|
||||
--text-sm: 0.9375rem;
|
||||
--text-lg: 1.1875rem;
|
||||
}
|
||||
|
||||
[data-text-size='xl'] {
|
||||
--text-base: 1.1875rem;
|
||||
--text-sm: 1.0625rem;
|
||||
--text-lg: 1.3125rem;
|
||||
}
|
||||
|
||||
/* Menu (sidebar) font size — independent of the body size above, because the two
|
||||
are separate questions: a menu you can read at a glance, and a list you can read
|
||||
a paragraph of. */
|
||||
[data-menu-size='small'] {
|
||||
--menu-size: 0.8125rem;
|
||||
}
|
||||
|
||||
[data-menu-size='large'] {
|
||||
--menu-size: 1.0625rem;
|
||||
}
|
||||
|
||||
[data-menu-size='xl'] {
|
||||
--menu-size: 1.1875rem;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
// face and again in another, which is a visible jump.
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
|
||||
// The font-size choices change layout too, so they belong before first
|
||||
// paint for the same reason: otherwise the sidebar and the list are drawn
|
||||
// once at one size and again at another.
|
||||
const menuSize = localStorage.getItem('verdande:menu-size');
|
||||
if (menuSize && menuSize !== 'default') document.documentElement.dataset.menuSize = menuSize;
|
||||
const textSize = localStorage.getItem('verdande:text-size');
|
||||
if (textSize && textSize !== 'default') document.documentElement.dataset.textSize = textSize;
|
||||
} catch (e) {
|
||||
// Private browsing can refuse localStorage; the dark default stands.
|
||||
}
|
||||
|
||||
@@ -23,6 +23,57 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||
|
||||
/**
|
||||
* How long a request keeps retrying a network failure before giving up.
|
||||
*
|
||||
* This is the whole of the "offline" feature and its whole limit: it rides out a
|
||||
* blip of a few seconds so a save is not lost to it, and then — because the app is
|
||||
* a live view of a shared list, not a local database — it stops and says so rather
|
||||
* than holding an edit for a network that is not coming back. Fifteen seconds is
|
||||
* long enough for a handover or a tunnel and short enough that a real outage fails
|
||||
* while the user is still looking at what they did.
|
||||
*/
|
||||
const RETRY_WINDOW_MS = 15000;
|
||||
|
||||
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
|
||||
* wakes the instant the OS reports a link again, so most blips end on the first. */
|
||||
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
|
||||
|
||||
/**
|
||||
* Runs `send` (a fetch), retrying only network failures for a short window.
|
||||
*
|
||||
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
|
||||
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
|
||||
* a 400 just asks the same wrong question again. An abort is the caller changing
|
||||
* its mind (a superseded search), so it is left to propagate.
|
||||
*
|
||||
* Retrying is not free of risk: a create whose request reached the server but whose
|
||||
* response was lost would be made twice. For a drop of seconds that is rare, and the
|
||||
* alternative — losing the save every time the network hiccups — is the thing this
|
||||
* exists to stop. A server-side idempotency key would remove even that; it is a
|
||||
* larger change noted for later.
|
||||
*/
|
||||
async function withRetry(send, noRetry = false) {
|
||||
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await send();
|
||||
connectivity.reachable();
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') throw e;
|
||||
if (Date.now() >= deadline) {
|
||||
connectivity.lost();
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
connectivity.retrying();
|
||||
await sleepOrOnline(backoff(attempt++));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key for each code the API can return.
|
||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, init);
|
||||
} catch (e) {
|
||||
// A network failure is not an HTTP status; give it one the app can handle
|
||||
// the same way as everything else.
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
||||
const form = new FormData();
|
||||
form.append(field, file);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
} catch {
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
@@ -310,6 +354,7 @@ export const api = {
|
||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
@@ -405,6 +450,12 @@ export const api = {
|
||||
deleteNote: (id) => del(`/notes/${id}`),
|
||||
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||
|
||||
// Sharing a note with people directly — its own access list, beside the project
|
||||
// path. Owner only; the list call also returns who it can still be shared with.
|
||||
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
||||
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
||||
|
||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||
// there is no instance-wide registration behind these the way Gmail has one.
|
||||
mailboxes: () => get('/mailboxes'),
|
||||
|
||||
@@ -286,6 +286,48 @@
|
||||
/** The cell lit up under the pointer. */
|
||||
let over = $state(null);
|
||||
|
||||
// A resize in progress: which task, and where its foot is being dragged to, in
|
||||
// minutes since midnight. Kept apart from the stored length so the task can
|
||||
// follow the handle live and only be written when the drag ends.
|
||||
let resizing = $state(null);
|
||||
|
||||
/**
|
||||
* Dragging the foot of a timed task changes how long it is.
|
||||
*
|
||||
* Pointer events rather than the HTML5 drag the body uses: the two must not be
|
||||
* the same gesture, or every attempt to make a task longer would instead pick it
|
||||
* up and move it. stopPropagation keeps the handle's press from starting the
|
||||
* move, and the column it lives in is what the minutes are measured against —
|
||||
* the same box onDropAt reads a drop time from.
|
||||
*/
|
||||
function startResize(event, task, date) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const col = event.currentTarget.closest('.daycol');
|
||||
if (!col || !task.due_datetime) return;
|
||||
const at = new Date(task.due_datetime);
|
||||
const from = at.getHours() * 60 + at.getMinutes();
|
||||
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
|
||||
|
||||
const onMove = (e) => {
|
||||
const box = col.getBoundingClientRect();
|
||||
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
|
||||
const minutes = hours.from * 60 + share * hours.minutes;
|
||||
// Quarter-hour steps, at least a quarter long, never past midnight.
|
||||
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
|
||||
resizing = { ...resizing, to: snapped };
|
||||
};
|
||||
const onUp = async () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
const current = resizing;
|
||||
resizing = null;
|
||||
if (current) await app.resize(current.id, current.to - current.from);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
function onDragOver(event, date) {
|
||||
if (!carries(event, TASK)) return;
|
||||
accept(event);
|
||||
@@ -394,7 +436,16 @@
|
||||
if (!task.due_datetime) continue;
|
||||
const at = new Date(task.due_datetime);
|
||||
const from = at.getHours() * 60 + at.getMinutes();
|
||||
out.push({ kind: 'task', ref: task, from, to: from + 30 });
|
||||
// The task's own length, not a fixed half hour. A quarter-hour floor so a
|
||||
// zero-minute task is still something to see and grab, and the end of the
|
||||
// day is the ceiling. While a resize is in flight this task follows the
|
||||
// handle instead of its stored length.
|
||||
let to =
|
||||
resizing && resizing.id === task.id
|
||||
? resizing.to
|
||||
: from + (task.duration_min || 30);
|
||||
to = Math.min(Math.max(to, from + 15), 24 * 60);
|
||||
out.push({ kind: 'task', ref: task, from, to });
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||
@@ -547,12 +598,13 @@
|
||||
</svelte:element>
|
||||
{/snippet}
|
||||
|
||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
||||
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
|
||||
<!-- Draggable as well as clickable: moving something to another day is the
|
||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||
of movement before it calls a press a drag, so the click survives. -->
|
||||
<button
|
||||
class={kind}
|
||||
class:resizing={resizing?.id === task.id}
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
@@ -562,6 +614,16 @@
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{/if}
|
||||
{task.content}
|
||||
<!-- Only a task standing at a time can be made longer, and only where the
|
||||
grid has room to show it — the timed column, which passes the date. -->
|
||||
{#if date && task.due_datetime}
|
||||
<span
|
||||
class="resize"
|
||||
title={t('cal.resize')}
|
||||
aria-hidden="true"
|
||||
onpointerdown={(e) => startResize(e, task, date)}
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -589,6 +651,7 @@
|
||||
<div
|
||||
class="allday"
|
||||
data-allday={iso(date)}
|
||||
class:today={iso(date) === todayISO}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
@@ -632,7 +695,7 @@
|
||||
{#if item.kind === 'event'}
|
||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||
{:else}
|
||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -814,12 +877,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* White, not the sidebar's sunken ground: a calendar reads lightest when the
|
||||
day itself is the brightest thing on the screen and the events are the soft
|
||||
colour laid on it — the opposite of the grey grid it was, where the events
|
||||
had to shout to be seen at all. */
|
||||
.corner,
|
||||
.head,
|
||||
.gutter,
|
||||
.allday,
|
||||
.daycol {
|
||||
background: var(--ground);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -827,6 +894,16 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Today's whole column, faintly, so the eye finds it before it reads a number —
|
||||
the marker on the date above is the label, this is the glow it sits in. Kept
|
||||
under the events, so a pastel block still reads as its own calendar's colour
|
||||
rather than as this wash tinted. */
|
||||
.head.today,
|
||||
.allday.today,
|
||||
.daycol.today {
|
||||
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
|
||||
}
|
||||
|
||||
.head .wd {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
@@ -839,9 +916,19 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* The marker at the top the day is named by: today's number in a filled disc,
|
||||
the same one the month grid gives today, so "this is today" is one glance in
|
||||
either view rather than a colour you have to already know to read. */
|
||||
.head.today .num {
|
||||
color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
font-weight: 560;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
@@ -915,6 +1002,10 @@
|
||||
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
|
||||
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
|
||||
luft, der gør to kasser til to kasser. */
|
||||
/* A soft wash of the calendar's own colour rather than a grey block edged in it:
|
||||
each calendar keeps a dusty, unmistakable nuance, and a screen of them reads
|
||||
as colour laid on white instead of a grid of grey bars. The left edge stays,
|
||||
a shade stronger than the fill, so the block still has a spine. */
|
||||
.tevent {
|
||||
position: absolute;
|
||||
margin-right: 2px;
|
||||
@@ -922,21 +1013,48 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour, var(--line-strong));
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* A task is a card you can pick up, not an event you cannot — so on the white
|
||||
column it is an outlined card, where an event is a solid wash. The priority
|
||||
colour rides the left edge, as it does everywhere else. */
|
||||
.tevent.task {
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
border-left-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* The grip along the foot of a task, for dragging its length. Out of the way
|
||||
until the task is hovered or being resized, so it never competes with the
|
||||
words above it. */
|
||||
.tevent.task .resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 7px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
.tevent.task:hover .resize,
|
||||
.tevent.task.resizing .resize {
|
||||
opacity: 1;
|
||||
background: linear-gradient(to bottom, transparent, var(--line-strong));
|
||||
}
|
||||
.tevent.task.resizing {
|
||||
cursor: ns-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tevent.task[data-priority='1'] {
|
||||
border-left-color: var(--p1);
|
||||
}
|
||||
@@ -1016,7 +1134,8 @@
|
||||
text-align: left;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
@@ -1035,7 +1154,7 @@
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: var(--surface);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
@@ -1055,8 +1174,8 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1071,7 +1190,7 @@
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1080,7 +1199,7 @@
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
@@ -1182,20 +1301,18 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.calendar.week .chip[data-priority='1'] {
|
||||
border-left-color: var(--p1);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='2'] {
|
||||
border-left-color: var(--p2);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='3'] {
|
||||
border-left-color: var(--p3);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
@@ -1207,12 +1324,12 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
@@ -120,11 +120,99 @@
|
||||
|
||||
function setStyle(style) {
|
||||
stylesOpen = false;
|
||||
editor?.focus();
|
||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||
|
||||
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
|
||||
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
|
||||
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||
// hand, replacing the element rather than reformatting inside it.
|
||||
const block = currentBlock();
|
||||
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
||||
replaceBlock(block, style.tag);
|
||||
return;
|
||||
}
|
||||
apply('formatBlock', style.tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a block element with one of another tag, keeping its contents and the
|
||||
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||
* <pre>, which it either nests or ignores.
|
||||
*/
|
||||
function replaceBlock(block, tag) {
|
||||
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||
// on the next render for any <pre> that still needs one.
|
||||
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
|
||||
|
||||
const el = document.createElement(tag);
|
||||
while (block.firstChild) el.appendChild(block.firstChild);
|
||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||
block.replaceWith(el);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps out of a code block on Enter.
|
||||
*
|
||||
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
|
||||
* staying inside it — which means a monospace last line makes every line after
|
||||
* it monospace too, with no way back to body by typing. Plain Enter now leaves
|
||||
* the block: the tail after the caret becomes a paragraph below the <pre>, and
|
||||
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
|
||||
* break still writes multi-line code inside the block.
|
||||
*/
|
||||
function exitPre(pre) {
|
||||
pre.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Everything from the caret to the end of the block leaves with the new line.
|
||||
const tail = document.createRange();
|
||||
tail.setStart(range.endContainer, range.endOffset);
|
||||
tail.setEnd(pre, pre.childNodes.length);
|
||||
const frag = tail.extractContents();
|
||||
|
||||
// A break at the head of what we carried is the empty line return was pressed
|
||||
// on; it should not open the paragraph with a blank row.
|
||||
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
|
||||
|
||||
const p = document.createElement('p');
|
||||
if (frag.childNodes.length) p.appendChild(frag);
|
||||
// The split can leave an empty text node behind, and a block with no element
|
||||
// and no text is not a caret host in Chromium — the next keystroke falls back
|
||||
// into the block above. An empty paragraph needs a <br> to be stood in.
|
||||
if (!p.textContent && !p.querySelector('br, img')) {
|
||||
p.replaceChildren(document.createElement('br'));
|
||||
}
|
||||
|
||||
// And the same break seen from the other side, left dangling in the <pre>.
|
||||
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
|
||||
|
||||
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
|
||||
if (!pre.textContent.trim()) pre.replaceWith(p);
|
||||
else pre.after(p);
|
||||
|
||||
const caret = document.createRange();
|
||||
caret.setStart(p, 0);
|
||||
caret.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(caret);
|
||||
}
|
||||
|
||||
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||
function readState() {
|
||||
if (!editor) return;
|
||||
@@ -302,9 +390,26 @@
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
|
||||
// A note link, [[, is the same idea as a project tag and gets the same
|
||||
// completion — checked first because it is the longer opener. The term runs
|
||||
// to the caret and is anything up to the closing bracket, which has not been
|
||||
// typed yet.
|
||||
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||
if (note) {
|
||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||
}
|
||||
|
||||
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (project) {
|
||||
return {
|
||||
kind: 'project',
|
||||
node,
|
||||
start: selection.anchorOffset - project[1].length,
|
||||
term: project[1]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,46 +453,87 @@
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
function projectMatches(term) {
|
||||
return app.projects
|
||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||
// current note is left out — a note that links to itself is a loop nobody meant
|
||||
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||
function noteMatches(term) {
|
||||
return notes
|
||||
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
function rank(a, b, term) {
|
||||
const al = a.toLowerCase();
|
||||
const bl = b.toLowerCase();
|
||||
const ap = al.startsWith(term);
|
||||
const bp = bl.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return al.localeCompare(bl, 'da');
|
||||
}
|
||||
|
||||
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||
|
||||
// A click on a note link follows it; a click anywhere else re-colours the code,
|
||||
// as it did before. The link is contenteditable="false", so the click is a click
|
||||
// and not a caret landing in the middle of a word.
|
||||
function onEditorClick(event) {
|
||||
const link = event.target.closest?.('.notelink');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||
return;
|
||||
}
|
||||
colourCode();
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole thing. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
if (item.kind === 'note') {
|
||||
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||
// become one when the note is next opened. A space after, as its own text,
|
||||
// so the caret leaves the atomic link and the next word is a word.
|
||||
range.setStart(partial.node, partial.start - 2);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
|
||||
);
|
||||
document.execCommand('insertText', false, ' ');
|
||||
} else {
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
// A trailing space, or the suggestions come straight back for the tag.
|
||||
document.execCommand('insertText', false, `${item.label} `);
|
||||
}
|
||||
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
@@ -470,6 +616,16 @@
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// A code block is the one place the browser keeps the caret inside on
|
||||
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||
// monospace last line traps everything typed after it in monospace.
|
||||
const block = currentBlock();
|
||||
if (block && block.tagName === 'PRE') {
|
||||
event.preventDefault();
|
||||
exitPre(block);
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
// After the split, not before it.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
@@ -678,7 +834,7 @@
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onclick={onEditorClick}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
@@ -698,7 +854,7 @@
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
{#each suggestions as item, i (item.kind + item.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
@@ -706,12 +862,17 @@
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
{#if item.kind === 'note'}
|
||||
<span class="dot note" aria-hidden="true">[[</span>
|
||||
{item.label}
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{item.label}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -864,6 +1025,18 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* A note suggestion wears the brackets that open it where a project wears its
|
||||
colour, so the two kinds are told apart at a glance in the same list. */
|
||||
.suggestions .dot.note {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: none;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -1023,6 +1196,18 @@
|
||||
margin: var(--s2) 0;
|
||||
}
|
||||
|
||||
/* A note link reads as a link: the accent colour, underlined on hover, and a
|
||||
pointer so it is plainly something to click rather than to edit. */
|
||||
.page :global(a.notelink) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.page :global(a.notelink:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
|
||||
@@ -31,9 +31,36 @@
|
||||
|
||||
async function signOut() {
|
||||
await api.logout();
|
||||
// Empty the offline data cache before leaving: the service worker keeps the
|
||||
// last-seen task and note lists so a network drop does not blank the app, and
|
||||
// the next person to sign in on this browser must not find them.
|
||||
try {
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||
await caches?.delete('verdande-api');
|
||||
} catch {
|
||||
// No service worker / no Cache API (older browser, private mode): nothing
|
||||
// was cached, so nothing to clear.
|
||||
}
|
||||
location.href = '/';
|
||||
}
|
||||
|
||||
// Signing out is one click from a button the pointer passes over on its way to
|
||||
// everything below it, and it drops you at the login screen — so it asks first.
|
||||
// A second click within a few seconds confirms; anything else lets it lapse. Not
|
||||
// a dialog: a two-step button is lighter and stays where the eye already is.
|
||||
let confirmSignOut = $state(false);
|
||||
let signOutTimer;
|
||||
function onSignOutClick() {
|
||||
if (confirmSignOut) {
|
||||
clearTimeout(signOutTimer);
|
||||
signOut();
|
||||
return;
|
||||
}
|
||||
confirmSignOut = true;
|
||||
clearTimeout(signOutTimer);
|
||||
signOutTimer = setTimeout(() => (confirmSignOut = false), 3500);
|
||||
}
|
||||
|
||||
let filters = $state([]);
|
||||
let labels = $state([]);
|
||||
|
||||
@@ -487,6 +514,7 @@
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
data-view={item.key}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
@@ -529,6 +557,52 @@
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.key === 'today'}
|
||||
<!-- A star, the way Things marks Today: the one view you open first. -->
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2l1.7 3.6 3.9.5-2.9 2.7.7 3.9L8 10.9 4.6 13.4l.7-3.9L2.4 6.1l3.9-.5z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.key === 'upcoming'}
|
||||
<!-- A calendar with a day marked — the Calendar view carries the plain
|
||||
grid, so the marked day keeps the two apart. -->
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
x="2.2"
|
||||
y="3.2"
|
||||
width="11.6"
|
||||
height="10.6"
|
||||
rx="1.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<path
|
||||
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<rect x="8.8" y="8.6" width="2.6" height="2.6" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
{:else if item.key === 'delegated'}
|
||||
<!-- A person: the tasks you are waiting on someone else for. -->
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="8" cy="5.4" r="2.5" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
<path
|
||||
d="M3.4 13.4c0-2.6 2.1-4.2 4.6-4.2s4.6 1.6 4.6 4.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
@@ -570,6 +644,7 @@
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
data-view={project.is_inbox ? 'inbox' : undefined}
|
||||
class:sortable
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
class:dragging={draggingId === project.id}
|
||||
@@ -593,7 +668,29 @@
|
||||
? onTaskDrop(e, project)
|
||||
: sortable && onDrop(e, project)}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{#if project.is_inbox}
|
||||
<!-- A tray, not a coloured dot: the Inbox is a fixed view that happens to
|
||||
be a project, and it reads as one of the views above, not as the first
|
||||
of the projects below. -->
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M2.3 9.2L4 3.4a1.1 1.1 0 011-.8h6a1.1 1.1 0 011 .8l1.7 5.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.3 9.2v2.9A1.3 1.3 0 003.6 13.4h8.8a1.3 1.3 0 001.3-1.3V9.2h-3.3l-1 1.7H6.6l-1-1.7z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{projectName(project)}
|
||||
<!-- Open tasks, not people. The same grey number meant three things in this
|
||||
one column — people at a project, projects at a group, tasks at a label
|
||||
@@ -612,28 +709,84 @@
|
||||
<!--
|
||||
A fixed heading that folds, the same way a project group's does.
|
||||
|
||||
One snippet for all four rather than four copies of a chevron and an aria
|
||||
One snippet for all four rather than four copies of an icon and an aria
|
||||
attribute: they have to behave identically, and the group heading is already
|
||||
the proof that they drift otherwise — it grew a fold, a colour and two
|
||||
actions while these stayed plain text.
|
||||
|
||||
The whole heading is the target, not the chevron. At this size a lone chevron
|
||||
is something you miss, and the name is where the eye already is.
|
||||
The whole heading is the target, and the icon is the mark: no arrow beside it.
|
||||
At this size an extra glyph is clutter, and the name is where the eye already
|
||||
is.
|
||||
-->
|
||||
<!-- One monochrome mark per kind of heading, so a section and a group read as the
|
||||
same kind of thing — a folder of projects, a funnel of filters, a tag of
|
||||
labels — instead of one being a faint label and the other a coloured dot. -->
|
||||
{#snippet sectionIcon(key)}
|
||||
{#if key === 'filters'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M2.2 3.4h11.6L9.3 8.8v3.4L6.7 10.9V8.8z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if key === 'labels'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M7.7 2.3H13v5.3l-5.3 5.3-5.3-5.3z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="10.2" cy="4.9" r="0.95" fill="currentColor" />
|
||||
</svg>
|
||||
{:else if key === 'shared'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="6.2" cy="5.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
<path
|
||||
d="M2.6 12.8c0-2.1 1.7-3.3 3.6-3.3s3.6 1.2 3.6 3.3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 9.6c1.5.2 2.6 1.3 2.6 3.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="11.2" cy="5.6" r="1.7" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Projekter and every group: a folder of projects. -->
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet foldHeading(key, label)}
|
||||
<!-- No chevron: a system section has no page and nothing to rename, so the
|
||||
whole heading is one button that folds it. The icon and the bold name are
|
||||
the whole control — an arrow beside them was only ever saying "clickable"
|
||||
twice. -->
|
||||
<h2 class="fold">
|
||||
<button
|
||||
onclick={() => app.toggleSection(key)}
|
||||
aria-expanded={!app.sectionCollapsed(key)}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={app.sectionCollapsed(key)}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{@render sectionIcon(key)}
|
||||
{label}
|
||||
</button>
|
||||
</h2>
|
||||
@@ -656,7 +809,7 @@
|
||||
and with every project filed there would be no loose row left to aim at. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head"
|
||||
class="group-head sticky-head"
|
||||
class:over={overGroup === ''}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, PROJECT)) return;
|
||||
@@ -801,31 +954,22 @@
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
project's title does: a group *is* a heading in the sidebar, and
|
||||
wrapping an h2 in a button both invalidates the markup and takes
|
||||
the heading away from a screen reader. The whole heading folds —
|
||||
at this size a lone chevron is a target you miss, and the name is
|
||||
where the eye already is. -->
|
||||
<!-- The chevron folds; the name opens the group's own page. Two
|
||||
targets in one heading, because they are two different intentions
|
||||
and the old single one could only ever serve the smaller. -->
|
||||
<!-- No chevron: the folder icon itself folds the group, and the name
|
||||
beside it opens the group's own page. Two intentions, two targets,
|
||||
but no arrow to clutter the rail — the icon a section carries is
|
||||
the same one that folds it here. The button sits inside the
|
||||
heading, not around it: a group *is* a heading, and wrapping an h2
|
||||
in a button both invalidates the markup and takes the heading from
|
||||
a screen reader. -->
|
||||
<button
|
||||
class="chevron-button"
|
||||
class="fold-icon"
|
||||
onclick={() => app.toggleGroup(group.id)}
|
||||
aria-expanded={!group.collapsed}
|
||||
aria-label={group.collapsed
|
||||
? t('sidebar.unfold', { name: group.name })
|
||||
: t('sidebar.fold', { name: group.name })}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{@render sectionIcon('group')}
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<!-- Navigation waits two tenths of a second for a second click. A
|
||||
@@ -846,11 +990,6 @@
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{group.name}
|
||||
</a>
|
||||
</h2>
|
||||
@@ -938,12 +1077,12 @@
|
||||
{#if !app.connected}
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
|
||||
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -979,14 +1118,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
padding: var(--s4) var(--s3);
|
||||
/* A few pixels more air down each side than the rows strictly need, so the
|
||||
menu breathes rather than sitting flush against its own edges. */
|
||||
padding: var(--s4) calc(var(--s3) + 3px);
|
||||
background: var(--surface-sunken);
|
||||
border-right: 1px solid var(--line);
|
||||
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||
overflow: hidden;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-left: max(calc(var(--s3) + 3px), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1005,6 +1146,32 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
/* The sidebar is padded, so the scrollbar sat a pad-width inside the edge and
|
||||
read as floating in the middle. Pull the scroller out to the border and pad
|
||||
the content back, so the bar sits against the edge where a scrollbar
|
||||
belongs — the rows do not move. Thin and dim, so it is present without
|
||||
drawing the eye. */
|
||||
margin-right: calc(var(--s3) * -1);
|
||||
padding-right: var(--s3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-strong) transparent;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* No transparent border and no padding-box clip: those centred a 4px bar in a
|
||||
10px track, so the thumb floated three pixels shy of the divider. The bar is
|
||||
the track now — a thin rounded thumb sitting flush against the line on the
|
||||
right, which is where a scrollbar belongs. */
|
||||
.scroller::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||
@@ -1133,8 +1300,26 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
/* The fixed views carry a colour of their own, the way Things does — the star
|
||||
amber, the Inbox blue — and keep it whether or not they are the active row.
|
||||
So no active-state override here; the colour is the icon's identity. */
|
||||
[data-view='inbox'] .mark {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
[data-view='today'] .mark {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
[data-view='upcoming'] .mark {
|
||||
color: var(--color-tomato);
|
||||
}
|
||||
[data-view='delegated'] .mark {
|
||||
color: var(--color-violet);
|
||||
}
|
||||
[data-view='notes'] .mark {
|
||||
color: var(--color-teal);
|
||||
}
|
||||
[data-view='calendar'] .mark {
|
||||
color: var(--color-indigo);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
@@ -1144,12 +1329,35 @@
|
||||
padding: 0 var(--s2) var(--s2);
|
||||
}
|
||||
|
||||
/* The Projekter heading holds the only way to add a project or a group, so it
|
||||
must not scroll away with a long list. Pinned to the top of the scroller, on
|
||||
the sidebar's own ground so the rows pass under it. */
|
||||
.sticky-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
padding-top: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Every heading — the system sections and the user's groups — reads the same:
|
||||
a mark, then a bold plainly-cased name. What used to set them apart (one a
|
||||
faint uppercase label, the other a bold name with a coloured dot) is gone. */
|
||||
h2 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 600;
|
||||
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
|
||||
falls back to the reading ink and keeps them bold and bright. */
|
||||
color: var(--heading-ink, var(--ink));
|
||||
}
|
||||
|
||||
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
||||
nudged like a row's own mark, so the icons line up down the sidebar. */
|
||||
.head-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Only the square icon buttons — "+" and the folder — and asked for by class
|
||||
@@ -1235,57 +1443,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||
you miss. */
|
||||
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||
it pushed the name 22px further in than a project's, so groups read as if
|
||||
they were filed under the projects above them — the opposite of what they
|
||||
are. Out of the flow, a group's name starts exactly where a project's does.
|
||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||
stays put while the group is folded, so a folded group still shows the way
|
||||
back open. */
|
||||
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
||||
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
||||
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
||||
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
||||
skifte, uden noget at male henover den med. */
|
||||
.chevron-button {
|
||||
position: absolute;
|
||||
left: calc(var(--s2) - 5px);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
/* The folder icon is the fold toggle now — no chevron beside it. A bare icon
|
||||
button the size of the icon it holds: the name next to it opens the group's
|
||||
page, this folds it, and neither wears an arrow to say so. */
|
||||
.fold-icon {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
||||
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
||||
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
||||
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
||||
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
||||
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.folder-head:hover .chevron-button,
|
||||
.chevron-button:focus-visible,
|
||||
.chevron-button[aria-expanded='false'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
||||
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
||||
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
||||
pilen kom frem. */
|
||||
.folder-head:hover .group-dot,
|
||||
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
||||
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chevron-button:hover {
|
||||
.fold-icon:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1314,22 +1482,6 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Hidden until the heading is hovered, like the section actions on a project
|
||||
page: two buttons permanently beside a label stop it reading as a label. */
|
||||
.folder-head .count {
|
||||
@@ -1362,29 +1514,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The group's own mark, between the chevron and the name — inside the fold
|
||||
button, so it cannot push the heading in. It is a mark now and not a
|
||||
control: the colour is chosen while renaming, which is the other edit to
|
||||
the same thing. */
|
||||
.group-dot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Projects inside a group are indented to the group's name, not to its dot:
|
||||
the eye follows the text, and lining up under the dot leaves the names
|
||||
looking a step short of where the heading starts. */
|
||||
/* Projects sit at the same left margin whether or not they are in an area —
|
||||
one straight edge down the whole sidebar reads calmer than a staircase, and
|
||||
the bold area heading above already does the grouping the indent used to. */
|
||||
.folder > a {
|
||||
padding-left: calc(var(--s2) + var(--s4));
|
||||
padding-left: var(--s2);
|
||||
}
|
||||
|
||||
/* The line is indented with them, so a drop between two projects in a group
|
||||
reads as landing in the group rather than across the whole sidebar. */
|
||||
.folder > a.sortable::before {
|
||||
left: calc(var(--s2) + var(--s4));
|
||||
left: var(--s2);
|
||||
}
|
||||
|
||||
.folder > .empty {
|
||||
padding-left: calc(var(--s2) + var(--s4));
|
||||
padding-left: var(--s2);
|
||||
}
|
||||
|
||||
/* Inside the rename form now, so it needs no padding of its own — the form
|
||||
@@ -1441,7 +1583,10 @@
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
/* Its own size (Settings → Appearance) and medium weight, so the menu items
|
||||
carry more than the muted small text they used to and read at a glance. */
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
@@ -1452,10 +1597,17 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An accent-tinted selection, not a half-step of surface: on the sunken dark
|
||||
sidebar --surface-raised was all but invisible, so the active row read as no
|
||||
different from the rest. --accent-sunken carries the accent hue and stands out
|
||||
in both themes. */
|
||||
a.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
background: var(--accent-sunken);
|
||||
/* On a solid accent (Kul's amber menu item) the label goes dark via
|
||||
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
|
||||
falls back to the reading ink. */
|
||||
color: var(--nav-active-ink, var(--ink));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||
@@ -1577,6 +1729,16 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Asking to confirm: the row goes to the danger tint and the name is replaced by
|
||||
"click again", so the second click is plainly the one that signs out. */
|
||||
.user.confirming {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.user.confirming .user-name {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||