Compare commits

...
Author SHA1 Message Date
Kristian Wind c868be8f89 Merge pull request #82 from kristianwind/hjemmesiden-er-med
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 10m19s
CI / Docker image builds (push) Successful in 2m3s
CI / Go (push) Failing after 14m45s
docs(site): landingssiden når op til v0.47 (+ en flakkende prøve)
2026-09-15 14:21:35 +02:00
kristianwindandClaude Opus 5 145fefea02 docs(site): landingssiden når op til v0.47
Den udrullede side på verdandetodo.com er fra 20.-22. august — den matcher en
commit fra før v0.42.15. Repoets egen kopi er fra 4. september. Begge stopper,
hvor "Lately" stopper, og den sagde 0.42.

Ni nye poster: billederne i noter, søgningen der bliver stående, deling af et
projekt ved navn, besked og udgang, søgning i indstillinger, dagens plan som
kort, browservalget, hele AI-laget, versionsbeskeden til administratorer, og
krogen der lader hvad som helst lægge en opgave i indbakken.

De to ældste ryger. Med ni nyere poster er 0.34 og 0.33 ikke "for nylig" længere,
og hele historien står på udgivelsessiden, som afsnittet selv henviser til.

Siden udrulles i hånden — der er ingen arbejdsgang, der gør det. Det er derfor,
den er tre en halv uge bagud, og det er værd at tage stilling til separat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 5d911d40fe test(noter): vent på at noten er gemt, før den næste laves
Prøven fra i går fejlede i CI på en PR, der kun rørte dokumentation — altså
flakkede den, og det er min.

Løkken laver tre noter og klikkede "Ny note" igen, uden at vente på at den
forrige gemning var kvitteret. Her på maskinen nåede den altid med; på en
belastet runner gjorde den ikke, og listen manglede noten. Fem sekunders
tålmodighed hjalp ikke, for det var ikke ventetid, der manglede — det var et
signal.

Der ventes nu på "Gemt". Kørt tre gange i træk grønt.

En prøve, der er hurtigere end programmet, måler den maskine, den kører på.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 9788bbaaf8 docs: siden bliver udgivet, og den siger det, der er bygget
Hjemmesiden har vist en Verdande fra før v0.43. /links/, /beacon/ og
/notifications/ svarede alle 404, måneder efter de blev skrevet.

Årsagen: docs-jobbet byggede på hvert push og udgav kun på en manuel udløsning,
med en note om at det ville køre, "once Pages is enabled". Pages BLEV slået til
— og betingelsen blev aldrig rettet. Siden er dermed bygget fyrre gange og
udgivet nul, uden at noget fejlede, for at bygge var præcis det, jobbet blev
bedt om.

Lektien er ikke den linje. Den er, at "udgiv senere" har brug for noget, der
fejler, når senere kommer. En kontakt, der er slukket og tavs, ligner en kontakt,
der er tændt.

Udgives nu fra main og ved håndsoprækning, aldrig fra en pull request — en fork
kunne ellers udgive hen over siden ved at åbne en.

Dokumentationen manglede ⌘K-ændringen fra i går: at vælge en note fra paletten nu
efterlader de andre træffere i listen ved siden af den. Den står i "Finding",
hvor de to søgefelter i forvejen er beskrevet hver for sig uden at sige, at de nu
er enige.

README'en manglede tolv ting, der er bygget siden tabellen sidst blev rørt — og
forseglingen af noter, som kun stod i docs. At en kopi af databasefilen ikke er
en kopi af noterne, er en overskrift for et selvhostet program, ikke en detalje
på side tre.

GitHub-repoet havde ingen hjemmeside og ingen emner, og beskrivelsen nævnte
hverken noter, kalender eller AI. Alle tre er sat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:00:22 +02:00
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
kristianwindandClaude Opus 5 ff0447317b feat(noter): et indsat billede kan ses helt og kopieres videre
I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
ikke ser ud som et. Der var ingen vej fra det til billedet selv.

Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link,
der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i
teksten — ellers står fokus på en knap, der ikke findes længere, og den næste
tast går ingen steder.

Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan
klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret
af klikket, og det skal det: det er sådan, det slettes.

To ting, der ikke er til at gætte:

Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er
en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred —
ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan
indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt
forbi netop den kode.

Og løftet gives videre til ClipboardItem frem for at blive ventet af først.
Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning
imellem gør klikket til fortid.

Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen
stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det
skal lægge sig over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 21:54:48 +02:00
Kristian Wind a2f6e9bda3 Merge pull request #79 from kristianwind/soegningen-foelger-med
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 10m33s
CI / Docker image builds (push) Successful in 2m1s
CI / Go (push) Failing after 14m48s
fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
2026-09-14 11:19:56 +02:00
kristianwindandClaude Opus 5 1bbd36c79b fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
lukket.

Feltet i selve notelisten har filtreret hele tiden, og det er prøvet to steder.
Men den søgning, man finder, er den store i toppen, og den er palettens. Så det
er ikke "søgning i noter virker ikke" — det er, at den søgning, man bruger, ikke
efterlod noget.

Adressen bærer nu søgningen. ⌘K sender `?note=…&q=…`, notesiden læser begge, og
listen ved siden af den åbne note er de andre træffere. Den skrives tilbage, når
feltet ændres, så adressen kan gemmes og deles — og frem for alt ikke lyver, når
feltet er tømt.

To effekter, der deler en markør, og hvorfor de læser den med `untrack`:

Den nederste sætter `settled` og kalder `replaceState`. Det vækker den øverste,
og på det tidspunkt har `$page.url` endnu ikke skiftet — så den læste den gamle
adresse, fandt en søgning, der ikke var enig med markøren, og skrev den tilbage
i feltet. Man tømte søgefeltet, og ordet kom igen. Hver effekt skal reagere på
sin egen kilde, adressen og feltet, og aldrig på den fælles markør.

Den fejl fandt jeg kun, fordi prøven fejlede på den sidste linje og
fejlbilledets tilgængelighedstræ stadig viste "Fyrreskov" i feltet. En prøve,
der kun havde tjekket adressen, havde været grøn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:10:09 +02:00
Kristian Wind 54359f8d68 Merge pull request #78 from kristianwind/tagbeskeden-naar-frem
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m46s
CI / Docker image builds (push) Successful in 1m21s
CI / Go (push) Failing after 13m54s
fix(release): tagbeskeden når frem til udgivelsen
2026-09-14 08:31:10 +02:00
kristianwindandClaude Opus 5 f400705bb7 fix(release): tagbeskeden når frem til udgivelsen
Den skulle have gjort det siden #72, og gjorde det ikke. v0.45.0 og v0.46.0 fik
begge flettecommittens overskrift som udgivelsestekst — uden at noget fejlede,
og uden at nogen kunne se det andet end på siden bagefter.

`fetch-depth: 0` er ikke nok. actions/checkout henter først alle tags korrekt og
overskriver derefter netop den ene, der udløste kørslen:

    git rev-parse refs/tags/v0.46.0
    git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0

Den henter commit-SHA'en ind i tag-refen. Den annoterede tag bliver dermed til
en letvægtstag, der peger direkte på commit'en, og `%(contents)` giver så
commitbeskeden. Kommentaren i filen sagde, at en overfladisk checkout var
problemet; det var rigtigt og utilstrækkeligt.

Tagget hentes derfor som *ref* igen, lige før det læses.

Og vagten: hver tagbesked begynder med sin egen version, så holder det ikke,
læser vi noget andet end tagget, og kørslen fejler frem for at udgive en forkert
tekst. Det er det, der manglede — fejlen var ikke svær at rette, den var svær at
opdage.

Målt i et klonet arbejdstræ i alle tre tilstande: annoteret giver den rigtige
tekst med selvlinket fjernet, letvægt giver flettecommitten og udløser vagten,
og en re-fetch af refen bringer den annoterede tilbage.

Teksten på de to udgivelser, der allerede stod forkert, er rettet i hånden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:21:27 +02:00
Kristian Wind 0dc4c2f9b8 Merge pull request #77 from kristianwind/besked-og-en-udgang
CI / OpenAPI is valid (push) Successful in 8s
CI / End-to-end (push) Successful in 8m56s
CI / Docker image builds (push) Successful in 1m39s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
feat(projekter): en deling siger til, og der er en vej ud af den
2026-09-14 08:17:53 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:07:46 +02:00
Kristian Wind 0f4048d8f2 Merge pull request #76 from kristianwind/del-projekt-med-en-der-er-her
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m53s
CI / Docker image builds (push) Successful in 1m39s
CI / Go (push) Failing after 14m7s
feat(projekter): del et projekt med en, der allerede er her
2026-09-13 18:00:04 +02:00
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:52:03 +02:00
Kristian Wind a39f655565 Merge pull request #75 from kristianwind/soeg-i-indstillinger
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m38s
CI / Docker image builds (push) Successful in 1m38s
CI / Go (push) Failing after 14m6s
Release / Build and publish image (push) Failing after 4s
feat(indstillinger): en indstilling kan findes ved at søge efter den
2026-09-12 10:04:17 +02:00
24 changed files with 1392 additions and 61 deletions
+18 -10
View File
@@ -1,13 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# And on pull requests, where the build is the only check there is: `mkdocs build
# --strict` fails on a link to a page that does not exist, and building only after
# the merge means finding that out on `main`. Nothing is published from a pull
# request — the `deploy` job below runs on a manual dispatch and nothing else.
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -54,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+34 -3
View File
@@ -72,13 +72,44 @@ jobs:
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
+17 -1
View File
@@ -20,7 +20,8 @@ Which is what a to-do list is.
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
+4 -3
View File
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
+5 -2
View File
@@ -40,8 +40,11 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
it is due.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
+18
View File
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
it is a document that can run script, and a note is not a place to run somebody
else's.
**Click one to see it whole.** In the note it is scaled down to the width of the
column — a photo off a phone is four thousand pixels across — so what you are
looking at is a thumbnail that does not look like one. The full-size view has a
button that copies the picture to the clipboard, and a link that opens it in its
own tab. Escape closes it and puts the cursor back where it was.
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
little else, and a photo off a phone is a JPEG — without the conversion the copy
either fails or succeeds with something nothing will paste.
### Code blocks
A fenced block is coloured for the language it names, and guesses when it does not.
@@ -128,6 +138,14 @@ Search finds a word wherever it is:
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
+1
View File
@@ -11,6 +11,7 @@ the app's icon.
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
+76 -5
View File
@@ -2307,7 +2307,15 @@ paths:
- { name: projectID, in: path, required: true, schema: { type: string } }
get:
tags: [projects]
summary: Who the project is shared with.
summary: Who the project is shared with, who can be added, and who has been invited.
description: >
Three lists in one answer, so the share panel opens complete in a single
round-trip. `candidates` is everybody with an account here who is not
already on the project — the instance's accounts are a closed, invited set,
so listing them to each other is the address book the feature needs, not a
disclosure. `pending` is the invitations sent to people who have no account
yet: without them an invitation sent yesterday is in neither of the other
two lists, and gets sent a second time.
responses:
"200":
description: OK
@@ -2319,6 +2327,17 @@ paths:
members:
type: array
items: { $ref: "#/components/schemas/Member" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
pending:
type: array
items:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [editor, viewer] }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/members/{userID}:
@@ -2360,8 +2379,19 @@ paths:
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
summary: Remove somebody from a project — its owner, or yourself.
description: |
Two things through one route: the owner taking somebody off, and anybody
taking themselves off. The second is what makes a share something you can
undo. You are let into a project without being asked, so a way out that
does not go through asking its owner is the other half of that.
The owner cannot leave their own project. That is a transfer of ownership,
not an exit, and there is no transfer yet.
Pointing at somebody else's membership without owning the project answers
404 rather than 403: saying "you may not" confirms the membership exists.
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
@@ -2384,13 +2414,32 @@ paths:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
user_id:
type: string
description: >
Somebody who already has an account here, picked from the
candidates in GET /members. One of user_id or email.
email:
type: string
format: email
description: >
An address. If it belongs to an account they are added at
once; otherwise an invitation is sent.
role: { type: string, enum: [editor, viewer] }
responses:
"200":
description: Added straight away — the person already had an account.
content:
application/json:
schema:
type: object
properties:
added: { type: boolean }
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [editor, viewer] }
"201":
description: Created
description: Invited. No account yet, so a link was made.
content:
application/json:
schema:
@@ -2399,8 +2448,30 @@ paths:
link: { type: string }
emailed: { type: boolean }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/invites/{inviteID}:
delete:
tags: [projects]
summary: Withdraw an invitation to a project before it is used. Its owner only.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: projectID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/projects/{projectID}/activity:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
+32
View File
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
})
}
// notifyProjectShared tells somebody that a project is now theirs to work in.
//
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
// opgaver til dig i det.
//
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
// handling — de skal ikke have besked om det, de lige selv gjorde.
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
if project == nil || userID == "" {
return
}
actor := userFrom(r.Context())
if actor.ID == userID {
return
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
+152 -14
View File
@@ -452,22 +452,100 @@ type memberJSON struct {
Role string `json:"role"`
}
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
// dukker op.
type projectInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleListMembers is everything the share panel needs, in one answer: who is on
// the project, who can be added, and who has been invited and not arrived.
//
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
//
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
already := map[string]bool{}
for _, m := range members {
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
already[m.UserID] = true
}
writeJSON(w, http.StatusOK, map[string]any{"members": out})
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, r, "get project", err)
return
}
already[project.OwnerID] = true
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
candidates := make([]personJSON, 0, len(people))
for _, p := range people {
if already[p.ID] {
continue
}
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list project invites", err)
return
}
pending := make([]projectInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"candidates": candidates,
"pending": pending,
})
}
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
//
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
// udløber, med et link, der virker hele tiden.
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
if err != nil {
s.storeError(w, r, "delete project invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type inviteRequest struct {
Email string `json:"email"`
Role string `json:"role"`
// UserID er den, man har peget på i listen. Email er den, man kender adressen
// på — og den er også den, der virker, når der ingen konto er endnu.
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -478,8 +556,14 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// handleInvite shares a project with somebody. An existing user is added directly;
// anybody else gets a link that creates their account and their membership at once.
// handleInvite shares a project with somebody.
//
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
// kontoen og medlemskabet på én gang.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// Somebody who already has an account is added straight away: sending them
// through a signup form they cannot complete would be a dead end.
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
if existing.ID == project.OwnerID {
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
if req.UserID == "" {
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
req.UserID = existing.ID
}
}
if req.UserID != "" {
if req.UserID == project.OwnerID {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
// tom plads, ingen kan fjerne.
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
// de kiggede — uden at noget sagde hvornår eller fra hvem.
s.notifyProjectShared(r, project, person.ID)
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
// Personen sendes med retur, fordi fladen kan have delt med en adresse
// og derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
return
}
// Et andet link til den samme indbakke hjælper ingen: det første virker
// stadig, og nu er der to at holde styr på og to at trække tilbage.
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
return
}
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
// anybody removing themselves.
//
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
//
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
// overdragelse, og den findes ikke endnu.
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
// persons medlemskab, bekræfter at medlemskabet findes.
if me.ID != owner && userID != me.ID {
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner cannot be removed from their own project")
+236
View File
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
}
}
}
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
// beskytte ved at lade være med at vise dem.
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
// Først står de i listen over dem, der kan tilføjes.
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if !hasPerson(before["candidates"], anden.ID) {
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
}
// Delt ved at pege, ikke ved at stave.
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": anden.ID, "role": "editor"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, added)
}
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
t.Errorf("user = %v, want Anden", added["user"])
}
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if hasPerson(after["candidates"], anden.ID) {
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
}
found := false
for _, raw := range after["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == anden.ID {
found = true
if m["role"] != "editor" {
t.Errorf("role = %v, want editor", m["role"])
}
}
}
if !found {
t.Errorf("Anden blev ikke medlem: %v", after["members"])
}
}
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if hasPerson(body["candidates"], me.ID) {
t.Errorf("ejeren står som kandidat til sit eget projekt")
}
}
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
//
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
// godt som det første, og så er der to at trække tilbage i stedet for et.
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invitation: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
pending, _ := body["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
}
first := pending[0].(map[string]any)
if first["email"] != "fremmed@example.dk" {
t.Errorf("email = %v", first["email"])
}
// Et andet link til den samme indbakke hjælper ingen.
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE",
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if p, _ := after["pending"].([]any); len(p) != 0 {
t.Errorf("invitationen står der stadig: %v", after["pending"])
}
}
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
// hasPerson siger, om en liste af personer indeholder et bestemt id.
func hasPerson(raw any, id string) bool {
list, _ := raw.([]any)
for _, item := range list {
if p, ok := item.(map[string]any); ok && p["id"] == id {
return true
}
}
return false
}
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anden := ts.newUser(t, "anden@example.dk", "Anden")
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": andenUser.ID, "role": "editor"})
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
var shared map[string]any
for _, raw := range inbox["notifications"].([]any) {
if n := raw.(map[string]any); n["kind"] == "project.shared" {
shared = n
}
}
if shared == nil {
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
}
if shared["project_id"] != projectID {
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
shared["project_id"], projectID)
}
// Udgangen.
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
}
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
}
}
// En udgang for en selv er ikke en dør til de andres medlemskaber.
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
en := ts.newUser(t, "en@example.dk", "En")
ts.newUser(t, "to@example.dk", "To")
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
if err != nil {
t.Fatal(err)
}
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
for _, u := range []string{enUser.ID, toUser.ID} {
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": u, "role": "editor"})
}
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if len(members["members"].([]any)) != 3 {
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
}
}
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
resp, _ := ts.do(t, "DELETE",
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("status %d, want 409", resp.StatusCode)
}
}
+4 -1
View File
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
+83
View File
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
return nil
}
// --- invitationer til ét projekt --------------------------------------------------
// ProjectInvite is somebody invited to a project who has not arrived yet.
//
// The same shape as NoteInvite and deliberately not the same type: the two rows
// differ in which column carries the target, and one struct covering both would
// need a field that is empty half the time — which is how a caller ends up
// reading the wrong one.
type ProjectInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListProjectInvites is who has been invited to a project and not yet taken it up.
//
// The panel needs them beside the members for the same reason the note panel does:
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
// second link to the same inbox, and no way to take either back. That mattered
// less while inviting meant typing an address; it matters more now that the panel
// offers a list to pick from, because the people who are *missing* from that list
// are exactly the ones already invited.
//
// Expired ones are left out, the same rule the administrator's list follows.
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, projectID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProjectInvite{}
for rows.Next() {
var i ProjectInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
// withdraw anything else.
//
// The project id is in the WHERE rather than checked beforehand, for the same
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
// established for *this* project, and a statement that can only touch this
// project's rows cannot be talked into deleting somebody else's invite by id.
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
inviteID, projectID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingProjectInvite reports whether this address already has a live invitation
// to this project, so a second attempt can say so instead of sending another link.
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
AND accepted_at IS NULL AND expires_at > ?`,
projectID, email, time.Now().Unix()).Scan(&n)
return n > 0, err
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
+58 -11
View File
@@ -1326,6 +1326,64 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.47</span>
Click a picture in a note and it opens at full size, with a button that
copies it to the clipboard. In the note it is scaled to the width of the
column — a photo off a phone is four thousand pixels across — so what you
were looking at was a thumbnail that did not look like one.
</li>
<li>
<span class="ver">0.47</span>
Search in ⌘K, pick a note, and the rest of the matches stay in the list
beside it. They used to exist only in the popup you had just closed.
</li>
<li>
<span class="ver">0.46</span>
Share a project with somebody who already has an account by picking their
name, rather than by spelling an email address you would have to know.
Invitations that have not been taken up stay visible so they do not get
sent twice, and can be withdrawn.
</li>
<li>
<span class="ver">0.46</span>
You are told when a project is shared with you, and there is a
<strong>Leave the project</strong> button. Being let in without being
asked, without being told, and with no way out was three things at once.
</li>
<li>
<span class="ver">0.45</span>
Search for a setting instead of remembering which tab it was under — in
the settings themselves and in ⌘K. It knows them by what they do, so
<em>beacon</em> finds the install count even though that is not its name.
</li>
<li>
<span class="ver">0.45</span>
Today's plan is a card on <strong>I dag</strong> rather than only a
message in the morning that was gone if you did not read it at once. And
a link can be told which browser to open in.
</li>
<li>
<span class="ver">0.44</span>
An optional AI layer, on your own key: it tidies the inbox, pulls the
tasks out of a meeting note, writes a morning plan, turns
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
for, and answers questions about your own notes with links to what the
answer rests on. Every one of them is a line you accept, edit or refuse —
it suggests, it never writes.
</li>
<li>
<span class="ver">0.44</span>
Administrators are told in the bell when a new version is out, once per
version, and the message leads to the button that restarts.
</li>
<li>
<span class="ver">0.43</span>
Anything that can call a URL can put a task in your inbox — a shortcut on
your phone, a script, a service. Notes can be handed to somebody who has
no account yet, and writing <code>@Sofie</code> in a note shares it with
her and tells her. Unread counts show on the app's icon in the Dock.
</li>
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
@@ -1362,17 +1420,6 @@
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
</ul>
</section>
+241 -1
View File
@@ -1104,7 +1104,9 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
await page.getByLabel('Rolle').selectOption('viewer');
// `exact`, fordi siden nu også har "Rolle for den valgte" ved siden af
// vælgeren — og getByLabel matcher som understreng.
await page.getByLabel('Rolle', { exact: true }).selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
@@ -4515,3 +4517,241 @@ test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Et projekt kan deles med en, der allerede er her.
*
* Indtil nu kunne det kun deles ved at stave en e-mailadresse — altså ved at kende
* adressen på en kollega, man kender navnet på og sidder i samme rum som. Konti på
* en instans bliver kun til ved invitation, så der er ikke noget at beskytte ved at
* lade være med at vise dem for hinanden; det er adressebogen, funktionen har brug
* for.
*
* Prøven laver selv sin anden person frem for at låne den fra prøven ovenfor. Den
* kobling så jeg først, da jeg kørte den her alene: basen tømmes for hver kørsel,
* så en prøve, der kun består, når hele filen kører i rækkefølge, består ikke af
* sine egne grunde.
*
* Og turen forbi invitationen er ikke spild. Den viser skiftet: den samme person er
* først en ventende adresse, og bagefter et navn, der kan vælges.
*/
test('et projekt kan deles med en, der allerede har en konto', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Vinterhaven');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Vinterhaven' })).toBeVisible();
// Først den, der ikke er her endnu. Adressen er den eneste vej til dem.
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('gartner@example.dk');
await page.getByRole('button', { name: 'Inviter' }).click();
// Og de bliver stående synligt, indtil de kommer — ellers er en invitation
// sendt i går usynlig, og så bliver den sendt igen.
const invited = page.locator('.members.invited');
await expect(invited.getByText('gartner@example.dk')).toBeVisible();
await expect(invited).toContainText('Inviteret, ikke kommet endnu');
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Tom kontekst: en inviteret, der allerede er logget ind som den, der inviterede,
// er det ene tilfælde, der aldrig sker.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const guest = await context.newPage();
await guest.goto(link);
await guest.getByLabel('Navn', { exact: true }).fill('Gartner');
await guest.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await guest.getByRole('button', { name: 'Opret konto' }).click();
await expect(guest.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await context.close();
// Nu er de her. Et nyt projekt, og den samme person er blevet et navn, man vælger.
await page.goto('/');
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drivhuset');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
await projectAction(page, 'Del');
const picker = page.locator('.addshare select').first();
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(1);
await picker.selectOption({ label: 'Gartner' });
await page.getByLabel('Rolle for den valgte').selectOption('viewer');
await page.locator('.addshare button', { hasText: 'Del' }).click();
const members = page.locator('.members').first();
await expect(members.locator('li', { hasText: 'Gartner' })).toContainText('Kan kun se');
// Og bagefter er de væk fra listen: en liste, der tilbyder at tilføje en, der
// allerede er der, er en liste, man gør det i ved et uheld.
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(0);
// Tilbagetrækningen af en invitation prøves i Go
// (TestAPendingInviteIsVisibleAndCanBeWithdrawn) og ikke her. Den krævede et
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
// kunne gå ud igen uden at bede ejeren om lov.
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gartner = await theirs.newPage();
const gartnerTrouble = watchForTrouble(gartner);
await gartner.goto('/');
await gartner.getByLabel('E-mail').fill('gartner@example.dk');
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
await gartner.getByRole('button', { name: 'Log ind' }).click();
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await gartner.getByRole('button', { name: /Beskeder/ }).click();
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
// oplysning om noget, man selv skal gå ud og finde.
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
gartner.on('dialog', (d) => d.accept());
await projectAction(gartner, /^Del/);
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
// Væk fra sidebjælken, og ikke længere til at åbne.
await expect(
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
).toHaveCount(0);
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
// forladt, og den er hele pointen med at have forladt det.
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
await theirs.close();
expect(trouble).toEqual([]);
});
/**
* En søgning i ⌘K efterlader resultaterne, den fandt.
*
* Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
* åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
* væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
* lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med
* resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste
* — ikke resultater."
*
* Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord
* lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den
* kobling har allerede narret mig én gang i den her fil.
*/
test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
// Gem-knap at huske.
//
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill(`${title}\n\nnoget om den`);
await body.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
}
await page.goto('/');
await page.getByRole('button', { name: /Søg/ }).click();
const palette = page.getByRole('dialog');
await palette.getByRole('textbox').fill('Fyrreskov');
await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible();
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
// Noten er åben …
await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/);
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// … og listen ved siden af den er de andre træffere, ikke den normale liste.
await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov');
const list = page.locator(".list");
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
// en søgning tilbage, man selv havde ryddet væk.
await page.getByPlaceholder('Søg i noter').fill('');
await expect(page).not.toHaveURL(/[?&]q=/);
await expect(list.getByText('Egeskov')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
*
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
* hele vedhæftningen over og dermed netop den kode, det her handler om.
*/
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
const trouble = watchForTrouble(page);
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
// ville måle en browserindstilling frem for programmet.
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Billednote');
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
await body.evaluate((el) => {
const jpeg =
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
const data = new DataTransfer();
data.items.add(file);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
});
const image = page.locator('[aria-label="Notens tekst"] img');
await expect(image).toBeVisible();
// Klikket åbner det, og ruden viser det samme billede.
await image.click();
const shot = page.getByRole('dialog', { name: 'Billede' });
await expect(shot).toBeVisible();
await expect(shot.locator('img')).toBeVisible();
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
// man indsætter noget andet og opdager det et helt andet sted.
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
// ikke findes mere, og den næste tast går ingen steder.
await page.keyboard.press('Escape');
await expect(shot).toHaveCount(0);
await expect(body).toBeFocused();
expect(trouble).toEqual([]);
});
+5 -1
View File
@@ -338,7 +338,11 @@ export const api = {
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
// hører til en konto her.
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
+7 -1
View File
@@ -125,7 +125,13 @@
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
// op, den ER stedet.
if (item.kind === 'setting') return goto(item.id);
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
// liste står uændret — og de andre træffere var kun at se i den popup, man
// lige lukkede.
if (item.kind === 'note') {
const q = query.trim();
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
}
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
+155
View File
@@ -640,9 +640,69 @@
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
return;
}
// Et billede i en note er et billede, man har indsat for at kunne se det. I
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
// der ikke ser ud som et.
const image = event.target.closest?.('img');
if (image) {
event.preventDefault();
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
copied = false;
copyError = '';
return;
}
colourCode();
}
// --- billedet i fuld størrelse ------------------------------------------------
let showing = $state(null);
let copied = $state(false);
let copyError = $state('');
function closeImage() {
showing = null;
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
// findes længere, og den næste tast går ingen steder.
editor?.focus();
}
/**
* Billedet som PNG, uanset hvad det kom som.
*
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
*/
async function asPNG(src) {
const blob = await (await fetch(src)).blob();
if (blob.type === 'image/png') return blob;
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
async function copyImage() {
copyError = '';
try {
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (e) {
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
copyError = t('notes.copyFailed');
}
}
/** Replaces the half-typed tag with the whole thing. */
function accept(item) {
const partial = partialTag();
@@ -1068,7 +1128,102 @@
</p>
</div>
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
af netop det ark, det skal lægge sig over. -->
{#if showing}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="shot" onclick={(e) => e.stopPropagation()}>
<img src={showing.src} alt={showing.alt} />
<div class="tools">
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
</div>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
{/if}
<svelte:window
onkeydown={(e) => {
if (showing && e.key === 'Escape') {
e.stopPropagation();
closeImage();
}
}}
/>
<style>
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
står alene — det er hele grunden til at åbne det. */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
}
.shot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
max-width: 100%;
max-height: 100%;
}
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
passe sig ind i den. */
.shot img {
min-height: 0;
max-width: 100%;
max-height: calc(100vh - 8rem);
object-fit: contain;
border-radius: var(--radius);
}
.tools {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
.tools button,
.tools a {
padding: var(--s2) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
text-decoration: none;
}
.tools button:hover,
.tools a:hover {
border-color: var(--accent);
}
.bad {
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
}
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
.page :global(img) {
cursor: zoom-in;
}
.wrap {
display: flex;
flex-direction: column;
@@ -24,6 +24,7 @@
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'project.shared': 'notif.projectShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
+13
View File
@@ -31,6 +31,7 @@ export const da = {
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.projectShared': '{name} delte et projekt med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.update': 'Der er kommet en ny version',
@@ -172,6 +173,10 @@ export const da = {
'notes.copyCode': 'Kopiér',
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.imageTitle': 'Billede',
'notes.imageCopy': 'Kopiér billedet',
'notes.imageCopied': 'Kopieret',
'notes.imageOpen': 'Åbn i nyt faneblad',
'notes.strike': 'Gennemstreget',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
@@ -908,6 +913,14 @@ export const da = {
'project.canEdit': 'Kan redigere',
'project.canView': 'Kan kun se',
'project.invite': 'Inviter',
'project.pickPerson': 'Vælg en person…',
'project.leave': 'Forlad projektet',
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
'project.roleForPicked': 'Rolle for den valgte',
'project.shareWith': 'Del',
'project.orByEmail': 'Eller en, der ikke er her endnu:',
'project.invited': 'Inviteret, ikke kommet endnu',
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
'project.inviteByEmail': 'Inviter via e-mail',
'project.emailAddress': 'e-mailadresse',
'project.role': 'Rolle',
+13
View File
@@ -28,6 +28,7 @@ export const en = {
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.projectShared': '{name} shared a project with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.update': 'A new version is out',
@@ -169,6 +170,10 @@ export const en = {
'notes.copyCode': 'Copy',
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.imageTitle': 'Image',
'notes.imageCopy': 'Copy the image',
'notes.imageCopied': 'Copied',
'notes.imageOpen': 'Open in a new tab',
'notes.strike': 'Strikethrough',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',
@@ -902,6 +907,14 @@ export const en = {
'project.canEdit': 'Can edit',
'project.canView': 'Can only view',
'project.invite': 'Invite',
'project.pickPerson': 'Pick somebody…',
'project.leave': 'Leave the project',
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
'project.roleForPicked': 'Role for the person picked',
'project.shareWith': 'Share',
'project.orByEmail': 'Or somebody who is not here yet:',
'project.invited': 'Invited, not arrived yet',
'project.withdrawInvite': 'Withdraw the invitation to {email}',
'project.inviteByEmail': 'Invite by email',
'project.emailAddress': 'email address',
'project.role': 'Role',
+45 -1
View File
@@ -14,7 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { goto, replaceState } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -483,6 +484,49 @@
load(query);
});
/**
* En søgning, man kommer med, og en søgning, man kan gå videre med.
*
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
* tilbage til den normale liste."
*
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
* deles — og frem for alt ikke lyver, når man har tømt feltet.
*
* `settled` er det, de to retninger er blevet enige om — og den læses med
* `untrack`, hvilket er hele forskellen på at virke og ikke.
*
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
* på den fælles markør.
*/
let settled = $state(null);
$effect(() => {
const inURL = $page.url.searchParams.get('q') ?? '';
if (inURL === untrack(() => settled)) return;
settled = inURL;
draftQuery = inURL;
query = inURL;
});
$effect(() => {
const now = query;
if (now === untrack(() => settled)) return;
settled = now;
const url = new URL(untrack(() => $page.url));
if (now) url.searchParams.set('q', now);
else url.searchParams.delete('q');
replaceState(url, {});
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
+150 -7
View File
@@ -41,6 +41,13 @@
let inviteRole = $state('editor');
let inviteLink = $state('');
let inviteError = $state('');
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
// medlemmerne, så panelet åbner færdigt i ét kald.
let candidates = $state([]);
let pending = $state([]);
let sharePick = $state('');
let pickRole = $state('editor');
let id = $derived($page.params.id);
@@ -101,7 +108,7 @@
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
api.listMembers(project.id).then(readMembers);
}
});
@@ -512,23 +519,61 @@
}
}
async function invite(event) {
event.preventDefault();
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
function readMembers(r) {
members = r.members;
candidates = r.candidates ?? [];
pending = r.pending ?? [];
}
async function share(who, role) {
inviteError = '';
inviteLink = '';
try {
const result = await api.invite(project.id, inviteEmail, inviteRole);
inviteEmail = '';
const result = await api.invite(project.id, who, role);
if (result.link && !result.emailed) {
// No mail server, or delivery failed. The link is the only way this
// invite reaches anybody, so it is shown rather than swallowed.
inviteLink = result.link;
}
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
} catch (e) {
inviteError = humanMessage(e);
}
}
async function invite(event) {
event.preventDefault();
const email = inviteEmail.trim();
if (!email) return;
await share({ email }, inviteRole);
if (!inviteError) inviteEmail = '';
}
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
// være en vej ud, der ikke går gennem at bede ejeren om det.
async function leave() {
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
try {
await api.removeMember(project.id, app.user.id);
// Væk fra en side, man ikke længere må se: bliver man stående, er det
// næste, siden gør, at hente den og få 404.
await goto('/');
await app.refreshProjects();
} catch (e) {
app.toast(humanMessage(e));
}
}
async function withdraw(invite) {
try {
await api.withdrawInvite(project.id, invite.id);
readMembers(await api.listMembers(project.id));
} catch (e) {
app.toast(humanMessage(e));
}
}
</script>
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
@@ -765,7 +810,7 @@
class="remove"
onclick={async () => {
await api.removeMember(project.id, member.user_id);
members = (await api.listMembers(project.id)).members;
readMembers(await api.listMembers(project.id));
}}
aria-label={t('project.removeMember', { name: member.name })}>×</button
>
@@ -774,7 +819,66 @@
{/each}
</ul>
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
sendt i går usynlig: personen har ingen konto, så de er hverken
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
{#if pending.length}
<ul class="members invited">
{#each pending as invite (invite.id)}
<li>
<span class="avatar waiting">@</span>
<span class="member-name">{invite.email}</span>
<span class="role">{t('project.invited')}</span>
{#if isOwner}
<button
class="remove"
onclick={() => withdraw(invite)}
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
>
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
projekt — det ville være en overdragelse, ikke en udgang. -->
{#if !isOwner}
<button class="leave" onclick={leave}>{t('project.leave')}</button>
{/if}
{#if isOwner}
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
deles ved at stave en e-mailadresse, altså ved at kende adressen på
en kollega, man kender navnet på — og konti her bliver kun til ved
invitation, så listen er en adressebog og ikke en udlevering. -->
{#if candidates.length}
<div class="addshare">
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
<option value="">{t('project.pickPerson')}</option>
{#each candidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
<option value="editor">{t('project.canEdit')}</option>
<option value="viewer">{t('project.canView')}</option>
</select>
<button
disabled={!sharePick}
onclick={async () => {
const id = sharePick;
sharePick = '';
await share({ user_id: id }, pickRole);
}}>{t('project.shareWith')}</button
>
</div>
{/if}
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
adressen en konto, bliver det en deling med det samme, og ellers en
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
<p class="byemail-hint">{t('project.orByEmail')}</p>
<form onsubmit={invite}>
<input
bind:value={inviteEmail}
@@ -1396,6 +1500,45 @@
font-size: var(--text-sm);
}
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
.invited {
border-top: 1px solid var(--line);
padding-top: var(--s2);
}
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
.avatar.waiting {
background: var(--surface-sunken);
border: 1px dashed var(--line);
color: var(--ink-muted);
}
.addshare {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
.leave {
align-self: flex-start;
padding: var(--s2) var(--s3);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: var(--danger);
font-size: var(--text-sm);
}
.byemail-hint {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.avatar {
width: 22px;
height: 22px;