Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1e12417b | ||
|
|
b9028e8f7a | ||
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 | ||
|
|
6a5228aed8 | ||
|
|
9155e799b1 | ||
|
|
5764271579 | ||
|
|
8a6329bfc9 | ||
|
|
fb8abcaa74 | ||
|
|
956e57f234 | ||
|
|
661e47ebdf | ||
|
|
6178cbbdd8 | ||
|
|
43bd405e36 | ||
|
|
b73f55ad0b | ||
|
|
475ecbe7ef | ||
|
|
159029675c | ||
|
|
b18d9a26e6 | ||
|
|
3768cf946e | ||
|
|
60795126a5 | ||
|
|
664614b924 | ||
|
|
807aa83e58 | ||
|
|
9199709aa5 | ||
|
|
1d15cb18c0 | ||
|
|
ff988d26bb | ||
|
|
b451fd585d | ||
|
|
8c3aa33f13 | ||
|
|
c6c7979d1a | ||
|
|
6154e6b247 | ||
|
|
7e3d300183 | ||
|
|
3cee9bfb6e | ||
|
|
9b067f7057 |
+21
-1
@@ -97,7 +97,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -271,3 +277,17 @@ jobs:
|
||||
set -eu
|
||||
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
||||
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
|
||||
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
|
||||
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
|
||||
|
||||
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
|
||||
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
|
||||
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
|
||||
# Det kræver `--load` to gange, og det er et halvt minut per image på en
|
||||
# runner, der deles med Yggdrasils produktionscontainere og allerede har
|
||||
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
|
||||
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
|
||||
# autoritativ for de offentlige projekter.
|
||||
#
|
||||
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
|
||||
|
||||
@@ -40,11 +40,51 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
# The iOS app's shell: the interface built the way the app carries it, and
|
||||
# copied into the Xcode project. No Xcode here — that build happens on a Mac —
|
||||
# but a config the CLI cannot read, a webDir that moved, or an app build that
|
||||
# stopped compiling all fail at this step rather than on the day somebody sits
|
||||
# down to ship.
|
||||
app:
|
||||
name: iOS app shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
(cd web && npm ci)
|
||||
(cd app && npm ci)
|
||||
|
||||
- name: Build and sync
|
||||
working-directory: app
|
||||
run: npm run sync
|
||||
|
||||
# The app build must not register the service worker — WKWebView has none
|
||||
# for the app's own scheme. The browser build registers it from index.html.
|
||||
- name: No service worker in the app build
|
||||
run: |
|
||||
if grep -qF 'serviceWorker.register' web/build/index.html; then
|
||||
echo "The app build registers a service worker in index.html."
|
||||
exit 1
|
||||
fi
|
||||
test -f app/ios/App/App/public/index.html
|
||||
echo "App build in the Xcode project: $(find app/ios/App/App/public -type f | wc -l) files."
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
@@ -117,9 +157,59 @@ jobs:
|
||||
# Build only — publishing happens on a tag, in release.yml. This job exists
|
||||
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||
# than at release time.
|
||||
#
|
||||
# Loaded into the local daemon rather than discarded, because the step below
|
||||
# has to inspect what came out.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: verdande:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: urd:ci
|
||||
build-args: EDITION=notes
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# The claim this repository makes about urd is that it is the same program,
|
||||
# not a fork — and that claim is checkable rather than a sentence in a README.
|
||||
#
|
||||
# Stages 1 and 2 are identical for both builds, and the edition is set with
|
||||
# ENV, which is image *config* rather than a layer. So the two images must
|
||||
# have the same layer digests and differ only in that one variable. A change
|
||||
# that made them diverge — a second COPY, a conditional RUN, a separate build
|
||||
# of the binary — would quietly turn "one source" into two, and the only
|
||||
# symptom would be a bug fixed in one product and not the other.
|
||||
#
|
||||
# This was impossible until the frontend build became deterministic: SvelteKit
|
||||
# put Date.now() in version.json, so no two builds ever matched. See the note
|
||||
# in web/svelte.config.js.
|
||||
- name: The two images are one build
|
||||
run: |
|
||||
set -eu
|
||||
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
|
||||
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
|
||||
echo "The verdande and urd images do not share their layers:"
|
||||
layers verdande:ci
|
||||
layers urd:ci
|
||||
echo "They are built from one source, so this is either a Dockerfile that"
|
||||
echo "now builds the binary twice, or a stage that is no longer shared."
|
||||
exit 1
|
||||
fi
|
||||
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
|
||||
|
||||
# And the half that stops the check above from passing over two identical
|
||||
# images: they must actually differ in the one way they are supposed to.
|
||||
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
|
||||
echo "verdande: $(edition verdande:ci)"
|
||||
echo "urd: $(edition urd:ci)"
|
||||
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
|
||||
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
|
||||
|
||||
@@ -44,6 +44,11 @@ node_modules.nosync/
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||
# af det samme.
|
||||
/web/.playwright-data-notes/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
+35
-1
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
|
||||
# Passed into the frontend build, because SvelteKit's default build version is
|
||||
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
|
||||
# the service worker, changed the content hash of every chunk referencing them, and
|
||||
# so changed those chunks' filenames. Every build of one commit produced different
|
||||
# bytes. See the long note in web/svelte.config.js.
|
||||
ARG VERSION=dev
|
||||
ENV VERDANDE_VERSION=${VERSION}
|
||||
|
||||
# The frontend is built when it is present. During backend-only development it is
|
||||
# not, and the build must still produce a working image rather than failing on a
|
||||
# missing directory — so this stage falls back to a placeholder page. The Go stage
|
||||
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
#
|
||||
# "Reproducible" was false for a year and nobody read the sentence that said it:
|
||||
# the flags above do their job, and the frontend embedded below carried a
|
||||
# millisecond timestamp, so every build of one commit produced a different binary.
|
||||
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
|
||||
# match rather than trusting this comment.
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-o /verdande ./cmd/verdande
|
||||
|
||||
# --- 3. what actually ships --------------------------------------------------
|
||||
#
|
||||
# Two images come out of this stage, and the only difference between them is one
|
||||
# environment variable:
|
||||
#
|
||||
# docker build . → verdande
|
||||
# docker build --build-arg EDITION=notes → urd
|
||||
#
|
||||
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
|
||||
# the same build rather than a rebuild that happens to match. That is the point:
|
||||
# urd is not a fork, and "built from the same source" is a property of the layers
|
||||
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
|
||||
# the two images share every layer digest and differ only in that config — which CI
|
||||
# asserts on every pull request.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
|
||||
# so one above the FROM above would be invisible down here. `full` by default,
|
||||
# because that is what this program has always been and an unqualified build must
|
||||
# not quietly produce the other product.
|
||||
ARG EDITION=full
|
||||
|
||||
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
|
||||
# so the container needs a tz database and CA certificates for outbound SMTP.
|
||||
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
|
||||
# nightly backups.
|
||||
VOLUME ["/data"]
|
||||
ENV VERDANDE_DATA_DIR=/data \
|
||||
VERDANDE_ADDR=:8080
|
||||
VERDANDE_ADDR=:8080 \
|
||||
VERDANDE_EDITION=${EDITION}
|
||||
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
||||
Which is what a to-do list is.
|
||||
|
||||
## Two editions: verdande and urd
|
||||
|
||||
This repository builds one program with two faces. Start it with
|
||||
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
|
||||
routes are not mounted, the sidebar has no way to them, and the program calls
|
||||
itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
|
||||
wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||
five of nine MCP tools. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
@@ -234,6 +251,7 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "dk.nolimit.verdande",
|
||||
"appName": "Verdande",
|
||||
"webDir": "../web/build",
|
||||
"ios": {
|
||||
"contentInset": "never",
|
||||
"scheme": "Verdande"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,380 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Verdande</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- A self-hosted Verdande on a home network may have no certificate:
|
||||
http://192.168.1.20:8080 or http://verdande.local. This allows plain
|
||||
http to local addresses only; anything on the internet must still be
|
||||
https. -->
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = CAPBridgeViewController()
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+1239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "verdande-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The iOS app: the web interface bundled into a native shell.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cd ../web && VERDANDE_TARGET=app npm run build",
|
||||
"sync": "npm run build && cap sync ios",
|
||||
"open": "cap open ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.5.3",
|
||||
"@capacitor/ios": "^8.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
// Not the edition's name: this fires before the configuration is read, so
|
||||
// the edition is not known yet — and one of the things it reports is a
|
||||
// VERDANDE_EDITION the program refused.
|
||||
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +40,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := httpapi.NewLogger(cfg.Dev)
|
||||
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
||||
// The edition's own name, so an urd instance's log says urd. The full edition
|
||||
// still logs "starting verdande", which is what the release check reads.
|
||||
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
||||
|
||||
// The data volume is the only state that matters; create the whole layout up
|
||||
// front so a failure here is a startup error rather than a surprise at the
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
# The install count
|
||||
|
||||
verdande reports that it exists. Two values, once a day:
|
||||
verdande reports that it exists — and so does urd, which is the same program in
|
||||
its [notes edition](editions.md). Two values, once a day:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day:
|
||||
|
||||
That is the whole message. No IP is stored by the collector, no hostname, no
|
||||
domain, no account, no counts of anything you have made, nothing about what you
|
||||
use it for. The payload is small enough to be printed in full on the settings page,
|
||||
use it for — and not which of the two editions is running, so the count does not
|
||||
tell the two apart either. The payload is small enough to be printed in full on the settings page,
|
||||
and it is printed there — a promise about telemetry is worth exactly as much as
|
||||
your ability to check it.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
@@ -36,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
|
||||
Any Go duration: `720h`, `30m`, `45s`.
|
||||
|
||||
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||
|
||||
## Mail
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Two editions: verdande and urd
|
||||
|
||||
One program, built once, with two faces.
|
||||
|
||||
| | **verdande** | **urd** |
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 137 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
mounted, the sidebar has no way to them, search answers with notes only, and the
|
||||
program calls itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
## Running the notes edition
|
||||
|
||||
There is one image. The edition is an environment variable on the container:
|
||||
|
||||
```
|
||||
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
|
||||
ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
No second image and no rune of its own, deliberately.
|
||||
|
||||
The edition is for an operator who wants an instance that genuinely has no task
|
||||
routes — the routes are absent from the router, not hidden in the interface. It is
|
||||
**not** how urd is normally reached. That is the second door, below.
|
||||
|
||||
Two details that surprise people who look inside:
|
||||
|
||||
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
|
||||
both editions. Not an oversight: it is the same binary, and renaming the file per
|
||||
edition would be a migration hazard in exchange for nothing a user sees.
|
||||
- **The icons for both editions ship in the one image.** The server decides which
|
||||
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
|
||||
and never reads the manifest, so renaming paths would have left iOS with the
|
||||
wrong mark.
|
||||
|
||||
## The second door: two apps, one instance
|
||||
|
||||
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
|
||||
database, one login, one set of links between notes and tasks — and two things you
|
||||
can install.
|
||||
|
||||
| | verdande | urd |
|
||||
|---|---|---|
|
||||
| entry | `/` | `/urd` |
|
||||
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
|
||||
| manifest `id` | `/` | `/urd` |
|
||||
| tab and mark | verdande, ᚹ | urd, ð |
|
||||
| database, account, login | the same | the same |
|
||||
|
||||
An operating system does not install a URL, it installs a **manifest**, and the
|
||||
identity of an installed app is that manifest's `id`. Two manifests on one origin
|
||||
with different ids are two apps; two URLs sharing one manifest are one app with a
|
||||
bookmark. That is the whole mechanism.
|
||||
|
||||
The shell is served per door rather than patched by JavaScript after the page
|
||||
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
|
||||
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
|
||||
the manifest's icons altogether.
|
||||
|
||||
urd's manifest is built from verdande's rather than written beside it, so the
|
||||
fields the two genuinely share — the colours, the display mode, the language —
|
||||
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
|
||||
`scope`, the two names and the icons. An icon in the built manifest that urd has no
|
||||
file for stops the server rather than being passed through, because the symptom
|
||||
would otherwise be urd's install prompt quietly showing Verdande's mark.
|
||||
|
||||
### The face is a preference, not a gate
|
||||
|
||||
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
|
||||
tab. It is kept strictly apart from the notes edition in the code, and the
|
||||
distinction is the one thing worth reading twice:
|
||||
|
||||
| | what it means | what follows |
|
||||
|---|---|---|
|
||||
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
|
||||
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
|
||||
|
||||
Conflate them and the urd face starts hiding data rather than chrome: a notebook
|
||||
would stop showing its tasks because somebody chose a different entrance. A test
|
||||
asserts the opposite directly — the urd window still gets 200 from
|
||||
`/api/v1/tasks`.
|
||||
|
||||
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
|
||||
two apps be two apps: they share an origin, so they share localStorage, cookies and
|
||||
the service worker. `sessionStorage` is per window, and an installed PWA is its own
|
||||
window. It survives a reload, which it must — after the first navigation the
|
||||
address is `/noter` and nobody passes through `/urd` again.
|
||||
|
||||
!!! note "One thing the two doors cannot have separately"
|
||||
Theme, typeface and text size are shared, because they are in the localStorage
|
||||
the origin shares. That was a deliberate choice: splitting them per face would
|
||||
mean migrating everybody's stored settings for a difference few people want.
|
||||
|
||||
## Moving between the two, measured
|
||||
|
||||
A `/data` directory moves between the editions in **both directions, losslessly**.
|
||||
Same binary, same schema, same migrations — so this is not a feature that was built,
|
||||
it is a property that was checked:
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
|
||||
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
|
||||
| the same directory, back to `full` | the task is still there; so is the note |
|
||||
|
||||
The ownership stayed at the panel's service account through all three.
|
||||
|
||||
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
|
||||
getting notes **into other programs** — it writes one `.md` per note and carries the
|
||||
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
|
||||
is the right division of labour, because the database already holds those.
|
||||
|
||||
!!! note "The task rows stay"
|
||||
An instance run as `notes` still has its tasks in the database; they are simply
|
||||
not served. Harmless on a fresh instance, dead weight on a converted one — and
|
||||
worth knowing before handing a converted instance to somebody else.
|
||||
|
||||
## Why one codebase and not a fork
|
||||
|
||||
The two share everything that is hard: accounts, sessions, passkeys, sharing,
|
||||
invitations, the realtime socket, search, the backup format, the migrations. Notes
|
||||
are about a twentieth of the Go in this repository and reference no task type;
|
||||
tasks reference no note type. A fork would have duplicated the other nineteen
|
||||
twentieths in order to separate the one — and then every security fix would have
|
||||
needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 63 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
## An unknown value refuses to start
|
||||
|
||||
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
|
||||
rather than being treated as "not notes".
|
||||
|
||||
The silent direction is the dangerous one here. An operator who sets up an instance
|
||||
for notes and mistypes the value would otherwise get every task route served on it,
|
||||
with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 63 withheld routes:
|
||||
|
||||
| | what |
|
||||
|---|---|
|
||||
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||
| 2 | the Todoist and CSV imports, which create tasks |
|
||||
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||
|
||||
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||
answer, and tells the person there is nothing to do.
|
||||
|
||||
Everything else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||
- AI settings, `ask`, and the note actions
|
||||
|
||||
!!! warning "Still being decided"
|
||||
Four connectors are reachable in urd today and have not been settled either
|
||||
way, because each has a plausible notes reading and withholding them would be
|
||||
deciding what this product is rather than closing a hole: the mail-in address,
|
||||
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||
anything. The same applies to one handler rather than a route:
|
||||
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||
|
||||
## The name
|
||||
|
||||
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
|
||||
cutting marks into wood — the act of writing something down. Verdande is what is
|
||||
becoming; Urd is what has become and was kept.
|
||||
|
||||
An instance started without the task routes is a different product to the person
|
||||
using it, so it says a different name. That name is read from the configuration in
|
||||
one place (`Config.ProductName`), which is why there is no build-time flag and no
|
||||
second frontend: the frontend is byte-identical in both editions, and the server
|
||||
renames the two files a browser reads before any JavaScript runs — the HTML shell's
|
||||
title and the web manifest.
|
||||
|
||||
!!! note "The beacon counts them separately"
|
||||
Both editions report the same two values once a day — instance id and version
|
||||
— and nothing else; see [The install count](beacon.md). The edition is not in
|
||||
the payload, so the count does not distinguish them.
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# The iOS app
|
||||
|
||||
The iOS app is the web interface bundled into a native shell with
|
||||
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
|
||||
Svelte build runs in both, and everything that differs between a browser and the
|
||||
app lives in `web/src/lib/platform.js`.
|
||||
|
||||
It talks to **any** Verdande server. The first screen asks for the address, checks
|
||||
it with `/api/v1/ping`, and then signs in there.
|
||||
|
||||
## How the app differs from a browser
|
||||
|
||||
| | Browser | App |
|
||||
|---|---|---|
|
||||
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
|
||||
| Which server | the one it was loaded from | the one its owner typed in |
|
||||
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
|
||||
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
|
||||
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
|
||||
| Service worker, Web Push | yes | no — WKWebView has neither |
|
||||
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
|
||||
| Creating the first account | yes | no — done in a browser, then sign in from the app |
|
||||
|
||||
The server side of this is `client: "app"` on `/auth/login`, the
|
||||
`capacitor://localhost` CORS rule, and the ticket. All three are in
|
||||
`docs/openapi.yaml`.
|
||||
|
||||
An app session slides: every use moves its end forward, up to
|
||||
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
|
||||
settings with the name the app gave itself, and can be signed out from there.
|
||||
|
||||
## Building
|
||||
|
||||
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm ci
|
||||
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
|
||||
```
|
||||
|
||||
On a Mac with Xcode:
|
||||
|
||||
```sh
|
||||
npm run open # opens ios/App/App.xcodeproj
|
||||
```
|
||||
|
||||
Choose a simulator and press Run. In the app, type the address of a Verdande —
|
||||
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
|
||||
|
||||
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
|
||||
`Info.plist`), so a home server without a certificate works on the same network;
|
||||
anything on the internet has to be `https`.
|
||||
|
||||
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
|
||||
generated config are not — `npm run sync` puts them there.
|
||||
|
||||
The icon and launch image come from `web/static/icon.svg`:
|
||||
|
||||
```sh
|
||||
cd web && node scripts/generate-ios-assets.js
|
||||
```
|
||||
|
||||
## Putting it on a phone
|
||||
|
||||
These steps are the account owner's — they involve Apple's terms, a payment and
|
||||
signing keys.
|
||||
|
||||
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
|
||||
should own the app.
|
||||
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
|
||||
`dk.nolimit.verdande`; it can change until the first upload and never after.
|
||||
3. *Product → Archive*, then *Distribute App → TestFlight*.
|
||||
4. In App Store Connect, add testers to the TestFlight build.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Notifications.** Web Push does not exist in the app. Notifications on an
|
||||
iPhone go through Apple's APNs, and the key that sends them is bound to the
|
||||
app's bundle id — so it cannot live on every self-hosted server. That means a
|
||||
small relay run by the app's publisher, which every instance with an app user
|
||||
would talk to. That is a new connection from other people's servers, and it
|
||||
waits on a decision before it is built.
|
||||
- **The session token in the Keychain.** It is in the WebView's own storage for
|
||||
now, behind one object in `platform.js` so the Keychain plugin can replace it
|
||||
without anything else changing.
|
||||
- **Passkeys** in the app.
|
||||
- **Share sheet** (share text or a link *into* Verdande from another app).
|
||||
+172
-1
@@ -53,7 +53,7 @@ paths:
|
||||
/api/v1/auth/setup:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance still needs its first account.
|
||||
summary: What this instance is, and whether it still needs its first account.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -64,6 +64,16 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
needs_setup: { type: boolean }
|
||||
edition:
|
||||
type: string
|
||||
enum: [full, notes]
|
||||
description: >
|
||||
`full` is tasks, projects and notes together. `notes` is
|
||||
the notes half: the task routes are not mounted at all, so
|
||||
an instance that is not a task manager cannot be asked to
|
||||
be one through its API. The interface reads this before
|
||||
anybody is signed in, because it decides what there is to
|
||||
show rather than what this person may see.
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Create the first account, which becomes the administrator.
|
||||
@@ -110,6 +120,19 @@ paths:
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app. Its session comes back as
|
||||
`session_token` instead of a cookie and slides — every use
|
||||
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
|
||||
(180 days) unused. Left out, it is a browser login.
|
||||
device_name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: "Required with `client: app`; shown in the list of sessions."
|
||||
example: Kristians iPhone
|
||||
responses:
|
||||
"200":
|
||||
description: |
|
||||
@@ -122,7 +145,14 @@ paths:
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/User" }
|
||||
totp_required: { type: boolean }
|
||||
session_token:
|
||||
type: string
|
||||
description: |
|
||||
An app login only, also while `totp_required` — the app
|
||||
sends it to `/auth/login/totp` too. Sent from then on as
|
||||
`Authorization: Bearer`. A browser never receives it.
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
|
||||
/api/v1/auth/login/totp:
|
||||
@@ -263,6 +293,49 @@ paths:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/default-reminder:
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: The account's standing alarm on tasks that have a clock.
|
||||
description: |
|
||||
Reminders have always had to be set one at a time: a task written as
|
||||
"i morgen kl 9" got a time and no alarm, because nothing creates a reminder
|
||||
by itself. That is the right default for a task with no clock — there is no
|
||||
moment to ring at — and an errand you walk every time for a task with one.
|
||||
|
||||
`minutes` is relative to the due moment and **negative for before it**, the
|
||||
same convention `reminders.offset_min` uses because that is the column the
|
||||
value ends up in: the store adds it to the due time. `0` is exactly at the
|
||||
due moment. `null` turns the default off, which is where every account
|
||||
starts — switching it on for everybody in a migration would have meant
|
||||
alarms nobody asked for, on tasks already lying there.
|
||||
|
||||
A positive number is refused. An alarm after the thing it is warning about
|
||||
is not a reminder, and accepting one here would make the single mistake this
|
||||
value can make — a sign the wrong way round — look like a choice.
|
||||
|
||||
The default never stacks on an alarm the task already has, and a task can
|
||||
opt out as it is written with "ingen alarm" in the quick-add line.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
minutes:
|
||||
type: [integer, "null"]
|
||||
maximum: 0
|
||||
minimum: -10080
|
||||
description: |
|
||||
Minutes relative to the due moment, negative for before it.
|
||||
Null is off.
|
||||
example: -10
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/auth/nav-order:
|
||||
put:
|
||||
tags: [auth]
|
||||
@@ -3951,14 +4024,51 @@ paths:
|
||||
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
||||
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
||||
`reminder`.
|
||||
|
||||
A browser opens it with its session cookie and from the instance's own
|
||||
origin. The iOS app cannot put a header on a handshake, so it asks
|
||||
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
|
||||
origin without one is refused.
|
||||
parameters:
|
||||
- name: ticket
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
|
||||
responses:
|
||||
"101": { description: Switching protocols. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ws-ticket:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: A one-use ticket for opening the live-sync socket without a cookie.
|
||||
description: |
|
||||
For the iOS app, whose session is a bearer token that a WebSocket
|
||||
handshake cannot carry. Putting the token itself in the URL would leave a
|
||||
credential lasting months in every log the URL passes through; the ticket
|
||||
lasts thirty seconds and opens one connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ticket: { type: string }
|
||||
expires_in: { type: integer, description: Seconds., example: 30 }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ping:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: A liveness check that does not touch the database.
|
||||
description: |
|
||||
Also how the iOS app tells a Verdande from anything else at an address
|
||||
somebody typed in, before anyone has signed in, and which half of the
|
||||
interface to show. It carries no version: `/version` is behind a login on
|
||||
purpose, and a public number is a list of the instances running an old one.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -3969,6 +4079,9 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
product: { type: string, const: verdande }
|
||||
edition: { type: string, enum: [full, notes] }
|
||||
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
|
||||
|
||||
# --- data -----------------------------------------------------------------
|
||||
|
||||
@@ -4386,6 +4499,10 @@ components:
|
||||
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
||||
there is no separate service identity — and is refused on `/tokens`.
|
||||
|
||||
Or the iOS app's session, the `session_token` from an app login. That
|
||||
one is a session and reaches everything a browser session does. A
|
||||
browser's own session is never accepted here.
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: The body could not be read.
|
||||
@@ -4791,6 +4908,17 @@ components:
|
||||
never null: a client that has to check for both an absent array and an
|
||||
empty one will one day check for only one of them.
|
||||
example: ["labels"]
|
||||
default_reminder_min:
|
||||
type: [integer, "null"]
|
||||
description: |
|
||||
The account's standing alarm on tasks that have a clock, as minutes
|
||||
relative to the due moment and negative for before it. Null is off,
|
||||
which is where every account starts.
|
||||
|
||||
A type union rather than a number with a sentinel, because zero is a
|
||||
real setting here — an alarm exactly at the due moment — so "off" and
|
||||
"at the time" have to stay different answers.
|
||||
example: -10
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
RecoveryCodes:
|
||||
@@ -4909,6 +5037,12 @@ components:
|
||||
it so anything the summary gets wrong is still visible.
|
||||
example: Chrome på macOS
|
||||
user_agent: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app, whose `device` is the name it gave itself and
|
||||
whose `expires_at` moves forward each time it is used.
|
||||
ip: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
@@ -4981,6 +5115,21 @@ components:
|
||||
task is still created, in the Inbox — a thought is not thrown away
|
||||
over a typo — but the caller is told, because the alternative is
|
||||
that the name vanishes from the title and nothing says why.
|
||||
unknown_section:
|
||||
type: string
|
||||
description: |
|
||||
The same, for a `/section` that no section of the project the task
|
||||
landed in answers to. The task is created without a section.
|
||||
reminder_not_set:
|
||||
type: string
|
||||
description: |
|
||||
Present when the line asked to be reminded and the task has
|
||||
nothing to count back from — "mind mig en time før" on a task with
|
||||
a day but no clock. The offset form of a reminder fires relative to
|
||||
the due moment, so without one the row would sit in the table and
|
||||
never go off. The task is created; the alarm is not, and this says
|
||||
so. An alarm that silently never rings is the one way a reminder
|
||||
must not fail, because nothing afterwards reveals it.
|
||||
|
||||
ParsedTask:
|
||||
type: object
|
||||
@@ -4992,6 +5141,28 @@ components:
|
||||
due_date: { type: string, format: date }
|
||||
due_datetime: { type: string, format: date-time }
|
||||
recurrence: { type: string }
|
||||
reminder_offset_min:
|
||||
type: integer
|
||||
description: |
|
||||
When to be told, as minutes relative to the due moment: negative for
|
||||
before it, zero for at it. Absent when the line asked for no alarm.
|
||||
Relative rather than absolute, so moving the task moves its alarm with
|
||||
it. Mind the sign — the store adds this to the due time, so "an hour
|
||||
before" is -60.
|
||||
no_reminder:
|
||||
type: boolean
|
||||
description: |
|
||||
The line said it wants no alarm on this one — "ingen alarm". It exists
|
||||
because of the account-wide default: with alarms on for everything that
|
||||
has a clock, there has to be a way to say "not this one" in the same
|
||||
breath as writing it, rather than creating the task, opening it and
|
||||
deleting the alarm just made.
|
||||
reminder_at:
|
||||
type: string
|
||||
description: |
|
||||
An absolute alarm, `YYYY-MM-DDTHH:MM`, set when the line gave the
|
||||
reminder a clock of its own. It takes the day the task is due: "mind
|
||||
mig kl 8" about something due Friday means Friday at eight.
|
||||
labels:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
@@ -24,8 +24,12 @@ type Config struct {
|
||||
DataDir string
|
||||
|
||||
SessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
// AppSessionTTL is how long a session from the iOS app may sit unused before
|
||||
// it ends. It slides: every use moves the end forward, so a phone in daily use
|
||||
// stays signed in and one left in a drawer does not.
|
||||
AppSessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
|
||||
@@ -65,6 +69,12 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||
// which is always included. Set by the end-to-end suite, which serves the
|
||||
// interface from a second host name to test it the way the app runs it. An
|
||||
// operator has no reason to set it.
|
||||
AppOrigins []string
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
@@ -161,11 +171,19 @@ func Load() (*Config, error) {
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
c.AppOrigins = append(c.AppOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,3 +12,35 @@ const (
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
|
||||
// ProductName and ProductSlug are the two forms of the name the running instance
|
||||
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
|
||||
// manifest.
|
||||
//
|
||||
// Two editions of one program, two names — which is the whole reason the name is
|
||||
// read from the configuration rather than written into the templates. Verdande is
|
||||
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
|
||||
// and kept. An instance started without the task routes is a different product to
|
||||
// whoever uses it, and a product that calls itself by its sibling's name is not a
|
||||
// product, it is a flag somebody set.
|
||||
//
|
||||
// The lowercase form is deliberate and not a styling accident: the wordmark, the
|
||||
// repository and the window title are all lowercase, so the slug is the name and
|
||||
// ProductName is the one that gets a capital because it stands in prose.
|
||||
func (c *Config) ProductName() string {
|
||||
if c.NotesOnly() {
|
||||
return "Urd"
|
||||
}
|
||||
return "Verdande"
|
||||
}
|
||||
|
||||
// ProductSlug is ProductName lowercased, kept as its own method rather than as
|
||||
// strings.ToLower at the call sites: the two are the same transformation today and
|
||||
// a name that does not lowercase cleanly would otherwise be a bug in every caller
|
||||
// at once.
|
||||
func (c *Config) ProductSlug() string {
|
||||
if c.NotesOnly() {
|
||||
return "urd"
|
||||
}
|
||||
return "verdande"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
preflight := map[string]string{
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
}
|
||||
|
||||
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||
}
|
||||
|
||||
// An ordinary request from the app can be read.
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app GET: Allow-Origin %q", got)
|
||||
}
|
||||
|
||||
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||
// router's login page.
|
||||
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ping: %d", resp.StatusCode)
|
||||
}
|
||||
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||
t.Errorf("ping = %v", body)
|
||||
}
|
||||
if _, ok := body["version"]; ok {
|
||||
t.Error("ping reveals the version to anybody")
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||
h := http.Header{}
|
||||
if origin != "" {
|
||||
h.Set("Origin", origin)
|
||||
}
|
||||
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||
}
|
||||
|
||||
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Without a ticket or a cookie: refused.
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Fatal("a socket opened with no credential")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no credential: %v", resp)
|
||||
}
|
||||
|
||||
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket, _ := body["ticket"].(string)
|
||||
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("dial with a ticket: %v", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// And it is the right person's stream: their own new project arrives on it.
|
||||
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||
t.Errorf("first event was %s", msg)
|
||||
}
|
||||
|
||||
// Spent: the same ticket does not open a second socket.
|
||||
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||
t.Error("a ticket opened two sockets")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("reused ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATicketExpires(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket := body["ticket"].(string)
|
||||
|
||||
ts.api.wsTickets.mu.Lock()
|
||||
v := ts.api.wsTickets.m[ticket]
|
||||
v.expires = time.Now().Add(-time.Second)
|
||||
ts.api.wsTickets.m[ticket] = v
|
||||
ts.api.wsTickets.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Error("an expired ticket opened a socket")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||
// on the cookie, and the instance's own page can.
|
||||
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dial := func(origin string) error {
|
||||
h := http.Header{}
|
||||
h.Set("Origin", origin)
|
||||
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := dial("https://evil.example"); err == nil {
|
||||
t.Error("a foreign page opened a socket on the cookie")
|
||||
}
|
||||
if err := dial("capacitor://localhost"); err == nil {
|
||||
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||
}
|
||||
if err := dial("http://localhost"); err != nil {
|
||||
t.Errorf("control: the instance's own origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end suite runs the interface from a second host name and tells the
|
||||
// server to count it as the app's. That origin is then the app's in every
|
||||
// respect, and capacitor://localhost stays one too.
|
||||
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||
})
|
||||
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
// Not a prefix match: a neighbour on another port is nobody.
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the iOS app needs from a server that a browser gets for free by being on
|
||||
// the same origin. The app loads its interface from capacitor://localhost and
|
||||
// talks to whichever server its owner typed in, so every request it makes is
|
||||
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||
// has signed in.
|
||||
|
||||
// appCORS answers the app's origin, and nobody else's.
|
||||
//
|
||||
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||
// reading anything, which is the default this must not loosen.
|
||||
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !s.isAppOrigin(origin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
// Read by the client: a rate limit's wait, and a download's file name.
|
||||
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- the live-sync socket without a cookie -----------------------------------
|
||||
|
||||
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||
// token has no way onto it. Putting the token in the URL would work and would
|
||||
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||
// gone in thirty seconds.
|
||||
|
||||
const wsTicketTTL = 30 * time.Second
|
||||
|
||||
type wsTicket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type wsTickets struct {
|
||||
mu sync.Mutex
|
||||
m map[string]wsTicket
|
||||
}
|
||||
|
||||
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||
|
||||
func (t *wsTickets) issue(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(raw)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||
now := time.Now()
|
||||
for k, v := range t.m {
|
||||
if now.After(v.expires) {
|
||||
delete(t.m, k)
|
||||
}
|
||||
}
|
||||
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||
// presentation finds nothing, whether the first one was in time or not.
|
||||
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
v, ok := t.m[ticket]
|
||||
delete(t.m, ticket)
|
||||
if !ok || time.Now().After(v.expires) {
|
||||
return "", false
|
||||
}
|
||||
return v.userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "issue websocket ticket", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||
// ordinary session check.
|
||||
//
|
||||
// A socket opened with a ticket skips the origin check, and that is the point
|
||||
// rather than a gap: the origin check exists because a browser attaches the
|
||||
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||
// ambient authority for a foreign page to borrow.
|
||||
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// The app never holds a cookie, so a handshake from its origin without a
|
||||
// ticket is not the app. Refused here rather than left to the origin
|
||||
// check, which compares host names only: capacitor://localhost and an
|
||||
// instance served at http://localhost have the same one.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||
return
|
||||
}
|
||||
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
userID, ok := s.wsTickets.redeem(ticket)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||
return
|
||||
}
|
||||
user, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "load user", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||
s.handleWebSocket(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// --- is this a Verdande? -----------------------------------------------------
|
||||
|
||||
// handlePing answers before anybody has signed in, which is when the app needs
|
||||
// it: the person has typed an address and the app has to tell "that is a
|
||||
// Verdande" from "that is a router's login page" before it shows a password
|
||||
// field for it. The edition decides which half of the interface to show.
|
||||
//
|
||||
// No version. /version sits behind a login on purpose, and a public number is a
|
||||
// list of the instances still running an old one.
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"product": "verdande",
|
||||
"edition": s.edition(),
|
||||
"api": 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The iOS app loads its interface from itself — capacitor://localhost — so it
|
||||
// never shares an origin with the server and never gets a cookie back. It signs
|
||||
// in like a browser does and carries the same kind of session, as a bearer token.
|
||||
|
||||
// appDo is a request the way the app makes it: no cookie jar, the token in a
|
||||
// header, and the browser engine reporting the request as cross-site, because to
|
||||
// WKWebView it is.
|
||||
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
"client": "app", "device_name": "Kristians iPhone",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("app login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
token, _ := body["session_token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("no session_token in %v", body)
|
||||
}
|
||||
if body["user"] == nil {
|
||||
t.Error("no user in the response")
|
||||
}
|
||||
|
||||
// The token is the whole credential, from a foreign origin, reads and writes.
|
||||
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
|
||||
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
|
||||
}
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// It is a session, not an API token: it reaches what a token may not, and it
|
||||
// is in the list of devices under the name the app gave itself.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
var found bool
|
||||
for _, raw := range body["sessions"].([]any) {
|
||||
s := raw.(map[string]any)
|
||||
if s["current"] == true {
|
||||
found = true
|
||||
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
|
||||
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the app's own session is not marked current")
|
||||
}
|
||||
|
||||
// And signing out ends it.
|
||||
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the token still works after logout: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
|
||||
// bearer must not work: that would turn the header into a second way past the
|
||||
// SameSite and CSRF locks the cookie sits behind.
|
||||
func TestABrowserSessionIsNotABearer(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Fatal("bootstrap left no session cookie")
|
||||
}
|
||||
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
|
||||
}
|
||||
// Control: the same value is still a perfectly good cookie.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginWithASecondFactor(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
|
||||
secret, _ := body["secret"].(string)
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
token, _ := body["session_token"].(string)
|
||||
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
|
||||
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["user"] != nil {
|
||||
t.Error("the user came back before the code was given")
|
||||
}
|
||||
|
||||
// Half a login reaches nothing, as in a browser, and does not slide yet.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
|
||||
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
session, _, err := ts.db.SessionByToken(t.Context(), token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
|
||||
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
|
||||
}
|
||||
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after the code: %d", resp.StatusCode)
|
||||
}
|
||||
session, _, _ = ts.db.SessionByToken(t.Context(), token)
|
||||
if session.IdleTTL != 7*24*time.Hour {
|
||||
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone in daily use must not sign itself out; one left in a drawer must. The
|
||||
// end moves forward each time the session is used, and a browser's does not.
|
||||
func TestAnAppSessionSlides(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
|
||||
// Pretend it was last used a while ago and is a day from ending.
|
||||
soon := time.Now().Add(24 * time.Hour).Unix()
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
var expires int64
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
|
||||
t.Errorf("using the app session left %v on it, want about the app TTL", left)
|
||||
}
|
||||
|
||||
// Control: a browser session used the same way keeps its fixed end.
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expires != soon {
|
||||
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
field string
|
||||
}{
|
||||
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
|
||||
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
|
||||
} {
|
||||
tc.body["email"] = "kristian@example.dk"
|
||||
tc.body["password"] = "et langt kodeord"
|
||||
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
|
||||
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -33,7 +33,14 @@ type userJSON struct {
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// DefaultReminderMin is the account's standing alarm on tasks that have a
|
||||
// clock, as minutes relative to the due moment and negative for before it.
|
||||
// Null is off, which is where every account starts.
|
||||
//
|
||||
// A pointer rather than a number with a sentinel, so that "off" and "at the
|
||||
// due moment" stay different answers: zero is a real setting here.
|
||||
DefaultReminderMin *int `json:"default_reminder_min"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
@@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON {
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
DefaultReminderMin: u.DefaultReminderMin,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +120,79 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetDefaultReminder turns the account's standing alarm on or off.
|
||||
//
|
||||
// `minutes` is relative to the due moment and negative for before it, which is the
|
||||
// convention reminders.offset_min uses because that is the column it lands in. Null
|
||||
// is off. A positive number is refused: an alarm AFTER the thing it is warning
|
||||
// about is not a reminder, and allowing it here would make the one mistake this
|
||||
// value can make — a sign the wrong way round — look like a choice.
|
||||
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Minutes != nil {
|
||||
switch {
|
||||
case *req.Minutes > 0:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
|
||||
return
|
||||
// A week is the outer edge of "remind me ahead of this"; beyond it the
|
||||
// number is far more likely to be a unit mistake than an intention.
|
||||
case *req.Minutes < -7*24*60:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
|
||||
s.storeError(w, r, "set default reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
|
||||
// what the app calls itself, shown in the list of signed-in devices.
|
||||
Client string `json:"client"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
User *userJSON `json:"user,omitempty"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
// SessionToken is set for an app login only, and instead of the cookie: the
|
||||
// app loads its interface from itself, so it never shares an origin with the
|
||||
// server and a cookie would not come back. It is sent from then on as
|
||||
// `Authorization: Bearer`. A browser never sees it — its session stays in an
|
||||
// httpOnly cookie no script can read.
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
}
|
||||
|
||||
// appClient reports whether a login asked for an app session, and refuses a value
|
||||
// it does not know rather than quietly giving it a browser session: a client that
|
||||
// asked for something else and got a cookie would look signed in until its next
|
||||
// request, and then not be.
|
||||
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
|
||||
switch client {
|
||||
case "", "web":
|
||||
return false, true
|
||||
case "app":
|
||||
if strings.TrimSpace(deviceName) == "" {
|
||||
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -129,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
app, ok := appClient(w, req.Client, req.DeviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
||||
@@ -173,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
ttl := s.cfg.SessionTTL
|
||||
if app {
|
||||
ttl = s.cfg.AppSessionTTL
|
||||
}
|
||||
if user.TOTPEnabled {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
|
||||
if app {
|
||||
// Sliding only once the login is finished; a pending one keeps its ten
|
||||
// minutes and handleLoginTOTP turns it on.
|
||||
idle := s.cfg.AppSessionTTL
|
||||
if user.TOTPEnabled {
|
||||
idle = 0
|
||||
}
|
||||
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
|
||||
s.internal(w, r, "mark app session", err)
|
||||
return
|
||||
}
|
||||
resp.SessionToken = token
|
||||
} else {
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
uj := toUserJSON(user)
|
||||
resp.User = &uj
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
@@ -263,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
// An app's session finishes as a sliding one; it already holds its token and
|
||||
// gets no cookie.
|
||||
if session.Client == "app" {
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
@@ -492,7 +597,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||
// instans", så den følger med uden en ekstra tur.
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Edition string `json:"edition"`
|
||||
}{n == 0, s.edition()})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
|
||||
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
cfg := &config.Config{
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
AppSessionTTL: 7 * 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
}
|
||||
if configure != nil {
|
||||
configure(cfg)
|
||||
|
||||
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "github.com/kristianwind/verdande/internal/config"
|
||||
|
||||
// edition is what this instance tells the world it is.
|
||||
//
|
||||
// Never the empty string, even when the config was built by hand in a test and
|
||||
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||
// full program" from "this build forgot to say", and the first of those is the
|
||||
// answer that has always been true.
|
||||
func (s *Server) edition() string {
|
||||
if s.cfg.NotesOnly() {
|
||||
return config.EditionNotes
|
||||
}
|
||||
return config.EditionFull
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -35,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
//
|
||||
// And note what this list could NOT catch, because it is the shape of mistake
|
||||
// that outlived the first version of this test. The list went green at 29
|
||||
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||
// exports, both task imports, the project templates, the sections and six AI
|
||||
// task endpoints were all still served by the notes edition — because the test
|
||||
// compares the router against THIS LIST, and the list was written from the
|
||||
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||
// is-it-complete check.
|
||||
//
|
||||
// So when a route is added that one edition should not have, the question to
|
||||
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||
// all without the half it is being withheld from". Three of the ones above were
|
||||
// settled by reading rather than by the name: a template turned out to create
|
||||
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/sections/{sectionID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"DELETE /api/v1/templates/{templateID}",
|
||||
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /.well-known/caldav",
|
||||
"GET /api/v1/ai/plan",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/export/projects.zip",
|
||||
"GET /api/v1/export/projects/{projectID}.csv",
|
||||
"GET /api/v1/export/projects/{projectID}.ics",
|
||||
"GET /api/v1/export/tasks.ics",
|
||||
"GET /api/v1/feed",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/projects/{projectID}/sections",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/templates",
|
||||
"GET /api/v1/upcoming",
|
||||
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /ics/{token}",
|
||||
"OPTIONS /caldav/*",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/sections/{sectionID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/ai/inbox/tidy",
|
||||
"POST /api/v1/ai/inbox/tidy/apply",
|
||||
"POST /api/v1/ai/plan/now",
|
||||
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||
"POST /api/v1/feed/rotate",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/import/csv",
|
||||
"POST /api/v1/import/todoist",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/projects/{projectID}/sections",
|
||||
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
@@ -66,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
"POST /api/v1/templates",
|
||||
"POST /api/v1/templates/{templateID}/use",
|
||||
"POST /inbound/hook/{token}",
|
||||
"PROPFIND /caldav",
|
||||
"PROPFIND /caldav/{userID}",
|
||||
"PROPFIND /caldav/{userID}/{projectID}",
|
||||
"PUT /api/v1/ai/plan",
|
||||
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"REPORT /caldav/{userID}/{projectID}",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
@@ -114,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"PATCH /api/v1/notes/{noteID}",
|
||||
"DELETE /api/v1/notes/{noteID}",
|
||||
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||
"POST /api/v1/notes/{noteID}/shares",
|
||||
"GET /api/v1/notes/{noteID}/shares",
|
||||
"POST /api/v1/notes/{noteID}/attachments",
|
||||
"POST /api/v1/notes/import",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/export/account",
|
||||
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
@@ -158,3 +225,151 @@ func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The interface cannot gate itself on something it is not told, and it has to be
|
||||
// told before anybody is signed in — an instance's shape is not a permission.
|
||||
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||
for _, tc := range []struct{ edition, want string }{
|
||||
{config.EditionFull, "full"},
|
||||
{config.EditionNotes, "notes"},
|
||||
{"", "full"}, // a config built by hand is the program as it has always been
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if got := body["edition"]; got != tc.want {
|
||||
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
// And the field it has always had is still there: adding one must not
|
||||
// quietly replace the answer the sign-in screen depends on.
|
||||
if _, ok := body["needs_setup"]; !ok {
|
||||
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||
// so it is the one door that could still open onto the half the router just shut.
|
||||
//
|
||||
// Not a theoretical case. An instance converted from a full one still has the
|
||||
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want int
|
||||
}{
|
||||
{config.EditionFull, 1},
|
||||
{config.EditionNotes, 0},
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
userID, _ := me["id"].(string)
|
||||
if userID == "" {
|
||||
t.Fatalf("no user: %v", me)
|
||||
}
|
||||
|
||||
// Created through the store rather than the API: in the notes edition there
|
||||
// is no route to create one, and the row this is about is the row that is
|
||||
// already there.
|
||||
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||
found, _ := body["tasks"].([]any)
|
||||
if len(found) != tc.want {
|
||||
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||
}
|
||||
// The full edition's number is the control: a search that finds nothing at
|
||||
// all would satisfy the notes case for the wrong reason.
|
||||
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||
//
|
||||
// The same hole as the routes, through a different door — and it was still open
|
||||
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||
// to a notes instance would be told it can create_task, call it, get something
|
||||
// back, and tell the person their task was filed.
|
||||
//
|
||||
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||
// what a client actually sees, and the slice is unexported for the same reason.
|
||||
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||
|
||||
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
names := mcpToolNames(t, edition)
|
||||
|
||||
// Both editions: the shared tool, and the notes half.
|
||||
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||
if !names[want] {
|
||||
t.Errorf("%s edition does not offer %s", edition, want)
|
||||
}
|
||||
}
|
||||
for _, task := range taskTools {
|
||||
switch edition {
|
||||
case config.EditionFull:
|
||||
if !names[task] {
|
||||
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||
}
|
||||
default:
|
||||
if names[task] {
|
||||
t.Errorf("the notes edition offers %s over MCP", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d tools", edition, len(names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if resp == nil {
|
||||
t.Fatal("tools/list got no response")
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct{ Name string } `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||
}
|
||||
if len(parsed.Result.Tools) == 0 {
|
||||
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
|
||||
@@ -23,7 +23,9 @@ import (
|
||||
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
||||
// and a description that only names the endpoint gives it nothing to decide with.
|
||||
func (s *Server) buildMCP() *mcp.Server {
|
||||
m := mcp.NewServer("verdande", "1")
|
||||
// Udgavens eget navn: det er sådan, instansen står i klientens
|
||||
// forbindelsesliste.
|
||||
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "list_projects",
|
||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}),
|
||||
}, s.mcpListProjects)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||
if !s.cfg.NotesOnly() {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
}
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
|
||||
@@ -18,6 +18,8 @@ type ctxKey int
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
||||
ctxTicket
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
@@ -126,9 +128,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// authenticate resolves a request to a user, by session cookie or by API token.
|
||||
// authenticate resolves a request to a user, by session cookie, by API token, or by
|
||||
// an app's session sent as a bearer token.
|
||||
//
|
||||
// Both are accepted everywhere. An API token belongs to a person and carries their
|
||||
// All are accepted everywhere. An API token belongs to a person and carries their
|
||||
// permissions exactly — there is no separate service identity to reason about, and
|
||||
// a script therefore cannot reach anything its owner could not.
|
||||
// errNoCredentials means the request carried nothing to authenticate with, as
|
||||
@@ -145,6 +148,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
|
||||
return user, nil, nil
|
||||
}
|
||||
|
||||
// Any other bearer is an app session (see loginResponse.SessionToken). It is
|
||||
// accepted only for a session that was issued to the app: a browser's session
|
||||
// never leaves its httpOnly cookie, and a cookie value found somewhere and
|
||||
// replayed as a header must not become a second way to use it.
|
||||
if token := bearerToken(r); token != "" {
|
||||
session, user, err := s.db.SessionByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if session.Client != "app" {
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, nil, errNoCredentials
|
||||
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// The iOS app, before it has a token: its sign-in. A web page cannot send
|
||||
// this Origin — the browser writes the header, not the page — and an app
|
||||
// login sets no cookie, so there is no session here for a forged request
|
||||
// to ride on.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
||||
switch r.Header.Get("Sec-Fetch-Site") {
|
||||
@@ -220,6 +246,28 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// appOrigins are where the iOS app's interface is served from. Capacitor loads
|
||||
// the bundled build on this scheme inside WKWebView, so every request the app
|
||||
// makes to a server arrives from here, whichever server it is.
|
||||
var appOrigins = []string{"capacitor://localhost"}
|
||||
|
||||
func (s *Server) isAppOrigin(origin string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range appOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, o := range s.cfg.AppOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isOwnOrigin(origin string) bool {
|
||||
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
||||
return true
|
||||
|
||||
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// The socket is same-origin only. Without this, any page on the internet
|
||||
// could open a socket carrying the user's cookie and watch their tasks —
|
||||
// the same-origin policy does not apply to WebSocket handshakes.
|
||||
OriginPatterns: s.allowedOrigins(),
|
||||
// Except one opened with a ticket, which carries no cookie for a foreign
|
||||
// page to borrow: see handleWebSocketEntry.
|
||||
InsecureSkipVerify: ticketed,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("websocket handshake", "err", err)
|
||||
|
||||
+212
-47
@@ -3,6 +3,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -59,6 +60,10 @@ type Server struct {
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// One-use tickets for opening the live-sync socket without a cookie — see
|
||||
// app_handlers.go.
|
||||
wsTickets *wsTickets
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -77,6 +82,20 @@ type Server struct {
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
// The shell and the manifest with this edition's name in them, or nil to serve
|
||||
// the built files unchanged. Only the notes edition needs them; see shell.go.
|
||||
shell []byte
|
||||
manifest []byte
|
||||
|
||||
// Icon paths this edition serves from another file. Empty in the full edition.
|
||||
assets map[string]string
|
||||
|
||||
// The second door: /urd and its own web manifest. Built on every instance,
|
||||
// whatever the edition, because it is how the notes face is installed as its
|
||||
// own app rather than a property of the instance. See shell.go.
|
||||
urdShell []byte
|
||||
urdManifest []byte
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -86,7 +105,7 @@ type Server struct {
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
@@ -100,9 +119,55 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
wsTickets: newWSTickets(),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
//
|
||||
// A failure here is logged rather than fatal, and that is a judgement about
|
||||
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
|
||||
// refuses to start, because serving task routes on an instance set up without
|
||||
// them is a different kind of wrong. A shell whose title says the sibling's
|
||||
// name is cosmetic — it must be loud, and it must not take the instance down.
|
||||
// What makes it loud is not this line: the end-to-end suite reads the served
|
||||
// title, so an anchor that falls out of app.html goes red there.
|
||||
if web != nil && cfg.NotesOnly() {
|
||||
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
|
||||
if err != nil {
|
||||
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
|
||||
"edition", cfg.Edition, "err", err)
|
||||
} else {
|
||||
s.shell, s.manifest = shell, manifest
|
||||
}
|
||||
|
||||
s.assets = resolveNotesAssets(web, func(name, alias string) {
|
||||
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
|
||||
"path", name, "wanted", alias)
|
||||
})
|
||||
}
|
||||
|
||||
// The second door, on every edition. A notes instance already calls itself urd
|
||||
// at the root; the door is still built there so the two addresses behave the
|
||||
// same, and so a single test does not have to know which edition it is in.
|
||||
if web != nil {
|
||||
urdShell, urdManifest, err := urdDoorAssets(web)
|
||||
if err != nil {
|
||||
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
|
||||
} else {
|
||||
s.urdShell, s.urdManifest = urdShell, urdManifest
|
||||
}
|
||||
}
|
||||
|
||||
if s.shell != nil {
|
||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||
} else {
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
}
|
||||
// The second argument is WebAuthn's display name, not its relying party id —
|
||||
// the id comes from BaseURL. So this is what the operating system's passkey
|
||||
// prompt calls the site, and the slug keeps it identical to what every existing
|
||||
// verdande instance already registered.
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
@@ -125,13 +190,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The same question as inside /api/v1, asked again here because four of the
|
||||
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
|
||||
// hook, and the well-known path CalDAV clients discover through. They are out
|
||||
// here because their callers cannot hold a session, not because they are any
|
||||
// less about tasks — and the first gate missed all four for exactly that
|
||||
// reason. It read as complete because the /tasks tree was covered.
|
||||
full := !s.cfg.NotesOnly()
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
//
|
||||
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
|
||||
if full {
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
//
|
||||
// Withheld rather than left to answer an empty collection: the well-known
|
||||
// path is a claim that a CalDAV server is here, and a client that believes
|
||||
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
|
||||
// broken calendar server on an instance that has none and needs none. Same
|
||||
// reasoning as the /.well-known/ 404 in serveWeb.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
}
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
@@ -156,8 +239,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
//
|
||||
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
|
||||
// i indbakken — så den findes ikke i noter-udgaven.
|
||||
if full {
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
}
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
@@ -184,14 +272,21 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
|
||||
if full {
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||
r.Use(s.appCORS)
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/ping", s.handlePing)
|
||||
|
||||
// The live-sync socket checks its own credential: a ticket, or the
|
||||
// session as everywhere else. See handleWebSocketEntry.
|
||||
r.Get("/ws", s.handleWebSocketEntry)
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
@@ -242,6 +337,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Put("/default-reminder", s.handleSetDefaultReminder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
@@ -291,7 +387,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
r.Post("/ws-ticket", s.handleWSTicket)
|
||||
|
||||
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||
// sees exactly what its owner sees.
|
||||
@@ -312,7 +408,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
|
||||
// findes, er en indstillingsside, der tilbyder en adresse, intet
|
||||
// svarer på.
|
||||
if full {
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
}
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotifications)
|
||||
@@ -338,7 +439,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateComment)
|
||||
r.Delete("/", s.handleDeleteComment)
|
||||
})
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
if full {
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
}
|
||||
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
@@ -359,20 +462,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
if full {
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
}
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
if full {
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
}
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
@@ -457,24 +564,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
|
||||
// frem for at fejle, hvilket er den stille version af samme fejl.
|
||||
if full {
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
if full {
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
}
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||
// overskrifter i.
|
||||
if full {
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
@@ -505,7 +626,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireProject(store.RoleViewer))
|
||||
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
// Sektioner grupperer opgaver inde i et projekt, og det er
|
||||
// afgjort af skemaet frem for af navnet: `section_id` står på
|
||||
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
|
||||
// kan ikke indeholde noget.
|
||||
if full {
|
||||
r.Get("/sections", s.handleListSections)
|
||||
}
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
@@ -514,8 +641,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
if full {
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
}
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -590,10 +719,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
if full {
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
@@ -685,7 +816,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
|
||||
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -694,6 +825,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
name = "index.html"
|
||||
}
|
||||
|
||||
// The second door, before anything else touches the filesystem: /urd is not a
|
||||
// file, so the fallback below would hand back the other door's shell.
|
||||
if b := s.door(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
// This edition's own icon, where it has one. Before the Open rather than after,
|
||||
// unlike the shell below: these are real files, so there is no fallback to get
|
||||
// in the way, and resolveNotesAssets has already checked each one exists.
|
||||
if alias, ok := s.assets[name]; ok {
|
||||
name = alias
|
||||
}
|
||||
|
||||
f, err := s.web.Open(name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// A client-side route: hand back the shell and let the app route it.
|
||||
@@ -706,6 +853,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Only here is `name` settled, and that matters: every client-side route —
|
||||
// /noter and all the rest — arrives as a path with no file behind it and is
|
||||
// resolved to index.html by the fallback above. Asking before the fallback
|
||||
// would have renamed the shell for a request to "/" and handed back the built
|
||||
// one for every other route in the app.
|
||||
//
|
||||
// These two assets never reach the filesystem: they are the built files with
|
||||
// this edition's name in them, held in memory since startup. See shell.go.
|
||||
if b := s.renamed(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
// A zero modification time tells ServeContent to send no Last-Modified and
|
||||
// to answer no conditional request — right for a shell that must never be
|
||||
// cached, and the same thing the built shell gets below.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
|
||||
@@ -23,8 +23,11 @@ type sessionJSON struct {
|
||||
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||
// the summary is what you read and the original is what settles the question
|
||||
// when the summary is not enough.
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// Client is "web" or "app". An app session slides — its expires_at moves
|
||||
// forward each time it is used — where a browser's is fixed.
|
||||
Client string `json:"client"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
@@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
out := make([]sessionJSON, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
// The app names itself; a browser is described from its user agent.
|
||||
device := sess.DeviceName
|
||||
if device == "" {
|
||||
device = describeUserAgent(sess.UA)
|
||||
}
|
||||
out = append(out, sessionJSON{
|
||||
ID: sess.ID,
|
||||
Current: current != nil && sess.ID == current.ID,
|
||||
Device: describeUserAgent(sess.UA),
|
||||
Device: device,
|
||||
UserAgent: sess.UA,
|
||||
Client: sess.Client,
|
||||
IP: sess.IP,
|
||||
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The product name is in the built frontend as a literal in four places, because
|
||||
// all four are read before any JavaScript runs: the window title and the meta
|
||||
// description in the HTML shell, and the two names in the web manifest that decide
|
||||
// what an installed PWA is called on a home screen.
|
||||
//
|
||||
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
||||
// first script executes, the manifest is fetched by the browser rather than by us,
|
||||
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
||||
// that renamed itself after loading would show the sibling's name first and correct
|
||||
// itself a moment later, on every load, in the tab and on the install prompt.
|
||||
//
|
||||
// So the server does it, once at startup, on the way out. One frontend build, two
|
||||
// names: the build is identical in both editions and the bytes served differ.
|
||||
//
|
||||
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
||||
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody files it and
|
||||
// nothing else notices.
|
||||
type rebrand struct {
|
||||
old, new string
|
||||
}
|
||||
|
||||
// shellRebrands returns the replacements for index.html.
|
||||
//
|
||||
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
||||
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
||||
// rest of the app reads the same three, so renaming them here and nowhere else
|
||||
// would set the theme from one key and read it from another, and the only symptom
|
||||
// is the white flash that script exists to prevent. This is why the replacements
|
||||
// are written out as whole anchored strings rather than as a replace of the word
|
||||
// "verdande".
|
||||
func shellRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + description + `" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
||||
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
||||
// form, so it is passed separately rather than derived.
|
||||
func manifestRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
||||
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
||||
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
||||
}
|
||||
}
|
||||
|
||||
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
||||
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
||||
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
||||
// about which program this is.
|
||||
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
||||
out := raw
|
||||
for _, p := range pairs {
|
||||
old := []byte(p.old)
|
||||
if bytes.Count(out, old) != 1 {
|
||||
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
||||
}
|
||||
out = bytes.Replace(out, old, []byte(p.new), 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
||||
// frontend and returns them renamed for this edition.
|
||||
//
|
||||
// Both or neither: an instance serving a renamed shell beside the sibling's
|
||||
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
||||
// at until it is on their home screen.
|
||||
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// The descriptions for the notes edition. Kept beside the replacements they feed
|
||||
// rather than in the locale files, because these two strings are read by a browser
|
||||
// and a home screen before any locale has been chosen — the shell is in Danish for
|
||||
// the same reason the built app.html is.
|
||||
const (
|
||||
notesShellDescription = "urd — noter, delt."
|
||||
notesManifestDescription = "Noter, delt."
|
||||
)
|
||||
|
||||
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
||||
//
|
||||
// Nil for everything in the full edition, which is what keeps that path exactly as
|
||||
// it was: one comparison per request against a nil slice.
|
||||
func (s *Server) renamed(name string) []byte {
|
||||
switch name {
|
||||
case "index.html":
|
||||
return s.shell
|
||||
case "manifest.webmanifest":
|
||||
return s.manifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFor names the type for the two assets served from memory.
|
||||
//
|
||||
// http.ServeContent sniffs from the extension, and it gets .html right and
|
||||
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
||||
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
||||
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||
// does not appear.
|
||||
func contentTypeFor(name string) string {
|
||||
// Any .webmanifest, not the one name — there are two of them now, and the
|
||||
// second one was served as HTML for about four minutes because this read
|
||||
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
|
||||
// with the wrong type is ignored silently, and the only symptom is an install
|
||||
// prompt that does not appear.
|
||||
if strings.HasSuffix(name, ".webmanifest") {
|
||||
return "application/manifest+json"
|
||||
}
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
// notesAssetAliases maps the icon paths the shell and the manifest already point
|
||||
// at to urd's own files. Both sets are in the binary; the server decides.
|
||||
//
|
||||
// Aliased rather than renamed in the manifest, and the reason is the third entry.
|
||||
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
|
||||
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
|
||||
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
|
||||
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
|
||||
// fixes all four at once, including the favicon in `app.html`, which is not in the
|
||||
// manifest either.
|
||||
var notesAssetAliases = map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}
|
||||
|
||||
// resolveNotesAssets returns the aliases that have a file behind them, and reports
|
||||
// the ones that do not.
|
||||
//
|
||||
// Resolved once at startup rather than per request, so a missing file is one loud
|
||||
// line in the log instead of a silent fallback on every page load. The fallback is
|
||||
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
|
||||
// absence turns into a broken-image placeholder on the Updates screen — which is
|
||||
// exactly how four releases of nolimit-views shipped with no icon — so serving the
|
||||
// wrong mark beats serving none.
|
||||
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, alias := range notesAssetAliases {
|
||||
if _, err := fs.Stat(web, alias); err != nil {
|
||||
missing(name, alias)
|
||||
continue
|
||||
}
|
||||
out[name] = alias
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The second door.
|
||||
//
|
||||
// One instance, one database, one login — and two things you can install. urd is
|
||||
// reached at /urd, which serves the same single-page app under its own identity:
|
||||
// its own title, its own mark, and its own web manifest.
|
||||
//
|
||||
// The manifest is the part that matters and the part that is easy to get wrong.
|
||||
// An operating system does not install a URL, it installs a *manifest*, and the
|
||||
// identity of an installed app is the manifest's `id`. Two manifests on one origin
|
||||
// with different ids are two installable apps; two URLs sharing one manifest are
|
||||
// one app with a bookmark.
|
||||
//
|
||||
// And the shell has to be served per door rather than patched by JavaScript,
|
||||
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
|
||||
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
|
||||
// manifest's icons entirely. A head rewritten after load is a head that may or may
|
||||
// not be the one iOS read.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
|
||||
// because those two being the same string is the whole mechanism.
|
||||
const urdDoorPath = "/urd"
|
||||
|
||||
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
|
||||
// this door is: what the tab says, what a crawler reads, which manifest the browser
|
||||
// fetches, and which icons it finds without the manifest.
|
||||
//
|
||||
// Note what is NOT in here, for the same reason as in shellRebrands above: the
|
||||
// three `verdande:` localStorage keys in the inline theme script. They are storage
|
||||
// rather than a name, the rest of the app reads them, and — now more than before —
|
||||
// the two doors share an origin and therefore share that storage. Renaming them
|
||||
// here would give the two apps different themes by accident and lose everybody's
|
||||
// setting on the way.
|
||||
func urdShellRebrands() []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>urd</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + notesShellDescription + `" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="manifest" href="/manifest.webmanifest" />`,
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
},
|
||||
{
|
||||
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// urdManifest builds the second door's manifest from the one the frontend build
|
||||
// produced, rather than writing a second one by hand.
|
||||
//
|
||||
// Transformed as JSON rather than by string replacement, which the shell above
|
||||
// does: a manifest needs keys the built one does not have — `id` and `start_url`
|
||||
// — so there is nothing to anchor a replacement on, and a hand-written copy would
|
||||
// drift on the fields the two genuinely share (the colours, the display mode, the
|
||||
// language).
|
||||
//
|
||||
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
|
||||
// out even though "/" is what the spec would default to from a start_url of
|
||||
// "/urd": an app whose scope did not cover the whole origin would open its own
|
||||
// links in a browser tab the first time somebody navigated out of /urd, and that
|
||||
// is a rule nobody should have to remember while reading this.
|
||||
func urdManifest(raw []byte) ([]byte, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
|
||||
}
|
||||
|
||||
m["id"] = urdDoorPath
|
||||
m["start_url"] = urdDoorPath
|
||||
m["scope"] = "/"
|
||||
m["name"] = "urd"
|
||||
m["short_name"] = "urd"
|
||||
m["description"] = notesManifestDescription
|
||||
|
||||
icons, ok := m["icons"].([]any)
|
||||
if !ok || len(icons) == 0 {
|
||||
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
icon, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is not an object", i)
|
||||
}
|
||||
src, _ := icon["src"].(string)
|
||||
// Every icon must be one this door has a file for. Refusing an unknown one
|
||||
// is the point: an icon added to the manifest and not to this map would
|
||||
// otherwise leave urd's install prompt showing Verdande's mark, which is
|
||||
// cosmetic enough that nobody files it.
|
||||
swapped, ok := map[string]string{
|
||||
"/icon.svg": "/urd-icon.svg",
|
||||
"/icon-192.png": "/urd-icon-192.png",
|
||||
"/icon-512.png": "/urd-icon-512.png",
|
||||
}[src]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
|
||||
}
|
||||
icon["src"] = swapped
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(out, '\n'), nil
|
||||
}
|
||||
|
||||
// urdDoorAssets builds the second door from the files the frontend build produced.
|
||||
//
|
||||
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
|
||||
// that manifest is not being served is an install prompt that silently falls back
|
||||
// to the other app's identity.
|
||||
func urdDoorAssets(web fs.FS) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, urdShellRebrands())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = urdManifest(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// door returns the bytes for the second door's two paths, or nil.
|
||||
//
|
||||
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
|
||||
// below: /urd is not a file and never will be, so it has to be answered before the
|
||||
// fallback to index.html gets to it — which would serve the other door's shell
|
||||
// under this door's address, with no error anywhere.
|
||||
func (s *Server) door(name string) []byte {
|
||||
switch name {
|
||||
case "urd":
|
||||
return s.urdShell
|
||||
case "urd.webmanifest":
|
||||
return s.urdManifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// serverWithWeb builds the real router around a frontend, which newTestServerWith
|
||||
// cannot do — it passes nil for the web filesystem, because every other test here
|
||||
// is about the API. The renaming only happens when there is something to rename.
|
||||
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
Edition: edition,
|
||||
}
|
||||
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
|
||||
}
|
||||
|
||||
// The shell as the frontend build produces it, cut down to the parts that matter
|
||||
// here: the two anchors that get renamed, and the inline theme script that must
|
||||
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
|
||||
// honest against the real file.
|
||||
const builtShell = `<!doctype html>
|
||||
<html lang="da" data-theme="dark">
|
||||
<head>
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('verdande:theme');
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const builtManifest = `{
|
||||
"name": "verdande",
|
||||
"short_name": "verdande",
|
||||
"description": "Opgaver og projekter, delt.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111312",
|
||||
"theme_color": "#111312",
|
||||
"lang": "da",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
func builtWeb() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
"_app/immutable/app.js": {Data: []byte("// app")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
|
||||
//
|
||||
// The product name and the localStorage prefix are the same word in app.html, and
|
||||
// they are not the same thing: the prefix is read back by the app itself, so
|
||||
// renaming it here and nowhere else sets the theme from one key and reads it from
|
||||
// another. The symptom is the white flash on load that the inline script exists to
|
||||
// prevent — which is to say, no error, nothing in a log, and nothing a test that
|
||||
// merely checked the title would see.
|
||||
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
|
||||
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
got := string(shell)
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`content="urd — noter, delt."`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// Named values rather than "no occurrences of verdande": the three keys are
|
||||
// what has to survive, so the assertion says the three keys.
|
||||
for _, want := range []string{
|
||||
`localStorage.getItem('verdande:theme')`,
|
||||
`localStorage.getItem('verdande:look')`,
|
||||
`look !== 'verdande'`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
|
||||
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
|
||||
}
|
||||
|
||||
gotManifest := string(manifest)
|
||||
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
|
||||
if !strings.Contains(gotManifest, want) {
|
||||
t.Errorf("the manifest does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
|
||||
// files that is easy to break and impossible to notice: the policy names a hash of
|
||||
// the inline script, and the renamed shell is what gets served. If a replacement
|
||||
// ever reached inside the <script>, the hash would change, the browser would refuse
|
||||
// to run it, and the page would be blank — in production only, because the policy
|
||||
// is not enforced for a page opened from a file.
|
||||
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
|
||||
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
before := scriptHashesIn([]byte(builtShell))
|
||||
after := scriptHashesIn(shell)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
|
||||
}
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
|
||||
// happy path. A replacement that silently matched nothing leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
|
||||
// rewrite has to say so rather than return what it managed.
|
||||
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
|
||||
cases := map[string]fstest.MapFS{
|
||||
"no title": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no description": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest name": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
|
||||
},
|
||||
"no index.html at all": {
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest at all": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
},
|
||||
}
|
||||
for name, web := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
|
||||
t.Error("renaming reported success over a shell it could not rename")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
|
||||
// frozen copy of a file that has moved on.
|
||||
//
|
||||
// The anchors are exact strings in web/src/app.html, and that file is edited for
|
||||
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
|
||||
// title line or rewrites the description breaks the rename and nothing in Go would
|
||||
// know, because the Go build graph has never heard of app.html.
|
||||
//
|
||||
// Note the caching trap that comes with reading a non-Go file from a test: `go
|
||||
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
|
||||
// both workflows; a local run needs it typed.
|
||||
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../web/src/app.html")
|
||||
if err != nil {
|
||||
t.Skipf("app.html is not reachable from here: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
|
||||
// Both sets: the edition's rename and the second door's. The door was added
|
||||
// after this test and its anchors were NOT covered, which is how its fixture
|
||||
// came to be missing two <link> lines the real file has — caught only because
|
||||
// applyRebrands refuses an anchor it cannot find.
|
||||
for what, pairs := range map[string][]rebrand{
|
||||
"the notes edition's rename": shellRebrands("urd", "d"),
|
||||
"the urd door": urdShellRebrands(),
|
||||
} {
|
||||
for _, pair := range pairs {
|
||||
if n := strings.Count(src, pair.old); n != 1 {
|
||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
|
||||
n, pair.old, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||
// somebody would rename them, and if they are renamed there they must be
|
||||
// renamed in stores.svelte.js in the same commit.
|
||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||
}
|
||||
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
|
||||
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
|
||||
}
|
||||
|
||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||
// is about the renamed path; this says the other edition does not go through it.
|
||||
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
|
||||
for _, edition := range []string{"full", "notes"} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, edition, builtWeb())
|
||||
if edition == "full" {
|
||||
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
|
||||
t.Error("the full edition renamed the shell")
|
||||
}
|
||||
return
|
||||
}
|
||||
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
|
||||
t.Fatal("the notes edition did not rename the shell")
|
||||
}
|
||||
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
|
||||
t.Error("the notes edition's shell does not say urd")
|
||||
}
|
||||
if srv.renamed("_app/immutable/app.js") != nil {
|
||||
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
|
||||
func webWithIcons() fstest.MapFS {
|
||||
web := builtWeb()
|
||||
for _, name := range []string{
|
||||
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
|
||||
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
|
||||
} {
|
||||
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
|
||||
}
|
||||
return web
|
||||
}
|
||||
|
||||
// TestEachEditionServesItsOwnMark.
|
||||
//
|
||||
// Four paths, named one by one rather than counted: the favicon in app.html, the
|
||||
// manifest's two, and the one Safari looks for by name. The last is the reason
|
||||
// this is an alias rather than a rewritten manifest — Safari does not read the
|
||||
// manifest at all.
|
||||
func TestEachEditionServesItsOwnMark(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want map[string]string
|
||||
}{
|
||||
{"notes", map[string]string{
|
||||
"icon.svg": "urd-icon.svg",
|
||||
"icon-192.png": "urd-icon-192.png",
|
||||
"icon-512.png": "urd-icon-512.png",
|
||||
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
||||
}},
|
||||
// The control, and it is the half that stops this being a swap rather than
|
||||
// a choice: the full edition must resolve nothing.
|
||||
{"full", map[string]string{}},
|
||||
} {
|
||||
t.Run(tc.edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, tc.edition, webWithIcons())
|
||||
if len(srv.assets) != len(tc.want) {
|
||||
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
|
||||
}
|
||||
for path, alias := range tc.want {
|
||||
if srv.assets[path] != alias {
|
||||
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
|
||||
}
|
||||
}
|
||||
// And nothing else: an alias on a build asset would serve one file's
|
||||
// bytes under another's hashed, immutable name.
|
||||
for path := range srv.assets {
|
||||
if _, ok := tc.want[path]; !ok {
|
||||
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMissingMarkIsReportedAndFallsBack.
|
||||
//
|
||||
// The fallback is deliberate and it is the only place in this file that prefers
|
||||
// carrying on to failing: an icon's absence is a broken-image placeholder on the
|
||||
// one screen somebody reads before deciding whether to press update, so the wrong
|
||||
// mark beats no mark. What must not happen is it being quiet.
|
||||
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
|
||||
web := webWithIcons()
|
||||
delete(web, "urd-icon-512.png")
|
||||
|
||||
var reported []string
|
||||
got := resolveNotesAssets(web, func(name, alias string) {
|
||||
reported = append(reported, name+" → "+alias)
|
||||
})
|
||||
|
||||
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
|
||||
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
|
||||
}
|
||||
if _, ok := got["icon-512.png"]; ok {
|
||||
t.Error("a path with no file behind it was aliased anyway")
|
||||
}
|
||||
if got["icon.svg"] != "urd-icon.svg" {
|
||||
t.Error("one missing icon took the other three with it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheUrdDoorIsItsOwnApp.
|
||||
//
|
||||
// An operating system does not install a URL, it installs a manifest, and the
|
||||
// identity of an installed app is that manifest's `id`. So this asserts the two
|
||||
// things that decide whether /urd becomes a second icon on a home screen or just a
|
||||
// bookmark into the first one — and it asserts them as named values, because an
|
||||
// empty manifest, verdande's manifest and urd's all parse as JSON.
|
||||
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
|
||||
srv := serverWithWeb(t, "full", webWithIcons())
|
||||
|
||||
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
|
||||
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
|
||||
}
|
||||
|
||||
shell := string(srv.door("urd"))
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||
// iOS reads this one by name and has historically ignored the manifest's
|
||||
// icons entirely, which is why the shell is served per door rather than
|
||||
// patched by script after load.
|
||||
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||
} {
|
||||
if !strings.Contains(shell, want) {
|
||||
t.Errorf("the urd door's shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// The same trap as the edition shell: the two doors share an origin and
|
||||
// therefore share localStorage, so renaming a storage key here would give the
|
||||
// two apps different themes by accident.
|
||||
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
|
||||
t.Fatalf("the urd manifest is not JSON: %v", err)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
// id is the install identity. Without it, two manifests on one origin are
|
||||
// one app with two start urls.
|
||||
"id": "/urd",
|
||||
"start_url": "/urd",
|
||||
"scope": "/",
|
||||
"name": "urd",
|
||||
"short_name": "urd",
|
||||
} {
|
||||
if m[key] != want {
|
||||
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
|
||||
}
|
||||
}
|
||||
// And the shared half, to prove it was transformed rather than written out: a
|
||||
// hand-written copy would drift on exactly these.
|
||||
var built map[string]any
|
||||
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
|
||||
_ = json.Unmarshal(raw, &built)
|
||||
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
|
||||
if _, ok := built[key]; ok && m[key] != built[key] {
|
||||
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
|
||||
}
|
||||
}
|
||||
|
||||
icons, _ := m["icons"].([]any)
|
||||
if len(icons) == 0 {
|
||||
t.Fatal("the urd manifest has no icons")
|
||||
}
|
||||
for i, entry := range icons {
|
||||
src, _ := entry.(map[string]any)["src"].(string)
|
||||
if !strings.HasPrefix(src, "/urd-") {
|
||||
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
|
||||
}
|
||||
}
|
||||
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
|
||||
}
|
||||
|
||||
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
|
||||
// added to the built manifest and not to urd's map would otherwise leave urd's
|
||||
// install prompt showing Verdande's mark, which nobody reports.
|
||||
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
|
||||
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
|
||||
if _, err := urdManifest(raw); err == nil {
|
||||
t.Error("urdManifest accepted an icon it has no file for")
|
||||
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
|
||||
t.Errorf("the error does not name the icon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
|
||||
// by the browser with no error anywhere; the only symptom is that the install
|
||||
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
|
||||
// second door was added.
|
||||
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
|
||||
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
|
||||
if got := contentTypeFor(name); got != "application/manifest+json" {
|
||||
t.Errorf("%s is served as %q", name, got)
|
||||
}
|
||||
}
|
||||
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
|
||||
t.Errorf("index.html is served as %q", got)
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
// The store wrote the labels; the struct it was handed does not know that, and
|
||||
// the response has to describe the task that now exists.
|
||||
t.Labels = req.Labels
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||
@@ -337,6 +341,12 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
// Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den
|
||||
// halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid".
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||
@@ -620,14 +630,55 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// The alarm the line asked for, after the task because a reminder needs its id.
|
||||
//
|
||||
// Set when the line asked to be reminded and the task has nothing to count back
|
||||
// from. An offset is relative to the due moment, and DueReminders selects the
|
||||
// offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day
|
||||
// but no clock there is nothing to be an hour before, and the row would sit in
|
||||
// the table forever without firing. Reported the same way an unknown project
|
||||
// is: the task is created either way, and the caller is told what could not be
|
||||
// honoured. An alarm that silently never rings is the one way a reminder must
|
||||
// not fail, because nothing afterwards reveals it.
|
||||
reminderNotSet := ""
|
||||
switch {
|
||||
case len(parsed.ReminderAt) == len("2006-01-02T15:04"):
|
||||
day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:]
|
||||
if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil {
|
||||
if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil {
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
case parsed.ReminderOffsetMin != nil:
|
||||
if t.DueDatetime == nil {
|
||||
reminderNotSet = "no due time to count back from"
|
||||
} else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil {
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har
|
||||
// allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen
|
||||
// alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden,
|
||||
// mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen.
|
||||
if !parsed.NoReminder {
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if unknownProject != "" || unknownSection != "" || reminderNotSet != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
ReminderNotSet string `json:"reminder_not_set,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
@@ -804,3 +855,38 @@ func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
// applyDefaultReminder gives a task the alarm the account asks for without being
|
||||
// asked each time.
|
||||
//
|
||||
// Three things make it a no-op, and each of them is the point rather than a guard:
|
||||
//
|
||||
// - The account has the default off. That is where every account starts, and it
|
||||
// stays there until somebody turns it on: switching it on in a migration would
|
||||
// have given every existing user alarms they never asked for, on tasks already
|
||||
// lying there, in software other people run themselves.
|
||||
// - The task has no clock. A task due "tomorrow" has no moment to ring at, and
|
||||
// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` —
|
||||
// so the row would sit there and never fire.
|
||||
// - The task already has a reminder. The line may have asked for one itself, or
|
||||
// the task may be being edited for the second time; either way the default is
|
||||
// a default and must not stack a second alarm on top of a stated one.
|
||||
//
|
||||
// Called on every path a task can gain a clock — created, edited, or quick-added —
|
||||
// because a default that only applies to one of them is a setting that is true on
|
||||
// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple
|
||||
// Reminders brings its own alarm, and two alarms for one task is worse than none.
|
||||
func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error {
|
||||
if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.db.ListReminders(ctx, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2080,3 +2080,390 @@ func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- alarms written into the line ---------------------------------------------
|
||||
|
||||
// A dictated sentence sets its own alarm, and the alarm actually goes off.
|
||||
//
|
||||
// The assertion is on DueReminders rather than on a row existing, and the
|
||||
// difference is the whole test: a reminder row with the offset the wrong way round
|
||||
// exists just as convincingly and rings an hour LATE. So the question asked here is
|
||||
// "would this fire, and when" — one minute before it is due it must not be in the
|
||||
// list, one minute after it must.
|
||||
func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til tandlægen i morgen kl 9 mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "ring til tandlægen" {
|
||||
t.Errorf("content = %v, want %q", body["content"], "ring til tandlægen")
|
||||
}
|
||||
if body["reminder_not_set"] != nil {
|
||||
t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"])
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil {
|
||||
t.Fatalf("reminder has no offset; it should not be absolute")
|
||||
}
|
||||
if *rems[0].OffsetMin != -60 {
|
||||
t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+
|
||||
"positive number is an alarm an hour late", *rems[0].OffsetMin)
|
||||
}
|
||||
|
||||
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
||||
}
|
||||
|
||||
early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("due reminders (early): %v", err)
|
||||
}
|
||||
if len(early) != 0 {
|
||||
t.Errorf("61 minutes before it is due, %d reminders are already due", len(early))
|
||||
}
|
||||
|
||||
ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("due reminders (ripe): %v", err)
|
||||
}
|
||||
if len(ripe) != 1 {
|
||||
t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe))
|
||||
}
|
||||
if ripe[0].TaskContent != "ring til tandlægen" {
|
||||
t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent)
|
||||
}
|
||||
}
|
||||
|
||||
// An alarm with nothing to count back from is refused out loud.
|
||||
//
|
||||
// "mind mig en time før" on a task with a day but no clock has no moment to be an
|
||||
// hour before, and DueReminders selects the offset form only when the task has a
|
||||
// due_datetime — so the row would sit there and never fire. The task is created and
|
||||
// the caller is told, the same way an unknown #project is reported. An alarm that
|
||||
// silently never rings is the one way a reminder must not fail.
|
||||
func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "køb mælk i morgen mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "køb mælk" {
|
||||
t.Errorf("content = %v, want %q", body["content"], "køb mælk")
|
||||
}
|
||||
if body["reminder_not_set"] == nil {
|
||||
t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so")
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+
|
||||
"than none, because nothing afterwards reveals it", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder given a clock of its own is kept as an absolute moment.
|
||||
func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til tandlægen i morgen kl 9 mind mig kl 8",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin != nil {
|
||||
t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin)
|
||||
}
|
||||
|
||||
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
||||
}
|
||||
// Due at nine, reminded at eight: one hour earlier, on the task's own day.
|
||||
if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) {
|
||||
t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the account's standing alarm --------------------------------------------
|
||||
|
||||
// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes
|
||||
// before the due moment.
|
||||
func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) {
|
||||
t.Helper()
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("set default reminder: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// With the setting on, a task that has a clock gets its alarm without being asked.
|
||||
func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
||||
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// And off is where every account starts. This is the control for the test above:
|
||||
// without it, an alarm appearing for any reason at all reads as the setting working.
|
||||
func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+
|
||||
"on for everybody is not a default anybody chose", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A task with a day but no clock gets nothing, because there is no moment to ring
|
||||
// at: DueReminders selects the offset form only when due_datetime is set, so the
|
||||
// row would be one that never fires.
|
||||
func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// The default does not stack on top of an alarm the line asked for.
|
||||
func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16 mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 {
|
||||
t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// "ingen alarm" is the way out of the default while writing the task.
|
||||
//
|
||||
// Without it the only way to have a timed task that does not ring is to create it,
|
||||
// open it, and delete the alarm that was just made — which is the omission a
|
||||
// default quietly creates.
|
||||
func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16 ingen alarm",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "hente cyklen" {
|
||||
t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen")
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A clock added later gets the alarm too.
|
||||
//
|
||||
// This is the half a promise about "every task with a date and time" loses if the
|
||||
// default is only applied where tasks are created: a task written now and given a
|
||||
// time this afternoon is the same task.
|
||||
func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
taskID := body["id"].(string)
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Fatalf("got %d reminders before there was a clock, want 0", len(rems))
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{
|
||||
"due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
|
||||
"due_time": "16:00",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch task: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err = ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders after the clock was added, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
||||
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// A positive offset is refused. An alarm after the thing it warns about is not a
|
||||
// reminder, and accepting one here would make the single mistake this value can
|
||||
// make — a sign the wrong way round — look like a choice somebody made.
|
||||
func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Null turns it off again, and the alarms already made are left alone: a default is
|
||||
// what happens to the NEXT task, not a statement about the ones already written.
|
||||
func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
kept := body["id"].(string)
|
||||
|
||||
resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("turn off: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente bilen i morgen kl 17",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders after turning the default off, want 0", len(rems))
|
||||
}
|
||||
|
||||
rems, err = ts.db.ListReminders(ctx, kept)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Errorf("the alarm made while it was on is gone; turning a default off is not " +
|
||||
"a statement about the tasks already written")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||
// ind til den halvdel, resten af programmet lige har lukket.
|
||||
var tasks []store.Task
|
||||
var err error
|
||||
if !s.cfg.NotesOnly() {
|
||||
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
|
||||
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *store.DB) *store.User {
|
||||
|
||||
+19
-13
@@ -24,13 +24,19 @@ import (
|
||||
type Sender struct {
|
||||
cfg config.SMTP
|
||||
log *slog.Logger
|
||||
// appName and baseURL appear in the body of every message.
|
||||
// appName and baseURL appear in every message. appName is the edition's own
|
||||
// name — an invite from an urd instance that said "verdande" would be a letter
|
||||
// to somebody else's inbox about a program they are not being invited to.
|
||||
//
|
||||
// It was a field with a value and no reader until 2026-10-07: the three places
|
||||
// that needed it had the word written out instead, and the comment above said
|
||||
// it appeared in every message. It does now.
|
||||
appName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
|
||||
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
|
||||
}
|
||||
|
||||
// Configured reports whether mail can actually be sent. Handlers use it to decide
|
||||
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||
what := "verdande"
|
||||
what := s.appName
|
||||
if projectName != "" {
|
||||
what = "projektet “" + projectName + "”"
|
||||
}
|
||||
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
|
||||
}
|
||||
|
||||
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
|
||||
body := fmt.Sprintf(`Hej
|
||||
|
||||
%s har inviteret dig til %s.
|
||||
@@ -71,9 +77,9 @@ Opret din konto her:
|
||||
|
||||
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
|
||||
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
|
||||
|
||||
%s
|
||||
|
||||
— verdande
|
||||
`, name, task, link)
|
||||
— %s
|
||||
`, name, task, link, s.appName)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
||||
subject := "Nulstil din adgangskode i verdande"
|
||||
subject := "Nulstil din adgangskode i " + s.appName
|
||||
body := fmt.Sprintf(`Hej %s
|
||||
|
||||
Der er bedt om at nulstille adgangskoden til din verdande-konto.
|
||||
Der er bedt om at nulstille adgangskoden til din %s-konto.
|
||||
|
||||
Vælg en ny adgangskode her:
|
||||
%s
|
||||
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
|
||||
|
||||
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, name, link, humanDuration(ttl), s.baseURL)
|
||||
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// TestEveryMessageCarriesTheEditionsOwnName.
|
||||
//
|
||||
// These three letters are the only thing this program sends to an address its
|
||||
// operator does not control, and an invitation from an urd instance that says
|
||||
// "verdande" is a letter to a stranger about a program they are not being invited
|
||||
// to. The name was a field with a value and no reader until 2026-10-07 — the three
|
||||
// places that needed it had the word written out instead — so the assertion that
|
||||
// matters is not "it says urd" but "it does not say the other one".
|
||||
//
|
||||
// Measured through the unconfigured path on purpose: with no SMTP host, send()
|
||||
// logs the whole message at warn level and returns, so the subject and the body
|
||||
// can be read as the recipient would get them without a mail server in the test.
|
||||
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
|
||||
for _, tc := range []struct{ name, other string }{
|
||||
{"verdande", "urd"},
|
||||
{"urd", "verdande"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", tc.name,
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendPasswordReset: %v", err)
|
||||
}
|
||||
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
|
||||
t.Fatalf("SendReminder: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
|
||||
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
|
||||
}
|
||||
if !strings.Contains(got, tc.name) {
|
||||
t.Errorf("no message mentions %q", tc.name)
|
||||
}
|
||||
if strings.Contains(got, tc.other) {
|
||||
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
|
||||
}
|
||||
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
|
||||
// *subject* of the sentence rather than a signature: an invitation to the instance
|
||||
// itself, with no project, reads "has invited you to <program>".
|
||||
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", "urd",
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "inviteret dig til urd") {
|
||||
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
KindReminder Kind = "reminder"
|
||||
)
|
||||
|
||||
// Span is a byte range of the input that was consumed, and what it was read as.
|
||||
@@ -70,7 +71,28 @@ type Result struct {
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
// RecurrenceText is that rule written back out in Danish, for the preview.
|
||||
RecurrenceText string `json:"recurrence_text,omitempty"`
|
||||
Spans []Span `json:"spans,omitempty"`
|
||||
// ReminderOffsetMin is when to be told, as minutes relative to the task's due
|
||||
// moment: negative for before it, zero for at it. Nil when the line asked for
|
||||
// no reminder.
|
||||
//
|
||||
// Relative rather than absolute, so that moving the task moves its alarm with
|
||||
// it. Mind the sign: the store ADDS this to the due time
|
||||
// (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that
|
||||
// backwards gives an alarm an hour late, which is the one direction a reminder
|
||||
// must never be wrong in.
|
||||
ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"`
|
||||
// ReminderAt is an absolute reminder, "YYYY-MM-DDTHH:MM", set when the line
|
||||
// gave the reminder a clock of its own. It takes the day the task is due:
|
||||
// "mind mig kl 8" about something due Friday means Friday at eight, not today.
|
||||
ReminderAt string `json:"reminder_at,omitempty"`
|
||||
// NoReminder is the line saying it wants no alarm on this one — "ingen alarm".
|
||||
//
|
||||
// It exists because of the account-wide default: with alarms on for everything
|
||||
// that has a clock, there has to be a way to say "not this one" in the same
|
||||
// breath as writing it. Without that the only way out is to create the task,
|
||||
// open it, and delete the alarm that was just made.
|
||||
NoReminder bool `json:"no_reminder,omitempty"`
|
||||
Spans []Span `json:"spans,omitempty"`
|
||||
}
|
||||
|
||||
// Parse reads input as of the moment `now`, which is also the timezone dates are
|
||||
@@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// mask keeps byte offsets intact, so spans still point into the original input.
|
||||
masked := mask(input, spans)
|
||||
|
||||
// Reminders before the clock and the calendar, and that order is the whole
|
||||
// trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a
|
||||
// clock, and either one read by the time parser first becomes the task's own
|
||||
// due time instead of its alarm. The phrase is anchored on a keyword the way a
|
||||
// project is anchored on '#', so consuming it this early cannot take anything
|
||||
// that was not offered to it.
|
||||
// "No alarm" before "alarm", or the refusal leaves the very word the stage
|
||||
// below is looking for standing in the line.
|
||||
res.NoReminder, spans = extractNoReminder(masked, spans)
|
||||
masked = mask(masked, spans)
|
||||
|
||||
rem, spans := extractReminder(masked, spans)
|
||||
masked = mask(masked, spans)
|
||||
|
||||
// Recurrence before the date parser, and this order is load-bearing: "hver
|
||||
// mandag" contains a weekday, and a date parser reading it first would claim
|
||||
// Monday as a one-off due date and leave "hver" stranded in the title.
|
||||
@@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
res.DueTime = pad2(hour) + ":" + pad2(minute)
|
||||
}
|
||||
|
||||
// The reminder is resolved last, because an absolute one needs the due date the
|
||||
// stages above just worked out.
|
||||
if rem.found {
|
||||
if rem.hasClock {
|
||||
day := res.DueDate
|
||||
if day == "" {
|
||||
day = now.Format("2006-01-02")
|
||||
}
|
||||
res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min)
|
||||
} else {
|
||||
off := rem.offsetMin
|
||||
res.ReminderOffsetMin = &off
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
|
||||
res.Spans = spans
|
||||
res.Content = remove(input, spans)
|
||||
@@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) {
|
||||
}
|
||||
return "", spans
|
||||
}
|
||||
|
||||
// --- reminders ----------------------------------------------------------------
|
||||
|
||||
// reReminderLead finds the words that introduce an alarm. A reminder is anchored
|
||||
// on a keyword the way a project is anchored on '#': nothing becomes an alarm by
|
||||
// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine
|
||||
// and not to be woken before it.
|
||||
//
|
||||
// The trailing \b is load-bearing. Without it the pattern matches the first six
|
||||
// letters of "alarmen" and of "mind migrationen", takes them out of the title, and
|
||||
// sets an alarm nobody asked for.
|
||||
var reReminderLead = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` +
|
||||
`|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`)
|
||||
|
||||
// reReminderOffset reads the tail as "this long before": "en time før", "10 min
|
||||
// før", "et kvarter før", "dagen før", "two days before".
|
||||
var reReminderOffset = regexp.MustCompile(
|
||||
`(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` +
|
||||
`(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` +
|
||||
`|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` +
|
||||
`(?:før|foer|inden|before)`)
|
||||
|
||||
// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30".
|
||||
var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`)
|
||||
|
||||
// reminder is what the phrase said. OffsetMin is minutes relative to the due
|
||||
// moment and negative for "before"; zero means at the due moment, which is what a
|
||||
// bare "mind mig" asks for.
|
||||
type reminder struct {
|
||||
found bool
|
||||
offsetMin int
|
||||
hour, min int
|
||||
hasClock bool
|
||||
}
|
||||
|
||||
// extractReminder reads the alarm out of the line.
|
||||
//
|
||||
// The lead-in is matched first and the tail interpreted afterwards, rather than in
|
||||
// one pattern, for the same reason the recurrence stage does it: a single
|
||||
// expression able to express all three endings — a duration, a clock, or nothing
|
||||
// at all — matches far more than the three things it is for.
|
||||
func extractReminder(input string, spans []Span) (reminder, []Span) {
|
||||
m := reReminderLead.FindStringSubmatchIndex(input)
|
||||
if m == nil {
|
||||
return reminder{}, spans
|
||||
}
|
||||
|
||||
rem := reminder{found: true}
|
||||
start, end := m[2], m[3]
|
||||
tail := input[end:]
|
||||
|
||||
if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil {
|
||||
n := 1
|
||||
if t[2] >= 0 {
|
||||
n = spokenNumber(tail[t[2]:t[3]])
|
||||
}
|
||||
rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]])
|
||||
end += t[1]
|
||||
} else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil {
|
||||
hour := atoi(tail, t[2], t[3])
|
||||
minute := 0
|
||||
if t[4] >= 0 {
|
||||
minute = atoi(tail, t[4], t[5])
|
||||
}
|
||||
// An impossible clock is not a reminder. Saying nothing is better than
|
||||
// setting one for 25:00, which the store would happily keep and never fire.
|
||||
if !valid(hour, minute) {
|
||||
return reminder{}, spans
|
||||
}
|
||||
rem.hour, rem.min, rem.hasClock = hour, minute, true
|
||||
end += t[1]
|
||||
}
|
||||
|
||||
return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder})
|
||||
}
|
||||
|
||||
// spokenNumber reads the count in front of a unit. People dictating say the word
|
||||
// rather than the digit, and "mind mig en time før" is the commonest form there is.
|
||||
func spokenNumber(w string) int {
|
||||
switch strings.ToLower(w) {
|
||||
case "en", "et", "a", "an":
|
||||
return 1
|
||||
case "to", "two":
|
||||
return 2
|
||||
case "tre", "three":
|
||||
return 3
|
||||
}
|
||||
if n, err := strconv.Atoi(w); err == nil {
|
||||
return n
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their
|
||||
// own count, so they come first: with a leading "en" they multiply by one and come
|
||||
// out as themselves.
|
||||
func unitMinutes(unit string) int {
|
||||
u := strings.ToLower(strings.Join(strings.Fields(unit), " "))
|
||||
switch {
|
||||
case strings.HasPrefix(u, "halv"):
|
||||
return 30
|
||||
case strings.HasPrefix(u, "kvarter"):
|
||||
return 15
|
||||
case strings.HasPrefix(u, "min"):
|
||||
return 1
|
||||
case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"):
|
||||
return 60
|
||||
case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"):
|
||||
return 1440
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reNoReminder finds the line asking not to be reminded. The account-wide default
|
||||
// gives every task with a clock an alarm, and this is how one task opts out while
|
||||
// it is being written rather than afterwards.
|
||||
var reNoReminder = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` +
|
||||
`|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`)
|
||||
|
||||
func extractNoReminder(input string, spans []Span) (bool, []Span) {
|
||||
m := reNoReminder.FindStringSubmatchIndex(input)
|
||||
if m == nil {
|
||||
return false, spans
|
||||
}
|
||||
return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder})
|
||||
}
|
||||
|
||||
@@ -636,3 +636,151 @@ func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders ----------------------------------------------------------------
|
||||
|
||||
// A dictated sentence can set its own alarm.
|
||||
//
|
||||
// The offset is negative because the store ADDS it to the due moment
|
||||
// (`due_datetime + offset_min * 60`). A positive number there is an alarm after
|
||||
// the thing it was meant to warn about, which is the one direction a reminder must
|
||||
// never be wrong in — so the sign is asserted here and not assumed.
|
||||
func TestParseReminderOffset(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 min før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 minutter før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig et kvarter før", -15},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en halv time før", -30},
|
||||
{"møde i morgen kl 9 mind mig dagen før", -1440},
|
||||
{"møde i morgen kl 9 mind mig to dage før", -2880},
|
||||
{"møde i morgen kl 9 mind mig 2 timer før", -120},
|
||||
// Det er talt ind, så kommaet hører til sætningen og ikke til syntaksen.
|
||||
{"ring til tandlægen i morgen kl 9, mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, påmind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, mind mig om det en time før", -60},
|
||||
// Engelsk i samme linje, som resten af parseren gør det.
|
||||
{"call the dentist tomorrow at 9 remind me an hour before", -60},
|
||||
{"call the dentist tomorrow at 9 remind me 15 minutes before", -15},
|
||||
{"call the dentist tomorrow at 9 remind me two days before", -2880},
|
||||
// En bar påmindelse er på selve forfaldstidspunktet.
|
||||
{"ring til tandlægen i morgen kl 9 mind mig", 0},
|
||||
{"ring til tandlægen i morgen kl 9 med alarm", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderOffsetMin == nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = nil, want %d", c.in, c.want)
|
||||
continue
|
||||
}
|
||||
if *got.ReminderOffsetMin != c.want {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want %d", c.in, *got.ReminderOffsetMin, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder with a clock of its own takes the day the task is due.
|
||||
//
|
||||
// "mind mig kl 8" about something due Friday means Friday at eight. Resolving it
|
||||
// against today instead would fire the alarm days early, silently.
|
||||
func TestParseReminderClockTakesTheDueDay(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl 8", "2026-03-11T08:00"},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl. 7.30", "2026-03-11T07:30"},
|
||||
{"ring til tandlægen i morgen kl 9, påmind kl 7:15", "2026-03-11T07:15"},
|
||||
{"call the dentist tomorrow at 9 remind me at 8", "2026-03-11T08:00"},
|
||||
// Ingen dato i linjen: så er det i dag, som en bar klokketid også er.
|
||||
{"ring til tandlægen mind mig kl 23", "2026-03-10T23:00"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderAt != c.want {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want %q", c.in, got.ReminderAt, c.want)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): also set an offset (%d); a reminder is one or the other",
|
||||
c.in, *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The alarm does not eat the task's own time, and the task's time does not eat the
|
||||
// alarm. This is the ordering the stage exists for.
|
||||
func TestReminderDoesNotTakeTheTasksOwnTime(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 8", ref(t), "da")
|
||||
|
||||
if got.DueTime != "09:00" {
|
||||
t.Errorf("DueTime = %q, want 09:00 — the reminder took the task's clock", got.DueTime)
|
||||
}
|
||||
if got.DueDate != "2026-03-11" {
|
||||
t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate)
|
||||
}
|
||||
if got.ReminderAt != "2026-03-11T08:00" {
|
||||
t.Errorf("ReminderAt = %q, want 2026-03-11T08:00", got.ReminderAt)
|
||||
}
|
||||
if got.Content != "ring til tandlægen" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "ring til tandlægen")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing becomes an alarm by standing near a clock.
|
||||
func TestNoReminderWhereNoneWasAskedFor(t *testing.T) {
|
||||
now := ref(t)
|
||||
for _, in := range []string{
|
||||
"ring til tandlægen i morgen kl 9",
|
||||
"betal moms i morgen kl 10 p1 #Firma @regnskab",
|
||||
// The \b cases: these words merely begin with the keywords.
|
||||
"skift batteri i alarmen",
|
||||
"mind migrationen om at køre",
|
||||
"vand planterne hver mandag kl 9",
|
||||
} {
|
||||
got := Parse(in, now, "da")
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want none", in, *got.ReminderOffsetMin)
|
||||
}
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want none", in, got.ReminderAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The phrase is highlighted, like every other thing the parser claims — see the
|
||||
// package comment: a parser you cannot see working is one you stop trusting.
|
||||
func TestReminderIsHighlighted(t *testing.T) {
|
||||
in := "ring til tandlægen i morgen kl 9 mind mig en time før"
|
||||
got := Parse(in, ref(t), "da")
|
||||
|
||||
var span *Span
|
||||
for i := range got.Spans {
|
||||
if got.Spans[i].Kind == KindReminder {
|
||||
span = &got.Spans[i]
|
||||
}
|
||||
}
|
||||
if span == nil {
|
||||
t.Fatalf("no %q span in %v", KindReminder, got.Spans)
|
||||
}
|
||||
if marked := in[span.Start:span.End]; marked != "mind mig en time før" {
|
||||
t.Errorf("highlighted %q, want %q", marked, "mind mig en time før")
|
||||
}
|
||||
}
|
||||
|
||||
// An impossible clock is not a reminder: the store would keep 25:00 and never fire
|
||||
// it, so saying nothing is the honest answer.
|
||||
func TestImpossibleReminderClockIsNotKept(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 25", ref(t), "da")
|
||||
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("ReminderAt = %q, want none", got.ReminderAt)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Alarm som standard på opgaver med et klokkeslæt.
|
||||
--
|
||||
-- Påmindelser har virket længe og skulle sættes én ad gangen: en opgave med
|
||||
-- "i morgen kl 9" fik en tid og ingen alarm, for intet skaber en påmindelse af sig
|
||||
-- selv. Det er den rigtige standard for en opgave UDEN klokkeslæt — der er intet
|
||||
-- tidspunkt at ringe på — men for en med et er det en omvej, man går hver gang.
|
||||
--
|
||||
-- Minutter i forhold til forfaldstiden, og NULL betyder slået fra. Én nullable
|
||||
-- kolonne frem for et flueben plus et tal, så tilstanden "slået til uden
|
||||
-- forskydning" ikke findes og derfor ikke kan blive uenig med sig selv.
|
||||
--
|
||||
-- Fortegnet er det samme som i reminders.offset_min, fordi det er den kolonne,
|
||||
-- værdien ender i: lageret LÆGGER tallet til forfaldstiden, så en alarm FØR er
|
||||
-- negativ og 0 er præcis på tidspunktet. To forskellige fortegnsregler for det
|
||||
-- samme begreb er den slags forskel, der kun viser sig som en alarm, der ringer
|
||||
-- på det forkerte tidspunkt.
|
||||
--
|
||||
-- NULL for alle, der findes i dag, og det er med vilje. At slå den til i en
|
||||
-- migrering ville give hver eksisterende bruger alarmer, de ikke har bedt om, på
|
||||
-- opgaver de har liggende — og det her er software, andre selv driver. En
|
||||
-- indstilling, man selv slår til, er en anden sag end en notifikation, der
|
||||
-- pludselig kommer.
|
||||
ALTER TABLE users ADD COLUMN default_reminder_min INTEGER;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Sessioner fra en app.
|
||||
--
|
||||
-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en
|
||||
-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes
|
||||
-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke
|
||||
-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en
|
||||
-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder,
|
||||
-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et
|
||||
-- token med vilje ikke gør.
|
||||
--
|
||||
-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen
|
||||
-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at
|
||||
-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får
|
||||
-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke
|
||||
-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt
|
||||
-- år, skal.
|
||||
ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web';
|
||||
ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0;
|
||||
+70
-31
@@ -15,9 +15,36 @@ type Session struct {
|
||||
UA string
|
||||
IP string
|
||||
PendingTOTP bool
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
// Client is "web" for a browser and "app" for the iOS app, which carries its
|
||||
// session as a bearer token rather than a cookie. DeviceName is what the app
|
||||
// calls itself; a browser has none and is described from its user agent.
|
||||
Client string
|
||||
DeviceName string
|
||||
// IdleTTL above zero makes the session slide: each use moves ExpiresAt to
|
||||
// IdleTTL from now. Zero is the fixed lifetime a browser session has always had.
|
||||
IdleTTL time.Duration
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl,
|
||||
created_at, last_seen_at, expires_at`
|
||||
|
||||
func scanSession(row interface{ Scan(...any) error }) (*Session, error) {
|
||||
var s Session
|
||||
var pending int
|
||||
var idle, created, lastSeen, expires int64
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle,
|
||||
&created, &lastSeen, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.IdleTTL = time.Duration(idle) * time.Second
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSession issues a session and returns the value to put in the cookie.
|
||||
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
UA: truncate(ua, 500),
|
||||
IP: truncate(ip, 100),
|
||||
PendingTOTP: pendingTOTP,
|
||||
Client: "web",
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
@@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
|
||||
id := auth.HashToken(token)
|
||||
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
FROM sessions WHERE id = ?`, id).
|
||||
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
|
||||
s, err := scanSession(db.QueryRowContext(ctx,
|
||||
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
|
||||
if time.Now().After(s.ExpiresAt) {
|
||||
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
||||
return nil, nil, ErrNotFound
|
||||
@@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
// last_seen_at drives the session list in settings ("this device, 2 minutes
|
||||
// ago"). It is written at most once a minute: every request would be a write
|
||||
// per request, which on SQLite means a write lock per request.
|
||||
//
|
||||
// A sliding session moves its end in the same write, and only then: its
|
||||
// expiry is therefore accurate to the minute, which is far finer than the
|
||||
// months it is measured in.
|
||||
if time.Since(s.LastSeenAt) > time.Minute {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
|
||||
now := time.Now()
|
||||
if s.IdleTTL > 0 && !s.PendingTOTP {
|
||||
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
|
||||
now.Unix(), s.ExpiresAt.Unix(), s.ID)
|
||||
} else {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID)
|
||||
}
|
||||
}
|
||||
return &s, user, nil
|
||||
return s, user, nil
|
||||
}
|
||||
|
||||
// MarkAppSession records that a session belongs to the iOS app, what the device
|
||||
// calls itself, and how long it may sit unused (see Session.IdleTTL).
|
||||
//
|
||||
// A login still waiting on its code is marked with idle 0, so it keeps its ten
|
||||
// minutes; PromoteSession turns sliding on once the code is in.
|
||||
func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`,
|
||||
truncate(deviceName, 100), int64(idle/time.Second), sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteSession completes a two-step login: the session stops being pending and
|
||||
// its clock restarts, so the full lifetime is counted from finishing the login and
|
||||
// not from typing the password.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
|
||||
//
|
||||
// idle above zero makes the finished session slide (see Session.IdleTTL); a
|
||||
// browser passes zero.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), sessionID)
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||
|
||||
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
`SELECT `+sessionColumns+`
|
||||
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
|
||||
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
|
||||
s, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
out = append(out, s)
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
+44
-3
@@ -46,7 +46,22 @@ type User struct {
|
||||
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
||||
// means the order the program ships with. Unknown keys are ignored and missing
|
||||
// ones are appended, so adding a view later is not a migration.
|
||||
NavOrder []string
|
||||
NavOrder []string
|
||||
|
||||
// DefaultReminderMin gives every task that has a clock an alarm without being
|
||||
// asked, as minutes relative to the due moment: negative for before it, zero
|
||||
// for at it. Nil is off, which is what every account starts as.
|
||||
//
|
||||
// The same sign convention as reminders.offset_min, because this is the column
|
||||
// the value ends up in — the store ADDS it to the due time. Two sign rules for
|
||||
// one idea is the kind of difference that only shows up as an alarm going off
|
||||
// at the wrong moment.
|
||||
//
|
||||
// Only tasks with a clock. A task due "tomorrow" with no time has no moment to
|
||||
// ring at, and DueReminders selects the offset form only when due_datetime is
|
||||
// set, so an alarm there would be a row that never fires.
|
||||
DefaultReminderMin *int
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -136,7 +151,8 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order,
|
||||
default_reminder_min, created_at, updated_at`
|
||||
|
||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||
var u User
|
||||
@@ -145,10 +161,12 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
var created, updated int64
|
||||
|
||||
var collapsed, navOrder string
|
||||
var defaultReminder sql.NullInt64
|
||||
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder,
|
||||
&defaultReminder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -172,6 +190,10 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||
u.NavOrder = []string{}
|
||||
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
||||
if defaultReminder.Valid {
|
||||
v := int(defaultReminder.Int64)
|
||||
u.DefaultReminderMin = &v
|
||||
}
|
||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &u, nil
|
||||
@@ -691,3 +713,22 @@ func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
|
||||
}
|
||||
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
|
||||
}
|
||||
|
||||
// SetDefaultReminder records whether tasks with a clock get an alarm unasked, and
|
||||
// how far ahead of the due moment it rings. Nil turns it off.
|
||||
func (db *DB) SetDefaultReminder(ctx context.Context, userID string, minutes *int) error {
|
||||
var value any
|
||||
if minutes != nil {
|
||||
value = *minutes
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET default_reminder_min = ?, updated_at = ? WHERE id = ?`,
|
||||
value, time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Two editions: editions.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
@@ -55,6 +56,7 @@ nav:
|
||||
- Opening links: links.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- The iOS app: ios.md
|
||||
- Connecting things:
|
||||
- Calendar: calendar.md
|
||||
- Feeds and CalDAV: caldav.md
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
|
||||
/**
|
||||
* The interface the way the iOS app runs it: from a different origin than the
|
||||
* server, with no cookie, its session as a bearer token.
|
||||
*
|
||||
* The real app is served from capacitor://localhost, which a test browser cannot
|
||||
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
|
||||
* the loopback address, so it is this same server, but to the browser it is a
|
||||
* different origin, and that difference is the whole thing under test. The server
|
||||
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
|
||||
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
|
||||
*
|
||||
* bypassCSP because the server's policy is written for its own pages
|
||||
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
|
||||
* Without this the browser would block exactly the cross-origin calls the test
|
||||
* exists to make, and the failure would read as the app not working.
|
||||
*/
|
||||
|
||||
const PORT = 8097;
|
||||
const SERVER = `http://localhost:${PORT}`;
|
||||
|
||||
test.use({
|
||||
baseURL: `http://app.localhost:${PORT}`,
|
||||
bypassCSP: true,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.Capacitor = { isNativePlatform: () => true };
|
||||
});
|
||||
});
|
||||
|
||||
/** The app's session, read the way the app reads it. */
|
||||
const storedToken = (page) =>
|
||||
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
|
||||
|
||||
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// First the address — and an address with nothing behind it is said to be so,
|
||||
// rather than leading to a sign-in form that cannot succeed.
|
||||
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
|
||||
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Serveradresse/).fill(SERVER);
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
|
||||
// The sign-in form, saying where it signs in to.
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// Signed in by token, and by nothing else: no session cookie on either origin.
|
||||
const token = await storedToken(page);
|
||||
expect(token).not.toBe('');
|
||||
const cookies = await page.context().cookies();
|
||||
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
|
||||
|
||||
// Live sync over a ticketed socket: a project made behind the interface's back
|
||||
// — straight to the API, as another device would — shows up in the sidebar
|
||||
// without a reload. Nothing but the socket can have put it there.
|
||||
const name = `Fra en anden enhed ${Date.now()}`;
|
||||
const status = await page.evaluate(
|
||||
async ({ server, token, name }) => {
|
||||
const res = await fetch(`${server}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
{ server: SERVER, token, name }
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
|
||||
|
||||
// A picture from the server, which an <img> cannot fetch with a token. It is
|
||||
// shown from a blob, and the path it came from is kept for the note to save.
|
||||
const path = await page.evaluate(
|
||||
async ({ server, token }) => {
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
const note = await fetch(`${server}/api/v1/notes`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: '# Billede fra appen' })
|
||||
}).then((r) => r.json());
|
||||
// A one-pixel PNG.
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
body: form
|
||||
}).then((r) => r.json());
|
||||
const img = document.createElement('img');
|
||||
img.id = 'appbillede';
|
||||
img.setAttribute('src', saved.url);
|
||||
document.body.append(img);
|
||||
return saved.url;
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const img = page.locator('#appbillede');
|
||||
await expect(img).toHaveAttribute('data-src', path);
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
|
||||
|
||||
// Signing out takes the token off the phone, and keeps the server.
|
||||
await page.evaluate(() => document.getElementById('appbillede')?.remove());
|
||||
// Two taps: the first asks, the second signs out.
|
||||
await page.getByRole('button', { name: /Log ud/ }).click();
|
||||
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
expect(await storedToken(page)).toBe('');
|
||||
|
||||
// And the token is dead on the server too, not just forgotten here.
|
||||
const after = await page.evaluate(
|
||||
async ({ server, token }) =>
|
||||
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
|
||||
.status,
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
expect(after).toBe(401);
|
||||
});
|
||||
|
||||
/**
|
||||
* Signs in the way the app does, outside the page, and hands the page the server
|
||||
* and the session as the app would have stored them.
|
||||
*/
|
||||
async function signedInApp(page) {
|
||||
const origin = { Origin: `http://app.localhost:${PORT}` };
|
||||
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { ...origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: USER.email,
|
||||
password: USER.password,
|
||||
client: 'app',
|
||||
device_name: 'Prøve-iPhone'
|
||||
})
|
||||
});
|
||||
const { session_token: token } = await res.json();
|
||||
expect(token).toBeTruthy();
|
||||
await page.addInitScript(
|
||||
({ server, token }) => {
|
||||
localStorage.setItem('verdande.app.server', server);
|
||||
localStorage.setItem('verdande.app.session', token);
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const call = async (method, path, body) => {
|
||||
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(`${SERVER}/api/v1${path}`, init);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture in a note is shown from a blob made on the phone. Editing the note
|
||||
* turns the page back into markdown, and what it must write is the path the
|
||||
* picture came from — a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it, on any device.
|
||||
*/
|
||||
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
|
||||
page
|
||||
}) => {
|
||||
const call = await signedInApp(page);
|
||||
|
||||
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
|
||||
await call('PATCH', `/notes/${note.id}`, {
|
||||
body: `# Billednote fra appen\n\n\n\nTekst under billedet`
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + note.id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const img = ed.locator('img');
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect(img).toHaveAttribute('data-src', saved.url);
|
||||
|
||||
// Touch the note so it saves, and wait for the save itself.
|
||||
const patched = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
|
||||
);
|
||||
await ed.getByText('Tekst under billedet').click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' og mere');
|
||||
await patched;
|
||||
|
||||
const after = await call('GET', `/notes/${note.id}`);
|
||||
expect(after.body).toContain('og mere');
|
||||
expect(after.body).toContain(`](${saved.url})`);
|
||||
expect(after.body).not.toContain('blob:');
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* Noter-udgaven, kørt som sit eget program.
|
||||
*
|
||||
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
|
||||
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
|
||||
* opsætning lavede, så den laver sin egen.
|
||||
*
|
||||
* Og det er hele grunden til at prøven findes som en separat server frem for som
|
||||
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
|
||||
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
|
||||
* bruger møder det.
|
||||
*/
|
||||
|
||||
const KONTO = {
|
||||
email: 'noter@example.dk',
|
||||
name: 'Noteskriver',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
|
||||
// så den anden gang er det en almindelig login.
|
||||
const opret = page.getByRole('button', { name: 'Opret konto' });
|
||||
const login = page.getByRole('button', { name: 'Log ind' });
|
||||
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
|
||||
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
|
||||
// tredive sekunder på en knap, der aldrig kom.
|
||||
await expect(opret.or(login)).toBeVisible();
|
||||
|
||||
if (await opret.isVisible()) {
|
||||
await page.getByLabel(/Navn/).fill(KONTO.name);
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await opret.click();
|
||||
// Beaconens besked står øverst første gang; den er ikke det her prøvens
|
||||
// ærinde, men den dækker for det, der er.
|
||||
const besked = page.getByRole('region', { name: /melder, at den findes/ });
|
||||
if (await besked.isVisible().catch(() => false)) {
|
||||
await besked.getByRole('button', { name: 'Behold den' }).click();
|
||||
}
|
||||
} else {
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await page.getByRole('button', { name: 'Log ind' }).click();
|
||||
}
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
|
||||
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
|
||||
// det at vide, før nogen er logget ind.
|
||||
const svar = await page.request.get('/api/v1/auth/setup');
|
||||
expect((await svar.json()).edition).toBe('notes');
|
||||
});
|
||||
|
||||
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
|
||||
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
|
||||
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
|
||||
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
|
||||
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
|
||||
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
});
|
||||
|
||||
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
|
||||
page
|
||||
}) => {
|
||||
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
|
||||
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
|
||||
// andet program end det, der kører her.
|
||||
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
|
||||
await page.goto(sti);
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
await page.keyboard.type('Kvartalsregnskabet');
|
||||
await felt.blur();
|
||||
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
|
||||
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
|
||||
|
||||
// Noten findes — ellers beviser det næste ingenting.
|
||||
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
|
||||
// Og søgningen på serveren svarer uden opgaver overhovedet.
|
||||
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
|
||||
expect((await svar.json()).tasks ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
|
||||
// Det her er forskellen på skjult og fraværende, målt udefra.
|
||||
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
|
||||
const svar = await page.request.get(sti);
|
||||
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
|
||||
}
|
||||
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
|
||||
// ovenstående bestå på en server, der var helt væk.
|
||||
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
|
||||
});
|
||||
|
||||
/**
|
||||
* Navnet.
|
||||
*
|
||||
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
|
||||
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
|
||||
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
|
||||
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
|
||||
*/
|
||||
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
|
||||
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
|
||||
// som browseren læser før noget JavaScript kører og som serveren skriver om,
|
||||
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
|
||||
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
|
||||
// over en fane, der sagde "Noter · verdande".
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
|
||||
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
|
||||
|
||||
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
|
||||
await expect(mærke.locator('.name')).toHaveText('urd');
|
||||
// ð, ikke ᚹ wunjo og ikke ᚢ uruz. Begge de to er navngivne værdier frem for
|
||||
// "der står noget": en tom span, Verdandes mærke og uruz-appens ser ens ud for
|
||||
// en prøve, der kun spørger, om feltet er udfyldt.
|
||||
await expect(mærke.locator('.rune')).toHaveText('ð');
|
||||
await expect(mærke.locator('.rune')).not.toHaveText('ᚢ');
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('urd');
|
||||
expect(manifest.short_name).toBe('urd');
|
||||
});
|
||||
|
||||
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
|
||||
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
|
||||
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
|
||||
// Symptomet er at installationsknappen bare ikke er der.
|
||||
const svar = await page.request.get('/manifest.webmanifest');
|
||||
expect(svar.headers()['content-type']).toContain('application/manifest+json');
|
||||
});
|
||||
|
||||
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
|
||||
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
|
||||
// i app.html, og de er ikke samme ting. Omdøbes præfikset her og ikke i
|
||||
// resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det
|
||||
// eneste symptom er det hvide blink, det indlejrede script findes for at
|
||||
// undgå. Målt på de bytes der blev sendt, ikke på kilden.
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal).toContain("localStorage.getItem('verdande:theme')");
|
||||
expect(skal).toContain("localStorage.getItem('verdande:look')");
|
||||
expect(skal).not.toContain('urd:theme');
|
||||
});
|
||||
|
||||
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
|
||||
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
|
||||
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
|
||||
// skifter navn med udgaven.
|
||||
await page.goto('/indstillinger');
|
||||
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
|
||||
*
|
||||
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
|
||||
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
|
||||
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
|
||||
* manglede. Der er ingen fejlskærm at se.
|
||||
*
|
||||
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
|
||||
* sektion, der ikke var noget i.
|
||||
*
|
||||
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
|
||||
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
|
||||
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
|
||||
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
|
||||
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
|
||||
* notesbogens noter.
|
||||
*
|
||||
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
|
||||
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
|
||||
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
|
||||
*
|
||||
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
|
||||
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
|
||||
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
|
||||
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
|
||||
* ikke "ingen flade".
|
||||
*/
|
||||
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
|
||||
// og den er den eneste side med en id i stien, der kan nås.
|
||||
await page.goto('/noter');
|
||||
const projekter = await (await page.request.get('/api/v1/projects')).json();
|
||||
const id = (projekter.projects ?? projekter)[0]?.id;
|
||||
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
|
||||
|
||||
const sider = [
|
||||
'/noter',
|
||||
'/genveje',
|
||||
'/indstillinger',
|
||||
'/indstillinger/ai',
|
||||
'/indstillinger/brugere',
|
||||
'/indstillinger/data',
|
||||
'/indstillinger/fejl',
|
||||
'/indstillinger/historik',
|
||||
'/indstillinger/integrationer',
|
||||
'/indstillinger/notifikationer',
|
||||
'/indstillinger/tokens',
|
||||
`/projekt/${id}`
|
||||
];
|
||||
|
||||
for (const side of sider) {
|
||||
await page.goto(side);
|
||||
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
|
||||
// nået at komme afsted. Uden den her går løkken videre, før svarene er
|
||||
// kommet, og prøven måler ingenting.
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
|
||||
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
|
||||
});
|
||||
|
||||
test('ikonet er urds, hele vejen ud', async ({ page }) => {
|
||||
// Målt på stregerne i SVG'en frem for på "der kom et billede": en tom
|
||||
// respons, verdandes mærke og urds ser alle ens ud for en prøve, der kun
|
||||
// spørger, om der står noget.
|
||||
const svg = await (await page.request.get('/icon.svg')).text();
|
||||
expect(svg, 'ð: skålen').toContain('cx="32" cy="42" r="12"');
|
||||
expect(svg, 'ð: ascenderen').toContain('M44 36L27 10');
|
||||
expect(svg, 'ð: tværstregen, som er det der skiller ð fra et d').toContain('M25 20h18');
|
||||
expect(svg, 'og ikke ᚹ wunjos flag').not.toContain('M26 15l17');
|
||||
|
||||
// De tre PNG'er har ingen streger at læse, så de måles på at de er urds —
|
||||
// sammenlignet med filen under sit eget navn, som ligger i samme byg.
|
||||
for (const [sti, egen] of [
|
||||
['/icon-192.png', '/urd-icon-192.png'],
|
||||
['/icon-512.png', '/urd-icon-512.png'],
|
||||
['/apple-touch-icon.png', '/urd-apple-touch-icon.png']
|
||||
]) {
|
||||
const a = await (await page.request.get(sti)).body();
|
||||
const b = await (await page.request.get(egen)).body();
|
||||
expect(a.length, `${sti} er tom`).toBeGreaterThan(1000);
|
||||
expect(a.equals(b), `${sti} er ikke urds`).toBe(true);
|
||||
}
|
||||
});
|
||||
+612
-32
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||
@@ -11,14 +12,6 @@ import { USER } from './user.js';
|
||||
* precisely the bug this suite exists to catch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
@@ -93,30 +86,29 @@ async function nyNote(side) {
|
||||
return felt;
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
/**
|
||||
* Som `nyNote`, men UDEN klikket i feltet.
|
||||
*
|
||||
* Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren
|
||||
* står i overskriften, uden at nogen har sat den. Ventetiden skal de til
|
||||
* gengæld have, og manglen på den kostede to røde CI-kørsler.
|
||||
*
|
||||
* `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten
|
||||
* først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det,
|
||||
* der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller
|
||||
* ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det
|
||||
* er to forskellige påstande, der fejler, med én årsag. Se kommentaren over
|
||||
* `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet.
|
||||
*
|
||||
* Overskriften er værnet frem for bare det tomme felt: <h1> skrives af samme
|
||||
* effekt, der sætter markøren, så når den er der, er markøren sat.
|
||||
*/
|
||||
async function nyTomNote(side) {
|
||||
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await expect(side.locator('.editor h1')).toHaveCount(1);
|
||||
return felt;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5135,6 +5127,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
|
||||
*
|
||||
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
|
||||
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
|
||||
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
|
||||
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
|
||||
* retter den den næste blok to gange og lader denne stå.
|
||||
*
|
||||
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
|
||||
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
|
||||
* en <div> og "linje to" og "linje tre" var <p>.
|
||||
*
|
||||
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
|
||||
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
|
||||
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
|
||||
* beholder, uanset hvor markøren har været i mellemtiden.
|
||||
*/
|
||||
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const ed = await nyNote(page);
|
||||
await page.keyboard.type('Overskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('første afsnit');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('andet afsnit');
|
||||
await expect(ed.locator('p')).toHaveCount(2);
|
||||
|
||||
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
|
||||
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
|
||||
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
|
||||
// når den første ombytning tager dens node ud af dokumentet.
|
||||
await ed.evaluate((el) => {
|
||||
for (const para of [...el.querySelectorAll('p')]) {
|
||||
const div = document.createElement('div');
|
||||
while (para.firstChild) div.appendChild(para.firstChild);
|
||||
para.replaceWith(div);
|
||||
}
|
||||
|
||||
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
|
||||
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
|
||||
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el.lastElementChild);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
await expect(ed.locator('p')).toHaveCount(0);
|
||||
await expect(ed.locator('> div')).toHaveCount(2);
|
||||
|
||||
// Næste retur henter dem begge ind igen, sammen med den nye linje.
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('tredje afsnit');
|
||||
|
||||
await expect(ed.locator('> div')).toHaveCount(0);
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
|
||||
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
|
||||
// rettelse. Den skal også overleve en gemning.
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Overskrift/ }).click();
|
||||
|
||||
await expect(ed.locator('> div')).toHaveCount(0);
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
|
||||
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
|
||||
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hele kontoens opgaver kan hentes i én omgang.
|
||||
*
|
||||
@@ -5369,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Et billede bliver større af at blive åbnet — ikke mindre.
|
||||
*
|
||||
* Rapporteret som "billederne er meget små, når man klikker på dem". Ruden havde
|
||||
* kun `max-width` og `max-height`, så den kunne gøre et billede mindre og aldrig
|
||||
* større. Målt i den rigtige app: et lille billede (600×400) stod 600 pixels bredt
|
||||
* i noten og 600 i ruden, og et højt skærmbillede fra en telefon stod 701 bredt i
|
||||
* noten og 357 i ruden. At klikke for at se billedet bedre gav et dårligere billede.
|
||||
*
|
||||
* Målt på kasserne, med tal: "billedet er synligt" var sandt hele tiden.
|
||||
*/
|
||||
test('et åbnet billede fylder skærmen og kan ses i fuld størrelse', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote med mål');
|
||||
|
||||
const paste = (w, h) =>
|
||||
body.evaluate(async (el, [w, h]) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#c33';
|
||||
g.fillRect(0, 0, w, h);
|
||||
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File([blob], 'skud.png', { type: 'image/png' }));
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
}, [w, h]);
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
const big = shot.locator('img');
|
||||
|
||||
// Et lille billede: ruden forstørrer det, til det fylder skærmen i den ene led.
|
||||
await paste(600, 400);
|
||||
const small = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(small).toHaveJSProperty('complete', true);
|
||||
const inNote = await small.boundingBox();
|
||||
await small.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
const fit = await big.boundingBox();
|
||||
expect(fit.width).toBeGreaterThan(inNote.width * 1.5);
|
||||
expect(fit.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(fit.y + fit.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Fuld størrelse er aldrig mindre end det, man kom fra.
|
||||
await shot.getByRole('button', { name: 'Fuld størrelse' }).click();
|
||||
const full = await big.boundingBox();
|
||||
expect(full.width).toBeGreaterThanOrEqual(fit.width);
|
||||
await shot.getByRole('button', { name: 'Tilpas skærmen' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
|
||||
// Et højt skærmbillede fra en telefon: i fuld størrelse er det mindst lige så
|
||||
// bredt som i noten, og ruden ruller, så hele billedet kan nås.
|
||||
await paste(1170, 2532);
|
||||
const tall = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(tall).toHaveJSProperty('complete', true);
|
||||
const tallInNote = await tall.boundingBox();
|
||||
await tall.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
await big.click();
|
||||
const tallFull = await big.boundingBox();
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(tallInNote.width);
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(1170);
|
||||
const scroll = await shot.evaluate((d) => d.scrollHeight - d.clientHeight);
|
||||
expect(scroll).toBeGreaterThan(1000);
|
||||
// Et klik på billedet igen bringer det tilbage til skærmens mål.
|
||||
await big.click();
|
||||
const back = await big.boundingBox();
|
||||
expect(back.y + back.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||
*
|
||||
@@ -5509,3 +5658,434 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
|
||||
await page.unroute('**/api/v1/notes?*');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The control for the notes edition's renaming, and it belongs here rather than
|
||||
* beside it: the whole point is that the full edition does NOT go through that
|
||||
* path, and that can only be measured on an instance which is not the notes one.
|
||||
*
|
||||
* A gate asserted in one direction is half a gate. edition.spec.js proves the
|
||||
* notes edition calls itself urd; without this, a rename that fired in both
|
||||
* editions would pass there and change the name of this program.
|
||||
*/
|
||||
test('the full edition still calls itself verdande', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Both titles, for the same reason edition.spec.js checks both: the shell's,
|
||||
// which the browser reads before any script runs, and the route's own, set in
|
||||
// <svelte:head> once the page draws. Today has no title of its own, so the
|
||||
// shell's is what stands here — and /noter has one, so it is asked separately.
|
||||
const shell = await (await page.request.get('/')).text();
|
||||
expect(shell).toContain('<title>verdande</title>');
|
||||
await expect(page).toHaveTitle('verdande');
|
||||
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||
// ᚹ wunjo, not ð.
|
||||
await expect(brand.locator('.rune')).toHaveText('ᚹ');
|
||||
|
||||
await page.goto('/noter');
|
||||
await expect(page).toHaveTitle(/· verdande$/);
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('verdande');
|
||||
expect(manifest.description).toBe('Opgaver og projekter, delt.');
|
||||
|
||||
// The other half of the icon alias: this edition must still get Wunjo. Both
|
||||
// sets of files are in the same binary, so a swap that fired in both editions
|
||||
// would pass in edition.spec.js and quietly change this program's mark.
|
||||
const icon = await (await page.request.get('/icon.svg')).text();
|
||||
expect(icon).toContain('M26 15l17');
|
||||
expect(icon).not.toContain('M44 36L27 10');
|
||||
});
|
||||
|
||||
/**
|
||||
* The second door.
|
||||
*
|
||||
* One instance, one database, one login — and two things that can be installed.
|
||||
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
|
||||
* verdande, not a property of an instance, so if it only worked where
|
||||
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
|
||||
*
|
||||
* Measured on the bytes the server sends. An operating system installs a manifest,
|
||||
* not a URL, so these assertions are about the manifest's `id` and the shell's
|
||||
* <head> — the two things that decide whether /urd becomes a second icon on a home
|
||||
* screen or a bookmark into the first app.
|
||||
*/
|
||||
test('urd is a second installable app on the same instance', async ({ page }) => {
|
||||
const shell = await (await page.request.get('/urd')).text();
|
||||
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
|
||||
expect(shell, 'which manifest the browser fetches').toContain(
|
||||
'<link rel="manifest" href="/urd.webmanifest" />'
|
||||
);
|
||||
// iOS looks for this one BY NAME and has historically ignored the manifest's
|
||||
// icons, which is why the shell is served per door rather than patched by
|
||||
// script after the page loads.
|
||||
expect(shell, 'what iOS puts on a home screen').toContain(
|
||||
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
|
||||
);
|
||||
// And the root is untouched: two doors, not a rename.
|
||||
const root = await (await page.request.get('/')).text();
|
||||
expect(root).toContain('<title>verdande</title>');
|
||||
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||
|
||||
const res = await page.request.get('/urd.webmanifest');
|
||||
// A manifest served as text/html is ignored with no error anywhere; the only
|
||||
// symptom is an install prompt that never appears.
|
||||
expect(res.headers()['content-type']).toContain('application/manifest+json');
|
||||
|
||||
const urd = await res.json();
|
||||
// `id` is the install identity. Without it, two manifests on one origin are one
|
||||
// app with two start urls.
|
||||
expect(urd.id).toBe('/urd');
|
||||
expect(urd.start_url).toBe('/urd');
|
||||
// Not "/urd": a scope that did not cover the origin would open the app's own
|
||||
// links in a browser tab the first time somebody navigated out of /urd.
|
||||
expect(urd.scope).toBe('/');
|
||||
expect(urd.name).toBe('urd');
|
||||
for (const icon of urd.icons) {
|
||||
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
|
||||
}
|
||||
|
||||
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(verdande.name).toBe('verdande');
|
||||
expect(verdande.id ?? '/').not.toBe('/urd');
|
||||
// The shared fields come from one source, so a colour changed in the build
|
||||
// reaches both doors.
|
||||
expect(urd.theme_color).toBe(verdande.theme_color);
|
||||
expect(urd.background_color).toBe(verdande.background_color);
|
||||
});
|
||||
|
||||
test('entering through the urd door lands on the notes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/urd');
|
||||
|
||||
// Asserted on what is drawn rather than on the URL: the Go binary serves the
|
||||
// shell for anything it does not recognise, so a route that no longer exists
|
||||
// still answers 200 here.
|
||||
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
|
||||
// replaceState, so the phone's back gesture does not return to a page whose
|
||||
// only job is to redirect.
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
|
||||
// And the window now wears urd's face: its mark, its name, its tab. This is
|
||||
// chrome only — the task routes are all still there and still answer, which is
|
||||
// the whole reason this is one instance.
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('urd');
|
||||
await expect(brand.locator('.rune')).toHaveText('ð');
|
||||
await expect(page).toHaveTitle(/· urd$/);
|
||||
|
||||
// Nothing is withheld: the same window can still read a task route. A face that
|
||||
// hid data would be the separate instance wearing a costume.
|
||||
const tasks = await page.request.get('/api/v1/tasks');
|
||||
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
|
||||
|
||||
// And the face survives a reload — sessionStorage, because the two doors share
|
||||
// an origin. Nobody passes through /urd again after the first navigation.
|
||||
await page.reload();
|
||||
await expect(brand.locator('.name')).toHaveText('urd');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
||||
// The control, and it is the half that stops the face from being a rename: a
|
||||
// window that never went through /urd must be verdande, in the same browser,
|
||||
// on the same origin, with the other app's sessionStorage sitting beside it.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||
await expect(brand.locator('.rune')).toHaveText('\u16B9');
|
||||
await expect(page).toHaveTitle(/· verdande$/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En ny note åbner med markøren i sin titel.
|
||||
*
|
||||
* Titlen er første linje, som i Apple Notes — der er ikke et selvstændigt felt,
|
||||
* så "sæt markøren i overskriften" betyder et område placeret inde i <h1> i et
|
||||
* contenteditable. Derfor måles der på markeringen og ikke på hvad der har fokus:
|
||||
* editoren kan have fokus med markøren stående et helt andet sted.
|
||||
*/
|
||||
async function caretBlock(page) {
|
||||
return page.evaluate(() => {
|
||||
const s = window.getSelection();
|
||||
if (!s || !s.anchorNode) return null;
|
||||
const node = s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
|
||||
const block = node?.closest('h1, h2, h3, p, li, pre');
|
||||
return block ? block.tagName.toLowerCase() : null;
|
||||
});
|
||||
}
|
||||
|
||||
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await nyTomNote(page);
|
||||
|
||||
// `expect.poll` og ikke én læsning: `click()` vender tilbage, så snart klikket er
|
||||
// sendt, mens markøren sættes efter en netværksrundtur OG en gentegning. Lokalt
|
||||
// er rundturen under et millisekund, så ét snapshot ramte altid — på en runner
|
||||
// ramte det `null`, og prøven bestod på main ved held frem for ved regel.
|
||||
//
|
||||
// Kontrollen nedenfor (`stjæler ikke markøren`) må IKKE laves om til en poll:
|
||||
// den forventer null, så en poll ville bestå på første læsning, uanset hvad
|
||||
// koden gjorde. Den ankrer sig i stedet på overskriftens tekst først.
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1' })
|
||||
.toBe('h1');
|
||||
|
||||
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
|
||||
// titlen er det, listen kalder noten.
|
||||
await page.keyboard.type('Pakkeliste til Island');
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Pakkeliste til Island');
|
||||
await expect(page.locator('.notes button.row strong').first()).toHaveText(
|
||||
/^Pakkeliste til Island$/
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note med indhold stjæler ikke markøren', async ({ page }) => {
|
||||
// Kontrollen, og den halvdel der gør det til en regel frem for en vane: åbner
|
||||
// man en note, man har skrevet i forvejen, skal markøren IKKE hoppe op i
|
||||
// overskriften og skubbe det, man var i gang med.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await nyTomNote(page);
|
||||
|
||||
// Lyt FØR der skrives. Her stod `waitForTimeout(1200)`, og kommentaren over
|
||||
// `gemt` i toppen af filen nævner præcis det tal: et fast ophold er et gæt om,
|
||||
// hvor hurtig maskinen er, og gættet holder indtil CI er travl. Det holdt indtil
|
||||
// CI var travl — gemningen var ikke nået, genindlæsningen smed det skrevne væk,
|
||||
// og prøven faldt på at noten ikke fandtes frem for på markøren, som er det den
|
||||
// måler. Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om
|
||||
// det.
|
||||
const gemt = noteGemt(page);
|
||||
await page.keyboard.type('Allerede skrevet');
|
||||
await gemt;
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Allerede skrevet');
|
||||
expect(await caretBlock(page), 'ingen markør skal være sat').toBe(null);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ page }) => {
|
||||
// Han bad om "Urd og noter". Det er samme side, men målt gennem døren, fordi
|
||||
// det er den vej en bruger af urd-appen kommer — og fordi en regel, der kun er
|
||||
// prøvet ad én vej, er prøvet ét sted.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/urd');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
await nyTomNote(page);
|
||||
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 også i urd' })
|
||||
.toBe('h1');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note oprettet fra et projekt åbner også med markøren i overskriften', async ({ page }) => {
|
||||
// Den anden vej ind, og den blev påstået dækket før den blev målt: projektets
|
||||
// "Ny note" opretter noten og sender videre med ?note=<id>, så markøren
|
||||
// placeres af editoren på den anden side af en navigation frem for i samme
|
||||
// hug. Reglen sidder i editoren netop for at dække begge — det er værdiløst
|
||||
// uden en prøve, der går den anden vej.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByLabel('Nyt projekt').click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Islandsturen');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await sidebar.getByRole('link', { name: 'Islandsturen' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Islandsturen' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/noter\?note=/);
|
||||
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 efter navigationen' })
|
||||
.toBe('h1');
|
||||
await page.keyboard.type('Dæk og kort');
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* The button does not do speech recognition and is not meant to: it puts the
|
||||
* quick-add field in front of the thumb so the keyboard's own dictation can be
|
||||
* used. So what there is to test is the field — that it is reachable, that it ends
|
||||
* up with the caret in it, and that what lands there becomes a task.
|
||||
*
|
||||
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
|
||||
* only when focus() is called inside the user gesture, and no test can see the
|
||||
* difference. What a test CAN see is the precondition — that the field is already
|
||||
* in the document before the button is pressed — and that is asserted below,
|
||||
* because it is exactly what a well-meaning `{#if open}` would take away.
|
||||
*/
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
|
||||
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
|
||||
await expect(knap).toBeVisible();
|
||||
|
||||
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
|
||||
// synkront, og den eneste del af iOS-kravet en prøve kan se.
|
||||
//
|
||||
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
|
||||
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
|
||||
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
|
||||
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
|
||||
|
||||
await knap.click();
|
||||
|
||||
// Markøren er i skuffens felt — ikke i sidens eget.
|
||||
const fokusIskuffen = await page.evaluate(
|
||||
() => !!document.activeElement?.closest('.sheet')
|
||||
);
|
||||
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
|
||||
|
||||
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
await felt.fill('ring til tandlægen i dag p1');
|
||||
await felt.press('Enter');
|
||||
|
||||
// "i dag", af samme grund som de andre prøver her siger det: dette er
|
||||
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
|
||||
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
|
||||
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
|
||||
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
|
||||
await expect(page.locator('.sheet.open')).toHaveCount(0);
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En talt opgave kan sætte sin egen alarm.
|
||||
*
|
||||
* Det var omvejen: en dikteret "i morgen kl 9" fik en tid og INGEN alarm, for
|
||||
* ingenting skaber en påmindelse af sig selv — `CreateReminder` blev kun kaldt fra
|
||||
* sit eget endepunkt. Så skulle man bagefter åbne opgaven, finde Påmindelser og
|
||||
* vælge et klokkeslæt, og den omvej er længere end at skrive opgaven.
|
||||
*
|
||||
* Nu siger sætningen det selv, og det gælder uanset hvor stemmen kommer ind:
|
||||
* tastaturets diktering, en genvej eller noget tredje ender som tekst gennem den
|
||||
* samme parser.
|
||||
*
|
||||
* Prøven går hele vejen til skærmbilledet frem for at stoppe ved svaret, fordi
|
||||
* forskydningen er negativ i lageret — det lægger den til forfaldstiden — og
|
||||
* "-60 min." på skærmen er et fortegn, læseren selv skal oversætte.
|
||||
*/
|
||||
test('en talt opgave kan sætte sin egen alarm', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
|
||||
// Set, før den sendes: alarmen lyser op som et forstået felt, ved siden af
|
||||
// datoen. Det er hele pointen med fremvisningen — en parser, man ikke kan se
|
||||
// arbejde, holder man op med at stole på, første gang den tager fejl.
|
||||
await felt.fill('booke tandrens hos Lisa i morgen kl 9 mind mig en time før');
|
||||
await expect(page.locator(".sheet mark[data-kind='reminder']")).toHaveText('mind mig en time før');
|
||||
|
||||
await felt.press('Enter');
|
||||
await expect(page.locator('.sheet.open')).toHaveCount(0);
|
||||
|
||||
// Opgaven er i morgen, så den står ikke på I dag — se de andre prøver her.
|
||||
// Et navn ingen anden prøve bruger, og det er ikke pynt: suiten kører med én
|
||||
// arbejder mod ÉN database, så "ring til tandlægen" findes fire steder i denne
|
||||
// fil, når denne prøve kommer til. `.first()` ramte en af de andres opgave, som
|
||||
// ikke har nogen alarm — og kørt alene bestod prøven, fordi der kun var én.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByText('booke tandrens hos Lisa').click();
|
||||
|
||||
// Og alarmen står der, skrevet som ord og ikke som et fortegn.
|
||||
await expect(page.getByText('1 time før')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En alarm, der ikke kan ringe, bliver sagt højt.
|
||||
*
|
||||
* "mind mig en time før" på en opgave med en dag men ingen klokketid har intet at
|
||||
* være en time før: `DueReminders` vælger forskydningsformen kun
|
||||
* `AND t.due_datetime IS NOT NULL`, så rækken ville stå i tabellen og aldrig fyre.
|
||||
*
|
||||
* Det er den ene slags påmindelse, hvis fejl intet bagefter afslører — der står
|
||||
* ingen række at savne, og man opdager den først, da den ikke ringede. Derfor
|
||||
* bliver opgaven oprettet og beskeden sagt, i stedet for at alarmen tabes i
|
||||
* stilhed.
|
||||
*/
|
||||
test('en alarm uden klokkeslæt at regne tilbage fra bliver sagt højt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
await felt.fill('hente pakken på posthuset i morgen mind mig en time før');
|
||||
await felt.press('Enter');
|
||||
|
||||
await expect(page.getByText(/Alarmen er ikke/)).toBeVisible();
|
||||
|
||||
// Opgaven er lavet alligevel. Et forkert ord må ikke koste tanken.
|
||||
await page.goto('/upcoming');
|
||||
await expect(page.getByText('hente pakken på posthuset')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
|
||||
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
|
||||
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
|
||||
// begynder et tastetryk at skrive i en kasse, ingen kan se.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
|
||||
const mærketErIskuffen = await page.evaluate(
|
||||
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
|
||||
);
|
||||
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
|
||||
|
||||
await page.keyboard.press('k');
|
||||
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
|
||||
});
|
||||
|
||||
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
|
||||
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
|
||||
// bunden er til tommelfingeren.
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
|
||||
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
|
||||
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/urd');
|
||||
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
|
||||
|
||||
// Kontrollen: samme telefon, den anden dør, og så er den der.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*
|
||||
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
|
||||
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
|
||||
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
|
||||
* der ser den.
|
||||
*/
|
||||
export function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
+100
-36
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = join(here, '..');
|
||||
const dataDir = join(here, '.playwright-data');
|
||||
const notesDataDir = join(here, '.playwright-data-notes');
|
||||
|
||||
// A clean instance every run: the first test creates the first account, which only
|
||||
// works on an empty database.
|
||||
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
|
||||
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(notesDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
|
||||
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
|
||||
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
|
||||
const NOTES_PORT = 8098;
|
||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||
// every browser makes. The passkey test cannot run against a loopback address.
|
||||
@@ -72,6 +78,11 @@ export default defineConfig({
|
||||
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
|
||||
testIgnore: /(edition|app)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -95,6 +106,34 @@ export default defineConfig({
|
||||
*
|
||||
* WebKit here is the same engine Safari is, which is the whole point.
|
||||
*/
|
||||
/**
|
||||
* Noter-udgaven, som sit eget program.
|
||||
*
|
||||
* Egen server, egen port, egen database, og ingen `storageState`: den her
|
||||
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
|
||||
* at låne dens cookie ville prøve to instanser som var de én.
|
||||
*
|
||||
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
|
||||
*/
|
||||
{
|
||||
name: 'notes-edition',
|
||||
testMatch: /edition\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
/**
|
||||
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
|
||||
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
|
||||
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
|
||||
* cookie, for det er netop det, appen ikke har.
|
||||
*/
|
||||
{
|
||||
name: 'app-origin',
|
||||
testMatch: /app\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -103,42 +142,67 @@ export default defineConfig({
|
||||
}
|
||||
],
|
||||
|
||||
webServer: {
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
webServer: [
|
||||
{
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false',
|
||||
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
|
||||
// egen — som capacitor://localhost er det i den rigtige app.
|
||||
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
|
||||
}
|
||||
},
|
||||
|
||||
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
|
||||
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
|
||||
// det her koster en opstart og ikke en oversættelse.
|
||||
{
|
||||
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
|
||||
cwd: here,
|
||||
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${NOTES_PORT}`,
|
||||
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
|
||||
VERDANDE_DATA_DIR: notesDataDir,
|
||||
VERDANDE_EDITION: 'notes',
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,40 +1,71 @@
|
||||
/**
|
||||
* Renders static/icon.svg to the PNGs the manifest and iOS need.
|
||||
* Renders each edition's icon SVG to the PNGs the manifest and iOS need.
|
||||
*
|
||||
* node scripts/generate-icons.js
|
||||
*
|
||||
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
||||
* binary. Run it when the icon changes; the smoke test fetches every icon the
|
||||
* binary. Run it when an icon changes; the smoke test fetches every icon the
|
||||
* manifest names, so a reference with no file behind it fails there.
|
||||
*
|
||||
* It drives Chromium because there is no SVG renderer on this machine — which is
|
||||
* why these references were once removed rather than left pointing at files that
|
||||
* did not exist. Playwright is a renderer, and it is already a dependency.
|
||||
*
|
||||
* Two sets, because there are two editions. Both ship in the one binary and the
|
||||
* server decides which to serve at /icon.svg and friends — see
|
||||
* internal/httpapi/shell.go. The alternative, rewriting the manifest to point at
|
||||
* urd-icon-192.png, would have left `apple-touch-icon.png` behind: Safari looks
|
||||
* for that one BY NAME and ignores the manifest entirely, so a renamed path there
|
||||
* is a home-screen icon that is a screenshot of the page.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// The three sizes, and why each is here.
|
||||
const SIZES = [
|
||||
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||
{ file: 'static/icon-192.png', size: 192 },
|
||||
{ file: 'static/icon-512.png', size: 512 },
|
||||
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
||||
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
||||
// screenshot of the page as its home-screen icon.
|
||||
{ file: 'static/apple-touch-icon.png', size: 180 }
|
||||
{ suffix: '-192.png', size: 192 },
|
||||
{ suffix: '-512.png', size: 512 },
|
||||
// And the one Safari wants by name.
|
||||
{ suffix: '-apple-touch.png', size: 180 }
|
||||
];
|
||||
|
||||
// `out` is spelled out per set rather than built from the prefix, because
|
||||
// verdande's three were named before there was a second set and renaming them
|
||||
// would move the paths the manifest and app.html already point at.
|
||||
const SETS = [
|
||||
{
|
||||
source: 'static/icon.svg',
|
||||
out: {
|
||||
'-192.png': 'static/icon-192.png',
|
||||
'-512.png': 'static/icon-512.png',
|
||||
'-apple-touch.png': 'static/apple-touch-icon.png'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: 'static/urd-icon.svg',
|
||||
out: {
|
||||
'-192.png': 'static/urd-icon-192.png',
|
||||
'-512.png': 'static/urd-icon-512.png',
|
||||
'-apple-touch.png': 'static/urd-apple-touch-icon.png'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const svg = readFileSync('static/icon.svg', 'utf8');
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
for (const { file, size } of SIZES) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
for (const set of SETS) {
|
||||
const svg = readFileSync(set.source, 'utf8');
|
||||
for (const { suffix, size } of SIZES) {
|
||||
const file = set.out[suffix];
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Renders the iOS app's icon and launch image from static/icon.svg.
|
||||
*
|
||||
* node scripts/generate-ios-assets.js
|
||||
*
|
||||
* Same reasoning as generate-icons.js: a script, not a test, run when the icon
|
||||
* changes, with Chromium as the SVG renderer because it is already here.
|
||||
*
|
||||
* Two things differ from the web icons, and both are Apple's rules rather than
|
||||
* taste. The app icon is square with no transparency — iOS draws the rounded
|
||||
* corners itself, and rounded corners already in the image show as a dark ring
|
||||
* inside its own mask — so the tile's `rx` is dropped and the result flattened.
|
||||
* And the launch image is the rune small on the tile colour, the size
|
||||
* Capacitor's template asks for, so the app opens on the colour it is about to
|
||||
* draw instead of on white.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const ICONS = '../app/ios/App/App/Assets.xcassets';
|
||||
const svg = readFileSync('static/icon.svg', 'utf8').replace(/ rx="[^"]*"/, '');
|
||||
const tile = /<rect[^>]*fill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1];
|
||||
if (!tile) throw new Error('static/icon.svg has no tile colour to paint the launch image with');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
async function render(file, size, markSize) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0;background:${tile}}` +
|
||||
`div{width:${size}px;height:${size}px;display:grid;place-items:center}` +
|
||||
`svg{display:block;width:${markSize}px;height:${markSize}px}</style><div>${svg}</div>`
|
||||
);
|
||||
writeFileSync(file, await page.locator('div').screenshot({ omitBackground: false }));
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
|
||||
await render(`${ICONS}/AppIcon.appiconset/AppIcon-512@2x.png`, 1024, 1024);
|
||||
// Capacitor's template names three files for the one launch image; all the same.
|
||||
for (const name of ['splash-2732x2732.png', 'splash-2732x2732-1.png', 'splash-2732x2732-2.png']) {
|
||||
await render(`${ICONS}/Splash.imageset/${name}`, 2732, 640);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
+36
-6
@@ -24,6 +24,7 @@ export class ApiError extends Error {
|
||||
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* How long a request keeps retrying a network failure before giving up.
|
||||
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {},
|
||||
// The session cookie is httpOnly and same-origin; nothing here handles a token.
|
||||
credentials: 'same-origin',
|
||||
signal: options.signal
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
// In a browser: the httpOnly session cookie, same-origin, and nothing here
|
||||
// handles a token. In the iOS app: its session token as a bearer. See
|
||||
// platform.js.
|
||||
authorize(init);
|
||||
|
||||
// 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);
|
||||
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
|
||||
form.append(field, file);
|
||||
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
@@ -232,15 +235,39 @@ export const api = {
|
||||
// --- auth
|
||||
setupState: () => get('/auth/setup'),
|
||||
setup: (data) => post('/auth/setup', data),
|
||||
login: (email, password) => post('/auth/login', { email, password }),
|
||||
login: async (email, password) => {
|
||||
if (!isNativeApp()) return post('/auth/login', { email, password });
|
||||
// The app gets its session in the answer instead of a cookie, and keeps
|
||||
// it — also while a code is still owed, because the second step is sent
|
||||
// with it.
|
||||
const result = await post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
client: 'app',
|
||||
device_name: deviceName()
|
||||
});
|
||||
if (result?.session_token) appSession.setToken(result.session_token);
|
||||
return result;
|
||||
},
|
||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||
logout: () => post('/auth/logout'),
|
||||
logout: async () => {
|
||||
try {
|
||||
return await post('/auth/logout');
|
||||
} finally {
|
||||
// Gone from the phone whether or not the server heard: a sign-out that
|
||||
// left the token behind because the network was down is not one.
|
||||
if (isNativeApp()) appSession.signOut();
|
||||
}
|
||||
},
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
// null slu00e5r den fra. Minutter i forhold til forfaldstiden, negativt for fu00f8r —
|
||||
// samme fortegn som reminders.offset_min, fordi det er den kolonne, den ender i.
|
||||
setDefaultReminder: (minutes) => put('/auth/default-reminder', { minutes }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||
@@ -418,6 +445,9 @@ export const api = {
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- live sync, for the app (see stores.svelte.js)
|
||||
wsTicket: () => post('/ws-ticket'),
|
||||
|
||||
// --- notifications and push
|
||||
listNotifications: () => get('/notifications'),
|
||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
{#if notice}
|
||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||
<h2>{t('beacon.noticeTitle')}</h2>
|
||||
<p>{t('beacon.noticeBody')}</p>
|
||||
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
|
||||
|
||||
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
|
||||
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
|
||||
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
|
||||
// skal ⌘K ikke være stedet, det viser sig.
|
||||
...(app.notesOnly ? [] : tasks).map((t) => ({
|
||||
kind: 'task',
|
||||
id: t.id,
|
||||
label: t.content,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script>
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
|
||||
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
|
||||
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
|
||||
*
|
||||
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
|
||||
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
|
||||
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
|
||||
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
|
||||
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
|
||||
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
|
||||
* på det, og intet oprettes før man siger ja.
|
||||
*
|
||||
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
|
||||
* lyden, den stiller feltet frem.
|
||||
*/
|
||||
import QuickAdd from './QuickAdd.svelte';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let open = $state(false);
|
||||
let box;
|
||||
let sheet;
|
||||
|
||||
/**
|
||||
* Åbner og sætter fokus I SAMME HUG som klikket.
|
||||
*
|
||||
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
|
||||
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
|
||||
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
|
||||
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
|
||||
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
|
||||
* frem for efter næste opdatering.
|
||||
*
|
||||
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
|
||||
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
|
||||
* og uden pointer-events frem for skjult.
|
||||
*/
|
||||
function start() {
|
||||
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
|
||||
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
|
||||
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
|
||||
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
|
||||
// åbne tastaturet.
|
||||
//
|
||||
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
|
||||
// tabbes til, mens den er usynlig, på hver eneste side.
|
||||
sheet?.removeAttribute('inert');
|
||||
open = true;
|
||||
box?.focusField();
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
// Tastaturet bliver stående, hvis feltet beholder fokus.
|
||||
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
|
||||
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
|
||||
bredde, ikke skal kunne tabe et felt, der har fokus. -->
|
||||
{#if !app.showsUrd}
|
||||
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
|
||||
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
|
||||
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
|
||||
og navnet står i aria-label. -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="11" rx="3" />
|
||||
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
|
||||
<path d="M12 17.5V21" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
class:open
|
||||
aria-hidden="true"
|
||||
onclick={close}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="sheet"
|
||||
class:open
|
||||
bind:this={sheet}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={t('task.speak')}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div class="grab" aria-hidden="true"></div>
|
||||
<p class="hint">{t('task.speakHint')}</p>
|
||||
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
|
||||
Se kommentaren ved `marked` i QuickAdd. -->
|
||||
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
|
||||
<button class="done" onclick={close}>{t('detail.close')}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: max(var(--s4), env(safe-area-inset-right));
|
||||
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
|
||||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||||
z-index: 40;
|
||||
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--fast) var(--ease-out),
|
||||
opacity var(--fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.fab svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
|
||||
.fab.away {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 44;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.scrim.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 45;
|
||||
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
|
||||
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
|
||||
background: var(--surface-raised);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--s2);
|
||||
|
||||
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
|
||||
fokuseres i samme hug som klikket. Se `start()`. */
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--medium) var(--ease-out),
|
||||
opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.sheet.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.grab {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--line-strong);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.done {
|
||||
justify-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
|
||||
på. Knappen er til tommelfingeren. */
|
||||
@media (min-width: 821px) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<h2>{t('net.downTitle')}</h2>
|
||||
<p class="lede">{t('net.downBody')}</p>
|
||||
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
|
||||
|
||||
<button onclick={again} disabled={trying}>
|
||||
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { tick } from 'svelte';
|
||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -81,12 +82,55 @@
|
||||
// fired.
|
||||
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
// Og markøren sættes DER.
|
||||
//
|
||||
// Indtil nu lavede linjen ovenfor kun titelblokken, så sætningen i
|
||||
// kommentaren — "opens on its title" — handlede om, hvad der blev tegnet,
|
||||
// ikke om hvor man kunne skrive. Noter-siden prøvede at råde bod på det
|
||||
// med `document.querySelector('.editor textarea')?.focus()`, og der er
|
||||
// ingen textarea: editoren er et contenteditable. Det kald har ramt
|
||||
// ingenting, stille, siden editoren blev lavet om, og `?.` sørgede for,
|
||||
// at det aldrig kastede.
|
||||
//
|
||||
// Her frem for på siden, så begge veje ind er dækket: noter-sidens egen
|
||||
// opret-knap, og projektsidens, der sender videre med ?note=<id>.
|
||||
if (!body.trim()) placeCaretInTitle();
|
||||
} catch (e) {
|
||||
editor.textContent = body;
|
||||
console.error('verdande: noten kunne ikke tegnes', note.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sætter markøren i titlen på en tom note.
|
||||
*
|
||||
* En contenteditable har ikke et felt at fokusere — den har en markør, og den
|
||||
* skal placeres. `focus()` alene giver elementet tastaturfokus og lader
|
||||
* markøren stå, hvor browseren synes, hvilket for et tomt element er starten
|
||||
* på dokumentet frem for inde i overskriften.
|
||||
*
|
||||
* Fokus FØRST og så området: at fokusere et contenteditable kan nulstille
|
||||
* markeringen, så den omvendte rækkefølge sætter et område, der straks bliver
|
||||
* kasseret igen.
|
||||
*
|
||||
* Hvad den IKKE kan, sagt her frem for opdaget på en telefon: åbne tastaturet
|
||||
* på iOS. Det sker kun, når focus() kaldes inde i den brugerhandling, der bad
|
||||
* om det — og en ny note skal først oprettes på serveren, så der ligger et
|
||||
* `await` imellem. Markøren står rigtigt; på en telefon skal man stadig trykke
|
||||
* én gang for at få tastaturet frem.
|
||||
*/
|
||||
function placeCaretInTitle() {
|
||||
const heading = editor?.querySelector('h1');
|
||||
if (!heading) return;
|
||||
editor.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
const SYNTAX = [
|
||||
{ mark: '#', what: 'notes.syntaxProject' },
|
||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||
@@ -713,6 +757,8 @@
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
zoomed = false;
|
||||
natural = null;
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
@@ -726,6 +772,49 @@
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
// Ruden viste billedet med `max-width`/`max-height` og intet andet, så det kunne
|
||||
// blive mindre, men aldrig større. Det var mindre end i noten, det kom fra: et
|
||||
// skærmbillede fra en telefon på en skærm i bredformat stod 701 pixels bredt i
|
||||
// arket og 357 i ruden, og et lille billede blev slet ikke forstørret. Nu fylder
|
||||
// det skærmen — også når det skal forstørres for at gøre det — og et klik på
|
||||
// billedet viser det i sin egen størrelse, til at rulle rundt i.
|
||||
let zoomed = $state(false);
|
||||
/** @type {{ w: number, h: number } | null} */
|
||||
let natural = $state(null);
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let lightbox = $state(null);
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLImageElement }} event */
|
||||
function measured(event) {
|
||||
const img = event.currentTarget;
|
||||
natural = { w: img.naturalWidth, h: img.naturalHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift mellem "hele skærmen" og "fuld størrelse".
|
||||
*
|
||||
* Fra et klik på billedet rulles der hen til det sted, man klikkede, så man
|
||||
* ikke lander i øverste venstre hjørne og skal lede efter det, man ville se.
|
||||
*
|
||||
* @param {MouseEvent} [event]
|
||||
*/
|
||||
async function toggleZoom(event) {
|
||||
const img = lightbox?.querySelector('.shot');
|
||||
let fx = 0.5;
|
||||
let fy = 0.5;
|
||||
if (event?.clientX !== undefined && event.detail > 0 && img) {
|
||||
const r = img.getBoundingClientRect();
|
||||
fx = (event.clientX - r.left) / r.width;
|
||||
fy = (event.clientY - r.top) / r.height;
|
||||
}
|
||||
zoomed = !zoomed;
|
||||
await tick();
|
||||
if (!zoomed || !img || !lightbox) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
lightbox.scrollLeft += r.left + r.width * fx - lightbox.clientWidth / 2;
|
||||
lightbox.scrollTop += r.top + r.height * fy - lightbox.clientHeight / 2;
|
||||
}
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
@@ -821,23 +910,54 @@
|
||||
* the caret in another heading in most browsers, and a note where every line is
|
||||
* a title is a note with no title at all.
|
||||
*/
|
||||
function keepTitleFirst() {
|
||||
const block = currentBlock();
|
||||
if (!block) return;
|
||||
function keepTitleFirst(split) {
|
||||
if (!editor) return;
|
||||
|
||||
// A heading that is not the first line becomes body.
|
||||
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
return;
|
||||
}
|
||||
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
|
||||
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
|
||||
// ligesom den tager et ord tilbage.
|
||||
const caret = currentBlock();
|
||||
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
|
||||
|
||||
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||
// return rather than choosing Brødtekst from the menu.
|
||||
if (block.tagName === 'DIV') {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
}
|
||||
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
|
||||
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
|
||||
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
|
||||
// nogen har valgt Titel til i menuen, og den er deres at beholde.
|
||||
const lavet = split?.nextElementSibling;
|
||||
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
|
||||
|
||||
// Og hver <div>, rettelsen ikke nåede.
|
||||
//
|
||||
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
|
||||
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
|
||||
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
|
||||
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
|
||||
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
|
||||
// afstand, hos netop de brugere der skriver hurtigst.
|
||||
//
|
||||
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
|
||||
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
|
||||
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
|
||||
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
|
||||
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
|
||||
const her = currentBlock();
|
||||
for (const block of [...editor.children])
|
||||
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* En blok, der ikke må stå, som den står.
|
||||
*
|
||||
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
|
||||
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
|
||||
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
|
||||
* hver linje er en titel, er en note uden titel.
|
||||
*/
|
||||
function stray(block) {
|
||||
if (block.parentNode !== editor) return false;
|
||||
return (
|
||||
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -912,9 +1032,10 @@
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
// After the split, not before it.
|
||||
// After the split, not before it — and with the block the split started from,
|
||||
// because by the time this runs the caret may be two blocks further on.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
keepTitleFirst(block);
|
||||
changed();
|
||||
}, 0);
|
||||
return;
|
||||
@@ -1218,15 +1339,35 @@
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<div
|
||||
class="lightbox"
|
||||
class:zoomed
|
||||
bind:this={lightbox}
|
||||
onclick={closeImage}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('notes.imageTitle')}
|
||||
style:--w={natural?.w}
|
||||
style:--h={natural?.h}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="shot"
|
||||
class:sized={natural}
|
||||
src={showing.src}
|
||||
alt={showing.alt}
|
||||
onload={measured}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom(e);
|
||||
}}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
<div class="tools" onclick={(e) => e.stopPropagation()}>
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<button onclick={toggleZoom}>{zoomed ? t('notes.imageZoomOut') : t('notes.imageZoomIn')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1243,43 +1384,77 @@
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
står alene — det er hele grunden til at åbne det.
|
||||
|
||||
Centreret med `margin: auto` på billedet og ikke med `justify-content`: i
|
||||
fuld størrelse er billedet større end ruden, og et flex-element, der er
|
||||
centreret og for stort, stikker lige meget ud til begge sider — den venstre og
|
||||
den øverste del kan man så ikke rulle hen til. */
|
||||
.lightbox {
|
||||
--pad: var(--s4);
|
||||
--bar: 4rem;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
background: rgb(0 0 0 / 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* På en telefon bryder de fire knapper over i to rækker. */
|
||||
@media (max-width: 600px) {
|
||||
.lightbox {
|
||||
--pad: var(--s2);
|
||||
--bar: 6.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
flex: none;
|
||||
max-width: calc(100vw - 2 * var(--pad));
|
||||
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
|
||||
border-radius: var(--radius);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Så snart billedets egne mål kendes, fylder det skærmen: den største bredde,
|
||||
der både er inden for skærmens bredde og — ved billedets form — inden for dens
|
||||
højde. Det forstørrer et lille billede, hvad `max-width` alene aldrig gjorde. */
|
||||
.shot.sized {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: min(
|
||||
calc(100vw - 2 * var(--pad)),
|
||||
calc((100dvh - 2 * var(--pad) - var(--bar)) * var(--w) / var(--h))
|
||||
);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Fuld størrelse: billedets egne pixels, og ruden ruller. Aldrig smallere end
|
||||
skærmen — et lille billede ville ellers blive MINDRE af at blive forstørret, og
|
||||
et højt skærmbillede fra en telefon skal kunne læses i hele skærmens bredde. */
|
||||
.zoomed .shot.sized {
|
||||
width: max(calc(var(--w) * 1px), calc(100vw - 2 * var(--pad)));
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/* Knapperne ligger fast nederst, så de er der, uanset hvor man har rullet hen
|
||||
i et billede i fuld størrelse. */
|
||||
.tools {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: var(--s3);
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2 * var(--s3));
|
||||
}
|
||||
|
||||
.tools button,
|
||||
@@ -1299,6 +1474,8 @@
|
||||
}
|
||||
|
||||
.bad {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
|
||||
@@ -16,14 +16,53 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||
let {
|
||||
projectId = undefined,
|
||||
onadded = undefined,
|
||||
autofocus = false,
|
||||
/**
|
||||
* Whether this box carries the `data-quickadd` marker.
|
||||
*
|
||||
* The layout's type-anywhere shortcut finds the field with
|
||||
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
|
||||
* in the document. So a second box on the page — the dictate sheet's — has
|
||||
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
|
||||
*/
|
||||
marked = true,
|
||||
/**
|
||||
* The field's accessible name.
|
||||
*
|
||||
* A prop because the dictate sheet puts a second one of these on the page,
|
||||
* and two fields answering to "Ny opgave" is a screen reader reading the
|
||||
* same name twice with no way to tell which is which. They are different
|
||||
* affordances — one is the box on the page, the other is the thing the
|
||||
* round button opens — so they say different names.
|
||||
*/
|
||||
label = undefined
|
||||
} = $props();
|
||||
|
||||
let fieldLabel = $derived(label ?? t('task.new'));
|
||||
|
||||
let text = $state('');
|
||||
let spans = $state([]);
|
||||
let submitting = $state(false);
|
||||
let input;
|
||||
let mirror;
|
||||
let focused = $state(false);
|
||||
|
||||
/**
|
||||
* Keeps the tint under the right characters when the line is longer than the
|
||||
* field.
|
||||
*
|
||||
* The input scrolls horizontally and the mirror has to scroll with it. Done on
|
||||
* both `scroll` and `input` because typing at the end of a long line moves the
|
||||
* caret — and therefore the scroll — without ever firing a scroll event in some
|
||||
* browsers.
|
||||
*/
|
||||
function syncScroll() {
|
||||
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
|
||||
}
|
||||
|
||||
// Kept beside the parser's own vocabulary on purpose: a legend that promises
|
||||
// something internal/quickadd cannot read is worse than no legend. These four
|
||||
// are what Parse recognises as marks; dates it reads as prose, which is why the
|
||||
@@ -72,6 +111,20 @@
|
||||
if (autofocus && input) input.focus();
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus the field from outside, synchronously.
|
||||
*
|
||||
* This exists because of iOS and nothing else. Safari opens the on-screen
|
||||
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
|
||||
* it — so the `autofocus` effect above, which runs after the click handler has
|
||||
* returned, gives the field a caret and no keyboard. For the dictate button that
|
||||
* is the whole feature failing quietly: the sheet opens, nothing to dictate
|
||||
* into, and there is no error anywhere.
|
||||
*/
|
||||
export function focusField() {
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/** Splits the raw text into highlighted and plain runs, by byte offset. */
|
||||
let segments = $derived.by(() => {
|
||||
if (!spans.length) return [{ text, kind: null }];
|
||||
@@ -197,7 +250,7 @@
|
||||
<!-- The mirror sits behind the input and must match it exactly: same font,
|
||||
same padding, same wrapping. Any difference shows up as highlighting
|
||||
that drifts out of alignment as the line grows. -->
|
||||
<div class="mirror" aria-hidden="true">
|
||||
<div class="mirror" aria-hidden="true" bind:this={mirror}>
|
||||
{#each segments as segment}
|
||||
{#if segment.kind}
|
||||
<mark data-kind={segment.kind}>{segment.text}</mark>
|
||||
@@ -211,12 +264,14 @@
|
||||
bind:this={input}
|
||||
bind:value={text}
|
||||
{onkeydown}
|
||||
onscroll={syncScroll}
|
||||
oninput={syncScroll}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={() => (focused = false)}
|
||||
type="text"
|
||||
data-quickadd
|
||||
data-quickadd={marked ? '' : undefined}
|
||||
placeholder={t('task.placeholder')}
|
||||
aria-label={t('task.new')}
|
||||
aria-label={fieldLabel}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{onpaste}
|
||||
@@ -385,8 +440,16 @@
|
||||
letter-spacing: normal;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
/* `pre`, not `pre-wrap`, and that is the whole alignment.
|
||||
|
||||
An <input type="text"> is single-line by specification and cannot wrap
|
||||
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
|
||||
not drift a little — it puts the tint on a second line that the input does
|
||||
not have, and the highlight ends up under empty space. Rare on a wide
|
||||
desktop field, constant in the dictate sheet on a 390px phone, where a
|
||||
spoken sentence overflows immediately. */
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
@@ -394,6 +457,10 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: transparent;
|
||||
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
|
||||
has to be hidden rather than visible, or the long tail of a line paints
|
||||
out past the field's edge. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The tint is behind the glyphs, not on them: colouring the text itself would
|
||||
|
||||
@@ -73,6 +73,11 @@
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
|
||||
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
|
||||
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
|
||||
// drift i to merges.
|
||||
if (app.notesOnly) return;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
@@ -155,9 +160,12 @@
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
|
||||
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
|
||||
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
|
||||
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
|
||||
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
!app.notesOnly && app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
@@ -185,7 +193,7 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -472,11 +480,15 @@
|
||||
|
||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||
<div class="brand">
|
||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
||||
the name is in the tab and everywhere else already. -->
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<span class="name">verdande</span>
|
||||
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
|
||||
was meant to be: uruz is already another of this author's apps and
|
||||
carries that glyph. See web/static/urd-icon.svg.
|
||||
|
||||
Both come from the store rather than from here, because the two editions
|
||||
of this program are two products with two names, and a wordmark is the
|
||||
one place a reader decides which one they are looking at. -->
|
||||
<span class="rune" aria-hidden="true">{app.productMark}</span>
|
||||
<span class="name">{app.productSlug}</span>
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
|
||||
@@ -21,9 +21,56 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
|
||||
// --- which server (the iOS app only) ------------------------------------------
|
||||
|
||||
const native = isNativeApp();
|
||||
let server = $state(native ? appSession.server() : '');
|
||||
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
|
||||
|
||||
/**
|
||||
* The app has no server of its own. Before a password field is shown for an
|
||||
* address somebody typed, the address has to be a Verdande: asked with /ping,
|
||||
* which answers without a login and names the product. A router's login page,
|
||||
* a typo that lands on somebody's blog, a server that is down — each gets its
|
||||
* own sentence here instead of a sign-in form that cannot succeed.
|
||||
*/
|
||||
async function chooseServer() {
|
||||
const url = normaliseServer(server);
|
||||
if (!url) {
|
||||
error = t('app.serverInvalid');
|
||||
return;
|
||||
}
|
||||
let answer;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
|
||||
answer = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
|
||||
return;
|
||||
}
|
||||
if (answer?.product !== 'verdande') {
|
||||
error = t('app.notVerdande');
|
||||
return;
|
||||
}
|
||||
appSession.setServer(url);
|
||||
server = url;
|
||||
await enterServer();
|
||||
}
|
||||
|
||||
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
|
||||
async function enterServer() {
|
||||
try {
|
||||
const state = await api.setupState();
|
||||
mode = state.needs_setup ? 'setup' : 'login';
|
||||
} catch {
|
||||
mode = 'login';
|
||||
}
|
||||
}
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
@@ -37,6 +84,9 @@
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Not in the app: a passkey is bound to the server's own domain, and the
|
||||
// app's pages are not on it. Offered there it would fail every time.
|
||||
if (native) return;
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
@@ -94,10 +144,11 @@
|
||||
mode = linkMode;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.setupState()
|
||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||
.catch(() => (mode = 'login'));
|
||||
if (native && !appSession.server()) {
|
||||
mode = 'server';
|
||||
return;
|
||||
}
|
||||
enterServer();
|
||||
});
|
||||
|
||||
async function run(fn) {
|
||||
@@ -120,6 +171,10 @@
|
||||
|
||||
run(async () => {
|
||||
switch (mode) {
|
||||
case 'server':
|
||||
await chooseServer();
|
||||
break;
|
||||
|
||||
case 'setup':
|
||||
await api.setup({
|
||||
email,
|
||||
@@ -190,6 +245,34 @@
|
||||
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'server'}
|
||||
<p class="lede">{t('app.serverPrompt')}</p>
|
||||
|
||||
<label>
|
||||
{t('app.server')}
|
||||
<input
|
||||
bind:value={server}
|
||||
use:focusOnMount
|
||||
type="url"
|
||||
inputmode="url"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
placeholder="verdande.example.dk"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{t('app.serverHint')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('app.connect')}</button>
|
||||
{:else if mode === 'setup' && native}
|
||||
<!-- The first account starts a browser session, and the app has no use
|
||||
for a cookie. An instance this new is being set up by whoever runs
|
||||
it, at a desk; they do it there, and the app signs in after. -->
|
||||
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
@@ -303,6 +386,15 @@
|
||||
{:else}
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
{#if native}
|
||||
<p class="server-line">
|
||||
<span class="mono">{serverHost}</span>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
@@ -463,6 +555,23 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Which server the app is signing in to, and the way back to choosing. */
|
||||
.server-line {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.server-line .mono {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
import { REPEATS, reminderOffsetLabel } from '$lib/when.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
@@ -757,7 +757,9 @@
|
||||
|
||||
{#each reminders as reminder (reminder.id)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
<span>{reminder.remind_at
|
||||
? stamp(reminder.remind_at)
|
||||
: reminderOffsetLabel(reminder.offset_min)}</span>
|
||||
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
|
||||
@@ -145,7 +145,7 @@ export const da = {
|
||||
// læserens mulighed for at efterprøve det.
|
||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||
'beacon.noticeBody':
|
||||
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'beacon.noticeTo': 'Sendes til {url}',
|
||||
'beacon.noticeKeep': 'Behold den',
|
||||
'beacon.noticeStop': 'Slå den fra',
|
||||
@@ -177,6 +177,8 @@ export const da = {
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.imageZoomIn': 'Fuld størrelse',
|
||||
'notes.imageZoomOut': 'Tilpas skærmen',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
@@ -238,7 +240,7 @@ export const da = {
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'net.downTitle': 'Ingen forbindelse',
|
||||
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downRetry': 'Prøv igen',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
@@ -267,6 +269,13 @@ export const da = {
|
||||
'task.pastedAdd': 'Opret {n} opgaver',
|
||||
'task.pastedCancel': 'Fortryd',
|
||||
'task.new': 'Ny opgave',
|
||||
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||
'task.alarmNeedsATime':
|
||||
'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.',
|
||||
'task.speak': 'Tal en opgave ind',
|
||||
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
|
||||
'task.syntaxProject': 'projekt',
|
||||
'task.syntaxSection': 'sektion',
|
||||
'task.syntaxLabel': 'etiket',
|
||||
@@ -353,7 +362,7 @@ export const da = {
|
||||
'size.default': 'Standard',
|
||||
'size.large': 'Stor',
|
||||
'size.xl': 'Størst',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'Som du kender den.',
|
||||
'look.rolig': 'Rolig',
|
||||
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||
@@ -398,6 +407,18 @@ export const da = {
|
||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarm som standard',
|
||||
'alarm.hint':
|
||||
'Opgaver med bu00e5de en dato og et klokkeslu00e6t fu00e5r en alarm, uden at du beder om den. En opgave uden klokkeslu00e6t fu00e5r ingen — der er intet tidspunkt at ringe pu00e5. Skriv "ingen alarm" i linjen for at springe en enkelt over, eller "mind mig en time fu00f8r" for at give den sin egen.',
|
||||
'alarm.on': 'Giv opgaver med et klokkeslu00e6t en alarm',
|
||||
'alarm.when': 'Hvornu00e5r',
|
||||
'alarm.atDue': 'Pu00e5 tidspunktet',
|
||||
'alarm.minBefore': '{n} min. fu00f8r',
|
||||
'alarm.hourBefore': '1 time fu00f8r',
|
||||
'alarm.hoursBefore': '{n} timer fu00f8r',
|
||||
'alarm.dayBefore': 'Dagen fu00f8r',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifikationer på enheden',
|
||||
'push.hint':
|
||||
@@ -715,6 +736,15 @@ export const da = {
|
||||
'auth.savePassword': 'Gem adgangskoden',
|
||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
|
||||
'app.server': 'Serveradresse',
|
||||
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
|
||||
'app.connect': 'Fortsæt',
|
||||
'app.serverInvalid': 'Det ligner ikke en adresse.',
|
||||
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
|
||||
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
|
||||
'app.changeServer': 'Skift server',
|
||||
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
|
||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||
'auth.signIn': 'Log ind',
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
@@ -757,6 +787,14 @@ export const da = {
|
||||
'detail.newSubtask': 'Ny undertask',
|
||||
'detail.files': 'Filer',
|
||||
'detail.delete': 'Slet',
|
||||
'detail.remindAtDue': 'På tidspunktet',
|
||||
'detail.remindBefore': '{when} før',
|
||||
'detail.remindAfter': '{when} efter',
|
||||
'when.minuteMany': '{n} min.',
|
||||
'when.hourOne': '1 time',
|
||||
'when.hourMany': '{n} timer',
|
||||
'when.dayOne': '1 dag',
|
||||
'when.dayMany': '{n} dage',
|
||||
'detail.reminders': 'Påmindelser',
|
||||
'detail.newReminder': 'Ny påmindelse',
|
||||
'detail.sent': 'sendt',
|
||||
|
||||
@@ -142,7 +142,7 @@ export const en = {
|
||||
// the reader's ability to check it.
|
||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||
'beacon.noticeBody':
|
||||
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'beacon.noticeTo': 'Sent to {url}',
|
||||
'beacon.noticeKeep': 'Keep it on',
|
||||
'beacon.noticeStop': 'Turn it off',
|
||||
@@ -174,6 +174,8 @@ export const en = {
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.imageZoomIn': 'Full size',
|
||||
'notes.imageZoomOut': 'Fit the screen',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
@@ -235,7 +237,7 @@ export const en = {
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'net.downTitle': 'No connection',
|
||||
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downRetry': 'Try again',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
@@ -264,6 +266,10 @@ export const en = {
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.alarmNeedsATime':
|
||||
'The task is created. The alarm is not — "an hour before" needs a time to count back from.',
|
||||
'task.speak': 'Speak a task',
|
||||
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
|
||||
'task.syntaxProject': 'project',
|
||||
'task.syntaxSection': 'section',
|
||||
'task.syntaxLabel': 'label',
|
||||
@@ -350,7 +356,7 @@ export const en = {
|
||||
'size.default': 'Default',
|
||||
'size.large': 'Large',
|
||||
'size.xl': 'Largest',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'As you know it.',
|
||||
'look.rolig': 'Calm',
|
||||
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||
@@ -395,6 +401,18 @@ export const en = {
|
||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||
'account.recoveryLeftMany': '{n} recovery codes left.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarms by default',
|
||||
'alarm.hint':
|
||||
'Tasks with both a date and a time get an alarm without being asked. A task with no time gets none — there is no moment to ring at. Write "no alarm" in the line to skip a single one, or "remind me an hour before" to give it its own.',
|
||||
'alarm.on': 'Give tasks with a time an alarm',
|
||||
'alarm.when': 'When',
|
||||
'alarm.atDue': 'At the time',
|
||||
'alarm.minBefore': '{n} min before',
|
||||
'alarm.hourBefore': '1 hour before',
|
||||
'alarm.hoursBefore': '{n} hours before',
|
||||
'alarm.dayBefore': 'The day before',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifications on this device',
|
||||
'push.hint':
|
||||
@@ -712,6 +730,15 @@ export const en = {
|
||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||
'auth.resetSent':
|
||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
|
||||
'app.server': 'Server address',
|
||||
'app.serverHint': 'The address you open Verdande at in a browser.',
|
||||
'app.connect': 'Continue',
|
||||
'app.serverInvalid': 'That does not look like an address.',
|
||||
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
|
||||
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
|
||||
'app.changeServer': 'Change server',
|
||||
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
|
||||
'auth.signInPrompt': 'Sign in to continue.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
@@ -754,6 +781,14 @@ export const en = {
|
||||
'detail.newSubtask': 'New sub-task',
|
||||
'detail.files': 'Files',
|
||||
'detail.delete': 'Delete',
|
||||
'detail.remindAtDue': 'At the time',
|
||||
'detail.remindBefore': '{when} before',
|
||||
'detail.remindAfter': '{when} after',
|
||||
'when.minuteMany': '{n} min',
|
||||
'when.hourOne': '1 hour',
|
||||
'when.hourMany': '{n} hours',
|
||||
'when.dayOne': '1 day',
|
||||
'when.dayMany': '{n} days',
|
||||
'detail.reminders': 'Reminders',
|
||||
'detail.newReminder': 'New reminder',
|
||||
'detail.sent': 'sent',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pictures and files from the server, inside the iOS app.
|
||||
*
|
||||
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
|
||||
* a task's file list, in the export links — and in a browser that is the whole
|
||||
* story: the path is on the page's own origin and the cookie goes with it. In the
|
||||
* app neither holds. The path would resolve against capacitor://localhost, and an
|
||||
* `<img>` or a link cannot carry the bearer token even when pointed at the right
|
||||
* server.
|
||||
*
|
||||
* So in the app, and only there, this watches the page: an image whose source is
|
||||
* an API path is fetched with the token and shown from a blob, and a tap on a
|
||||
* link to an API path is answered the same way. Done once for the whole document
|
||||
* rather than in each component, because the paths come from many places — a
|
||||
* note's own markdown among them — and a component that forgot would show a
|
||||
* broken picture only in the app, where nobody runs the tests.
|
||||
*
|
||||
* The original path is kept in `data-src`. The note editor turns the page back
|
||||
* into markdown, and a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it; `richtext.js` reads `data-src` first.
|
||||
*/
|
||||
import { apiURL, authorize, isNativeApp } from './platform.js';
|
||||
|
||||
const API = '/api/v1/';
|
||||
const blobs = new Map();
|
||||
|
||||
async function blobFor(path) {
|
||||
if (!blobs.has(path)) {
|
||||
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
|
||||
async (res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
);
|
||||
// A failure is not cached: the next sight of the picture tries again.
|
||||
promise.catch(() => blobs.delete(path));
|
||||
blobs.set(path, promise);
|
||||
}
|
||||
return blobs.get(path);
|
||||
}
|
||||
|
||||
function adopt(img) {
|
||||
const path = img.getAttribute('src');
|
||||
// Our own blob address coming back through the observer is not an API path,
|
||||
// so it stops here; a new API path on an image already adopted is taken up.
|
||||
if (!path?.startsWith(API) || img.dataset.src === path) return;
|
||||
img.dataset.src = path;
|
||||
// Nothing until the picture is here, rather than a broken-image glyph.
|
||||
img.removeAttribute('src');
|
||||
blobFor(path)
|
||||
.then((url) => {
|
||||
if (img.dataset.src === path) img.src = url;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function scan(root) {
|
||||
if (root.nodeType !== 1) return;
|
||||
if (root.tagName === 'IMG') adopt(root);
|
||||
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
|
||||
}
|
||||
|
||||
async function openLink(event) {
|
||||
const a = event.target.closest?.('a[href]');
|
||||
const path = a?.getAttribute('href');
|
||||
if (!path?.startsWith(API)) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
window.open(await blobFor(path), '_blank');
|
||||
} catch {
|
||||
// Nothing to open; the request's own failure is what there is to say,
|
||||
// and there is no screen here to say it on.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called once by the layout. A no-op in a browser. */
|
||||
export function installNativeMedia() {
|
||||
if (!isNativeApp() || typeof document === 'undefined') return;
|
||||
scan(document.body);
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'attributes') adopt(record.target);
|
||||
else for (const node of record.addedNodes) scan(node);
|
||||
}
|
||||
}).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
document.addEventListener('click', openLink, true);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Where the interface is running, and what that changes about reaching the server.
|
||||
*
|
||||
* In a browser the interface and the API share an origin: every path is relative
|
||||
* and the session is an httpOnly cookie no script can see. In the iOS app the
|
||||
* interface is bundled into the app and served from capacitor://localhost, and
|
||||
* the server is whichever one its owner typed in. So the app needs three things
|
||||
* a browser never does: the server's address, its own session token, and that
|
||||
* token on every request instead of a cookie.
|
||||
*
|
||||
* Everything that differs between the two lives here, so `api.js` and the socket
|
||||
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
|
||||
* is exactly what it was before this file existed.
|
||||
*/
|
||||
|
||||
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
|
||||
* our code runs. */
|
||||
export function isNativeApp() {
|
||||
return !!globalThis.Capacitor?.isNativePlatform?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's stored settings: which server, and the session on it.
|
||||
*
|
||||
* localStorage for now, behind this one object, so the Keychain plugin can take
|
||||
* its place without anything else changing. The token is a session — whoever
|
||||
* holds it is signed in — and the Keychain is where a phone keeps those; until it
|
||||
* is wired in, this is a WKWebView's own storage, readable only by the app.
|
||||
*/
|
||||
const SERVER_KEY = 'verdande.app.server';
|
||||
const TOKEN_KEY = 'verdande.app.session';
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage refused (private mode, quota). The app then asks again next
|
||||
// launch, which is the right failure: a token that seemed saved and was
|
||||
// not would look like being signed out for no reason.
|
||||
}
|
||||
}
|
||||
|
||||
export const appSession = {
|
||||
server: () => read(SERVER_KEY),
|
||||
setServer: (url) => write(SERVER_KEY, url),
|
||||
token: () => read(TOKEN_KEY),
|
||||
setToken: (token) => write(TOKEN_KEY, token),
|
||||
/** Signed out: the token goes, the server stays — signing in again is to the
|
||||
* same place nearly every time. */
|
||||
signOut: () => write(TOKEN_KEY, '')
|
||||
};
|
||||
|
||||
/** The address of an API path: relative in a browser, on the chosen server in
|
||||
* the app. */
|
||||
export function apiURL(path) {
|
||||
const base = isNativeApp() ? appSession.server() : '';
|
||||
return `${base}/api/v1${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in how a request proves who is asking.
|
||||
*
|
||||
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
|
||||
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
|
||||
* token as a bearer.
|
||||
*/
|
||||
export function authorize(init) {
|
||||
if (!isNativeApp()) {
|
||||
init.credentials = 'same-origin';
|
||||
return init;
|
||||
}
|
||||
init.credentials = 'omit';
|
||||
const token = appSession.token();
|
||||
if (token) {
|
||||
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
/** What the app calls itself in the list of signed-in devices. */
|
||||
export function deviceName() {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
return 'Verdande-appen';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises what somebody typed as their server's address.
|
||||
*
|
||||
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
|
||||
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
|
||||
* because a self-hoster on their own network may have no certificate — iOS will
|
||||
* refuse it unless the app allows it, and that refusal should be the one they see.
|
||||
*/
|
||||
export function normaliseServer(input) {
|
||||
let s = (input ?? '').trim();
|
||||
if (!s) return '';
|
||||
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@
|
||||
|
||||
import { api } from './api.js';
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { isNativeApp } from './platform.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
export function supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
||||
// has no push service, and a subscription made there would go nowhere.
|
||||
!isNativeApp() &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
|
||||
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
|
||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||
// nogen havde rørt det.
|
||||
case 'IMG':
|
||||
return ` ?? ''})`;
|
||||
// data-src first: in the iOS app the shown source is a blob made on this
|
||||
// device, and the path it was made from is what the note keeps. See
|
||||
// nativemedia.js.
|
||||
return ` || ''})`;
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
|
||||
@@ -38,6 +38,7 @@ export const SETTINGS = [
|
||||
// Notifikationer
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'standard-alarm', key: 'alarm.title', tab: 'settings.tab.notifications', words: 'alarm pu00e5mindelse standard reminder default klokkeslu00e6t' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
import { appSession, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* Where the live-sync socket is. A browser opens it on its own origin and the
|
||||
* cookie goes with the handshake. The app cannot put its token on a handshake,
|
||||
* so it asks for a one-use ticket first and opens the socket on its server with
|
||||
* that.
|
||||
*/
|
||||
async function socketURL() {
|
||||
if (!isNativeApp()) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/api/v1/ws`;
|
||||
}
|
||||
const { ticket } = await api.wsTicket();
|
||||
const base = appSession.server().replace(/^http/, 'ws');
|
||||
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -43,6 +60,126 @@ class AppState {
|
||||
*/
|
||||
unreachable = $state(false);
|
||||
|
||||
/**
|
||||
* Hvilken udgave af programmet den her instans er.
|
||||
*
|
||||
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
|
||||
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
|
||||
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
|
||||
* det er en knap, der fører et sted hen, hvor der ikke er noget.
|
||||
*
|
||||
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
|
||||
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
|
||||
*
|
||||
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
|
||||
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
|
||||
* program.
|
||||
*/
|
||||
edition = $state('full');
|
||||
|
||||
get notesOnly() {
|
||||
return this.edition === 'notes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue kom ind ad.
|
||||
*
|
||||
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
|
||||
* filen her. De to ligner hinanden og betyder ikke det samme:
|
||||
*
|
||||
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
|
||||
* dem, for de svarer 404.
|
||||
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
|
||||
* dataene er deres egne, og der er intet at holde tilbage.
|
||||
*
|
||||
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
|
||||
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
|
||||
* database og personen bare valgte en anden indgang.
|
||||
*
|
||||
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
|
||||
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
|
||||
* PWA er sit eget vindue.
|
||||
*/
|
||||
urdFace = $state(false);
|
||||
|
||||
enterUrd() {
|
||||
this.setDoor('urd');
|
||||
}
|
||||
|
||||
leaveUrd() {
|
||||
this.setDoor('verdande');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue sidst kom ind ad.
|
||||
*
|
||||
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
|
||||
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
|
||||
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
|
||||
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
|
||||
* tiden.
|
||||
*/
|
||||
setDoor(door) {
|
||||
this.urdFace = door === 'urd';
|
||||
try {
|
||||
sessionStorage.setItem('verdande:door', door);
|
||||
} catch (e) {
|
||||
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
||||
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
||||
// mister det.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
|
||||
*
|
||||
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
|
||||
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
|
||||
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
|
||||
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
|
||||
*
|
||||
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud
|
||||
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
|
||||
* vise søsterens navn et øjeblik og så rette sig selv.
|
||||
*/
|
||||
/**
|
||||
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
|
||||
* fordi personen kom ind ad urd-døren.
|
||||
*
|
||||
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
|
||||
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
|
||||
* om der overhovedet er en rute at hente fra.
|
||||
*/
|
||||
get showsUrd() {
|
||||
return this.notesOnly || this.urdFace;
|
||||
}
|
||||
|
||||
get productName() {
|
||||
return this.showsUrd ? 'Urd' : 'Verdande';
|
||||
}
|
||||
|
||||
get productSlug() {
|
||||
return this.showsUrd ? 'urd' : 'verdande';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mærket: ᚹ wunjo for Verdande, ð for Urd.
|
||||
*
|
||||
* Ikke to runer, selvom det var meningen. Urds oplagte mærke var ᚢ uruz,
|
||||
* bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er
|
||||
* wunjo — og det blev tegnet sådan først. Men uruz er allerede et af husets
|
||||
* egne produkter, og dets `gen-icons.ts` tegner samme glyf i guld. To af samme
|
||||
* persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det sted,
|
||||
* mærket skal kunne skelnes. ᚦ thurisaz blev forkastet et niveau nede af samme
|
||||
* grund: en stav med en trekant til højre, og det er ᚹ også.
|
||||
*
|
||||
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
|
||||
*/
|
||||
get productMark() {
|
||||
return this.showsUrd ? 'ð' : '\u16B9';
|
||||
}
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
@@ -108,6 +245,34 @@ class AppState {
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// The app before it knows which server it belongs to: nothing to ask, and
|
||||
// the sign-in screen is where the address is asked for.
|
||||
if (isNativeApp() && !appSession.server()) {
|
||||
this.user = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||
// linje ville ansigtet falde af ved hvert F5.
|
||||
try {
|
||||
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
|
||||
} catch (e) {
|
||||
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
|
||||
}
|
||||
|
||||
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
||||
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
||||
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
||||
const shape = api
|
||||
.setupState()
|
||||
.then((state) => {
|
||||
if (state?.edition) this.edition = state.edition;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
try {
|
||||
this.user = await api.me();
|
||||
this.unreachable = false;
|
||||
@@ -131,16 +296,33 @@ class AppState {
|
||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||
this.unreachable = e?.code === 'offline';
|
||||
} finally {
|
||||
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
|
||||
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
|
||||
// en instans, der ikke har nogen.
|
||||
await shape;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- realtime ---------------------------------------------------------------
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
if (this.#socket) return;
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
||||
// Claimed before the await below, so two calls in the same moment cannot
|
||||
// both open one.
|
||||
this.#socket = 'opening';
|
||||
let url;
|
||||
try {
|
||||
url = await socketURL();
|
||||
} catch {
|
||||
// No ticket — offline, or signed out. Try again the way a dropped
|
||||
// socket does.
|
||||
this.#socket = null;
|
||||
setTimeout(() => this.connect(), this.#reconnectDelay);
|
||||
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(url);
|
||||
this.#socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -278,6 +460,21 @@ class AppState {
|
||||
* `completed: 'exclude'`.
|
||||
*/
|
||||
async loadTasks(params) {
|
||||
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
|
||||
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
|
||||
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
|
||||
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
|
||||
// den fjerde, og det var præcis sådan den her slap igennem.
|
||||
if (this.notesOnly) {
|
||||
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
|
||||
// looper: projektsiden har en effekt, der læser app.tasks og kalder
|
||||
// herind, så et skriv ved hvert kald er en uendelig runde —
|
||||
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
|
||||
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
|
||||
// fejl.
|
||||
if (this.tasks.length) this.tasks = [];
|
||||
return this.tasks;
|
||||
}
|
||||
const { tasks } = await api.listTasks({
|
||||
completed: completedView.shown ? 'include' : undefined,
|
||||
...params
|
||||
@@ -528,6 +725,10 @@ class AppState {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||
this.upsert(task);
|
||||
// Sagt højt frem for tabt. En alarm, der ikke kunne sættes, er den eneste
|
||||
// slags påmindelse, hvis fejl intet bagefter afslører — der står ingen
|
||||
// række at savne, og man opdager den først, da den ikke ringede.
|
||||
if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime'));
|
||||
return task;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
|
||||
@@ -184,3 +184,37 @@ function every(interval, unit) {
|
||||
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||
}
|
||||
|
||||
/**
|
||||
* En påmindelses forskydning, skrevet som ord.
|
||||
*
|
||||
* Lageret LÆGGER tallet til forfaldstiden (`due_datetime + offset_min * 60`), så
|
||||
* en alarm før er negativ. Rå blev det vist som "-60 min.": et fortegn, læseren
|
||||
* selv skal oversætte, på netop den oplysning man skal kunne stole på uden at
|
||||
* regne — og en alarm, man har misforstået, opdager man først, da den ringede på
|
||||
* det forkerte tidspunkt.
|
||||
*
|
||||
* Retningen er skilt fra varigheden, så der kun er ét sæt enheder at vedligeholde.
|
||||
* En positiv forskydning kan ikke laves hverken i hurtig tilføjelse eller i
|
||||
* skærmbilledet — kun gennem API'et — men den har et ord her frem for at falde
|
||||
* tilbage på et tal, for det er den slags gren, der står urørt indtil den en dag
|
||||
* ikke gør.
|
||||
*/
|
||||
export function reminderOffsetLabel(minutes) {
|
||||
if (minutes === 0) return t('detail.remindAtDue');
|
||||
const span = durationLabel(Math.abs(minutes));
|
||||
return minutes < 0 ? t('detail.remindBefore', { when: span }) : t('detail.remindAfter', { when: span });
|
||||
}
|
||||
|
||||
/** Minutter som hele dage, hele timer eller minutter — den største, der går op. */
|
||||
function durationLabel(minutes) {
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return days === 1 ? t('when.dayOne') : t('when.dayMany', { n: days });
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const hours = minutes / 60;
|
||||
return hours === 1 ? t('when.hourOne') : t('when.hourMany', { n: hours });
|
||||
}
|
||||
return t('when.minuteMany', { n: minutes });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import DictateSheet from '$lib/components/DictateSheet.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||
@@ -14,6 +15,7 @@
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||
import { installNativeMedia } from '$lib/nativemedia.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -57,6 +59,64 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
// Pictures and downloads from the server, in the iOS app. A no-op in a
|
||||
// browser. See nativemedia.js.
|
||||
$effect(() => {
|
||||
installNativeMedia();
|
||||
});
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue er inde ad.
|
||||
*
|
||||
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
|
||||
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
|
||||
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
|
||||
* vinduet åbnede med.
|
||||
*
|
||||
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
|
||||
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
|
||||
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
|
||||
* hver sin dør — men en browserfane springer mellem dem hele tiden.
|
||||
*/
|
||||
$effect(() => {
|
||||
const here = $page.url.pathname;
|
||||
if (here === '/urd') app.enterUrd();
|
||||
else if (here === '/') app.leaveUrd();
|
||||
});
|
||||
|
||||
/**
|
||||
* Opgavernes sider findes ikke i noter-udgaven.
|
||||
*
|
||||
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
|
||||
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
|
||||
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
|
||||
*
|
||||
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
|
||||
* Adressen hører bare til et andet program end det, der kører her — og nogen,
|
||||
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
|
||||
* mening frem for i en blindgyde.
|
||||
*
|
||||
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
|
||||
* væk fra, og derfra tilbage igen.
|
||||
*/
|
||||
const TASK_PAGES = [
|
||||
'/',
|
||||
'/upcoming',
|
||||
'/uddelegeret',
|
||||
'/kalender',
|
||||
'/faerdige',
|
||||
'/opgave',
|
||||
'/filter',
|
||||
'/etiket'
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (!app.notesOnly) return;
|
||||
const here = $page.url.pathname;
|
||||
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
|
||||
if (taskPage) goto('/noter', { replaceState: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||
*
|
||||
@@ -262,6 +322,15 @@
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
|
||||
komponenten.
|
||||
|
||||
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
|
||||
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
|
||||
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
|
||||
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
|
||||
<DictateSheet />
|
||||
</div>
|
||||
|
||||
<CommandPalette bind:open={paletteOpen} />
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
{#if status === 'denied'}
|
||||
|
||||
@@ -379,7 +379,10 @@
|
||||
<!-- Each card is written in the face it selects, because that is the
|
||||
only part of the choice a name cannot carry. -->
|
||||
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
||||
<span class="theme-name">{t(option.name)}</span>
|
||||
<!-- The product name is passed to every look, and only one of them has a
|
||||
place for it: the look named after the program. The other three
|
||||
carry no placeholder, so the parameter is ignored. -->
|
||||
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
|
||||
<span class="theme-note">{t(option.note)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -141,6 +141,13 @@
|
||||
let startDate = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
|
||||
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
|
||||
// fejlbesked på indstillingssiden frem for en tom liste.
|
||||
if (app.notesOnly) {
|
||||
loadingTemplates = false;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.listTemplates()
|
||||
.then((r) => (templates = r.templates))
|
||||
|
||||
@@ -296,7 +296,11 @@
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
// Feedet er en kalender af opgaver, så adressen findes ikke i
|
||||
// noter-udgaven.
|
||||
if (!app.notesOnly) {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
}
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
import * as push from '$lib/push.js';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- alarm som standard -------------------------------------------------------
|
||||
|
||||
// Minutter i forhold til forfaldstiden, negativt for fu00f8r. Samme fortegn som
|
||||
// reminders.offset_min, fordi det er den kolonne, vu00e6rdien ender i — to
|
||||
// fortegnsregler for det samme begreb viser sig kun som en alarm pu00e5 et forkert
|
||||
// tidspunkt.
|
||||
const ALARM_CHOICES = [0, -5, -10, -15, -30, -60, -120, -1440];
|
||||
|
||||
// Valgt, nu00e5r indstillingen slu00e5s til igen. Pu00e5 tidspunktet er sju00e6ldent det, man
|
||||
// mener med en alarm: der er ingen varsel i at blive mindet om noget, mens det
|
||||
// sker.
|
||||
const ALARM_DEFAULT = -10;
|
||||
|
||||
function alarmLabel(minutes) {
|
||||
if (minutes === 0) return t('alarm.atDue');
|
||||
const n = Math.abs(minutes);
|
||||
if (n === 1440) return t('alarm.dayBefore');
|
||||
if (n === 60) return t('alarm.hourBefore');
|
||||
if (n % 60 === 0) return t('alarm.hoursBefore', { n: n / 60 });
|
||||
return t('alarm.minBefore', { n });
|
||||
}
|
||||
|
||||
async function saveAlarm(minutes) {
|
||||
try {
|
||||
await api.setDefaultReminder(minutes);
|
||||
// Kontoen genlu00e6ses frem for at blive gu00e6ttet lokalt: serveren er den, der
|
||||
// afviser et positivt tal, su00e5 det, der vises, skal vu00e6re det, der blev gemt.
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- web push ----------------------------------------------------------------
|
||||
|
||||
let pushState = $state('loading');
|
||||
@@ -92,6 +125,8 @@
|
||||
let planBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Dagens plan er dagens opgaver.
|
||||
if (app.notesOnly) return;
|
||||
api
|
||||
.planSettings()
|
||||
.then((p) => (plan = p))
|
||||
@@ -252,6 +287,37 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="standard-alarm">
|
||||
<header>
|
||||
<h2>{t('alarm.title')}</h2>
|
||||
<p class="hint">{t('alarm.hint')}</p>
|
||||
</header>
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.user?.default_reminder_min != null}
|
||||
onchange={(e) => saveAlarm(e.currentTarget.checked ? ALARM_DEFAULT : null)}
|
||||
/>
|
||||
<span>{t('alarm.on')}</span>
|
||||
</label>
|
||||
|
||||
{#if app.user?.default_reminder_min != null}
|
||||
<div class="field">
|
||||
<label for="alarmwhen">{t('alarm.when')}</label>
|
||||
<select
|
||||
id="alarmwhen"
|
||||
value={app.user.default_reminder_min}
|
||||
onchange={(e) => saveAlarm(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each ALARM_CHOICES as minutes (minutes)}
|
||||
<option value={minutes}>{alarmLabel(minutes)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="push">
|
||||
<header>
|
||||
<h2>{t('push.title')}</h2>
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* and it reads the token out of the URL itself.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
|
||||
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
|
||||
// pointen med den her rute.
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Invitation · verdande</title></svelte:head>
|
||||
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
|
||||
@@ -730,8 +730,10 @@
|
||||
const note = await api.createNote({ body: '' });
|
||||
notes = [note, ...notes];
|
||||
// Svaret er hele noten, så den skal ikke hentes igen — se open().
|
||||
// Markøren sættes af editoren selv, når noten er tom — se
|
||||
// placeCaretInTitle i NoteEditor. Her stod en focus() på
|
||||
// `.editor textarea`, og editoren har ingen textarea.
|
||||
await open(note, { whole: true });
|
||||
document.querySelector('.editor textarea')?.focus();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -933,7 +935,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onclick={onShareOutside}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user