Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 | ||
|
|
9b21b3eac5 | ||
|
|
a39f655565 | ||
|
|
7e981354c2 | ||
|
|
1f0a43ac90 | ||
|
|
61f8887319 | ||
|
|
72637a153a | ||
|
|
f06a25b43e | ||
|
|
6cf184caa6 | ||
|
|
3c14906db2 | ||
|
|
70d7398d5a | ||
|
|
1cabbb7a78 | ||
|
|
5eb689e4c2 | ||
|
|
e931ddc313 |
@@ -1,8 +1,21 @@
|
|||||||
name: Documentation
|
name: Documentation
|
||||||
|
|
||||||
# The docs are built on every push so a broken build is caught immediately, but
|
# Built on every push, and published from main.
|
||||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
#
|
||||||
# owner decides to publish, this job builds and stops.
|
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||||
|
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||||
|
# changed, so the site was built forty times and published none of them. What it
|
||||||
|
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
|
||||||
|
# all answered 404, months after they were written. Nothing failed, because
|
||||||
|
# building *is* what the job was asked to do.
|
||||||
|
#
|
||||||
|
# The lesson is not about this line. It is that "publish later" needs something
|
||||||
|
# that fails when later arrives — a switch that is off and silent looks exactly
|
||||||
|
# like a switch that is on.
|
||||||
|
#
|
||||||
|
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
|
||||||
|
# link to a page that does not exist, and building only after the merge means
|
||||||
|
# finding that out on `main`.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -10,6 +23,11 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'mkdocs.yml'
|
- 'mkdocs.yml'
|
||||||
- '.github/workflows/docs.yml'
|
- '.github/workflows/docs.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'mkdocs.yml'
|
||||||
|
- '.github/workflows/docs.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -44,9 +62,9 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Only runs once Pages is enabled. Until then the build above still guards
|
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||||
# against a broken docs tree.
|
# publish over the site by opening one.
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name != 'pull_request'
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deploy.outputs.page_url }}
|
url: ${{ steps.deploy.outputs.page_url }}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
|
||||||
|
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
|
||||||
|
# commit'en uden den.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@v3
|
- uses: docker/setup-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
@@ -60,7 +65,59 @@ jobs:
|
|||||||
#
|
#
|
||||||
# Created after the image, so a release never announces a version that
|
# Created after the image, so a release never announces a version that
|
||||||
# failed to build.
|
# failed to build.
|
||||||
|
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
|
||||||
|
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
|
||||||
|
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
|
||||||
|
# commit-overskrifter, som er skrevet til dem, der læser koden.
|
||||||
|
#
|
||||||
|
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||||
|
# side, der linker til sig selv, hjælper ingen.
|
||||||
|
#
|
||||||
|
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||||
|
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||||
|
# netop den ene, der udløste kørslen:
|
||||||
|
#
|
||||||
|
# git rev-parse refs/tags/v0.46.0
|
||||||
|
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||||
|
#
|
||||||
|
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||||
|
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||||
|
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||||
|
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||||
|
#
|
||||||
|
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||||
|
- id: notes
|
||||||
|
run: |
|
||||||
|
git fetch --force origin \
|
||||||
|
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
|
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||||
|
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||||
|
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||||
|
|
||||||
|
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||||
|
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||||
|
# frem for at blive opdaget på siden bagefter.
|
||||||
|
case "$body" in
|
||||||
|
"${GITHUB_REF_NAME}"*) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||||
|
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo 'body<<VERDANDE_EOF'
|
||||||
|
printf '%s\n' "$body"
|
||||||
|
echo VERDANDE_EOF
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2
|
- uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
body: ${{ steps.notes.outputs.body }}
|
||||||
|
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
|
||||||
|
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
|
||||||
|
# ændringer det var, kan læse det samme sted.
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
|||||||
@@ -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
|
All six phases are built. See [What works today](#what-works-today) for what is
|
||||||
tested and what is not.
|
tested and what is not.
|
||||||
|
|
||||||
Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
|
||||||
|
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
@@ -37,17 +38,36 @@ and an MCP endpoint so Claude can work your task list directly.
|
|||||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||||
views, and foldable groups over the projects in the sidebar.
|
views, and foldable groups over the projects in the sidebar.
|
||||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
lists, quotes, fenced code. A note is shared by filing it in a project, by
|
||||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
handing it to one person — including somebody who has no account yet, who gets an
|
||||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
invitation that becomes the share when they sign up — or by writing `@Sofie` in
|
||||||
|
the text, which shares it with her and tells her. `#project` and
|
||||||
|
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||||
|
- **Notes are sealed at rest.** Title and body are encrypted in the database under
|
||||||
|
a key in a file beside it — the title too, because a title beside a sealed body
|
||||||
|
leaves the beginning of every note in the open. A copy of the file is not a copy
|
||||||
|
of the notes.
|
||||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||||
a group, drop a task on another project, or drop it on another day.
|
a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||||
everybody arrives through an invite link, either to a project or — from
|
everybody arrives through an invite link — to a project, to a single note, or
|
||||||
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
from **Settings → Brugere** to the instance itself. Nobody's password is ever
|
||||||
by somebody else.
|
chosen by somebody else.
|
||||||
- **Live sync.** A change made by somebody else appears without a refresh.
|
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||||
|
- **Notifications that are worth reading.** A bell for what other people did to what
|
||||||
|
you share — folded, so one person typing for five minutes is one line — pushed to
|
||||||
|
your devices and counted on the app's icon in the Dock.
|
||||||
|
- **AI that suggests and never writes.** Optional, off by default, your own key and
|
||||||
|
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
|
||||||
|
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
|
||||||
|
action the mail asks for, and answers questions about your own notes with links to
|
||||||
|
what the answer rests on. Every one of them arrives as a line you accept, edit or
|
||||||
|
refuse — a model that edits your tasks directly has to be right every time to be
|
||||||
|
worth having.
|
||||||
|
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
|
||||||
|
or a POST of one line of text from a shortcut on your phone, a script or any
|
||||||
|
service that can call a URL.
|
||||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||||
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
||||||
@@ -113,6 +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 |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
|
||||||
|
| `@name` in a note shares it with that person, and tells them | Done, tested |
|
||||||
|
| Share a project with somebody who already has an account, by name | Done, tested |
|
||||||
|
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
|
||||||
|
| Told when a project is shared with you — and able to leave it | Done, tested |
|
||||||
|
| Push from other programs: a webhook address per person | Done, tested |
|
||||||
|
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
|
||||||
|
| Search the settings, from the page or from ⌘K | Done, tested |
|
||||||
|
| Choose which browser a link opens in | Done, tested |
|
||||||
|
| Images in notes: open full size, copy to the clipboard | Done, tested |
|
||||||
|
| Install count: two values, once a day, one click to stop | Done, tested |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||||
implemented against their specifications and have only ever been exercised there,
|
implemented against their specifications and have only ever been exercised there,
|
||||||
|
|||||||
+78
-9
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
|
|||||||
a page that repopulates a password field is one that will eventually leak it into a
|
a page that repopulates a password field is one that will eventually leak it into a
|
||||||
screenshot.
|
screenshot.
|
||||||
|
|
||||||
|
## The shape of all of it
|
||||||
|
|
||||||
|
**The model proposes; you decide.** Nothing is written until you say yes, and the
|
||||||
|
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
|
||||||
|
p1 #Firma` — sitting in a field you can edit before you accept it.
|
||||||
|
|
||||||
|
That is not caution for its own sake. A model that edits your tasks directly has to
|
||||||
|
be right *every* time to be worth having. One that suggests only has to be right
|
||||||
|
often enough to save you some typing, and when it is wrong you have already seen it.
|
||||||
|
|
||||||
|
It is also why the suggestion is a line rather than a set of fields: there is one
|
||||||
|
reading of "i morgen" in this program, and a suggestion goes through the same
|
||||||
|
[quick add](quick-add.md) parser as anything you type — including after you have
|
||||||
|
edited it by hand.
|
||||||
|
|
||||||
|
The model is given the names of the projects that exist and may name no others. A
|
||||||
|
`#Projekt` it invented would create one by accident the first time you accepted it,
|
||||||
|
and a guess must not be able to move a task somewhere you will not look for it.
|
||||||
|
|
||||||
## What it can do
|
## What it can do
|
||||||
|
|
||||||
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
|
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
|
||||||
seven concrete actions and creates them as sub-tasks. Written in your language.
|
line it suggests how you would have written it if you had finished it — project,
|
||||||
|
date, priority — with one sentence on what it read out of the task. Accept, edit, or
|
||||||
|
skip, one line at a time.
|
||||||
|
|
||||||
|
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
|
||||||
|
somebody committed to or was asked to do; a heading is not a task and a topic that
|
||||||
|
was discussed is not a task. Each suggestion quotes the words it came from, so a
|
||||||
|
wrong one can be seen through rather than merely being wrong. The note itself is
|
||||||
|
never edited — the task carries `[[the note's title]]` in its description instead.
|
||||||
|
|
||||||
|
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
|
||||||
|
default; pick the hour yourself. One notification about what is due today and what
|
||||||
|
has been missed, and what to start with — and the same text as a card at the top of
|
||||||
|
**I dag**, where you are looking anyway when you start the day. The notification is
|
||||||
|
the reminder; the card is where it stays.
|
||||||
|
|
||||||
|
The card shows the plan *as it was sent*, with the time it was made, rather than a
|
||||||
|
new one each time you look: a plan that says something different when you look at it
|
||||||
|
than it said when it arrived is worse than either. **Forny planen** makes a new one
|
||||||
|
when the day has moved, and it does not send a notification about something you are
|
||||||
|
already looking at. With the morning notification off, the card offers to make one
|
||||||
|
on the spot.
|
||||||
|
|
||||||
|
The counts are done in the database and handed to the model as facts it may not
|
||||||
|
recompute — a plan that says four tasks where there are three is worse than no plan.
|
||||||
|
With no model configured the counts stand on their own, which is what you needed to
|
||||||
|
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
|
||||||
|
|
||||||
|
**Mail that becomes a task you can act on.** Where a
|
||||||
|
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
||||||
|
12"*, the model writes the action the mail asks for — and leaves the mail alone if
|
||||||
|
it asks for nothing. The subject stays in the description, so a mail it misread can
|
||||||
|
still be read. If the model is unavailable the task is named sender and subject as
|
||||||
|
before: a model that is down must not be able to stop the post.
|
||||||
|
|
||||||
|
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
|
||||||
|
Anders i august?"* — search finds what contains the words, this answers the
|
||||||
|
question, and the answer links to the notes and tasks it rests on. If nothing
|
||||||
|
matches, it says so; a guess would be useless here, because you are asking precisely
|
||||||
|
because you cannot remember.
|
||||||
|
|
||||||
|
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
|
||||||
|
actions, created as sub-tasks.
|
||||||
|
|
||||||
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
||||||
what appears to be slipping. At most six lines — it is a prompt to think, not a
|
what appears to be slipping. At most six lines — a prompt to think, not a report.
|
||||||
report.
|
|
||||||
|
|
||||||
## What is sent
|
## What is sent
|
||||||
|
|
||||||
Only what the feature needs: for a split, the task's title and notes; for a
|
Only what the feature needs, and only when you invoke it:
|
||||||
summary, the titles, dates and priorities of your open tasks. Never comments,
|
|
||||||
attachments, or anything from a project the feature was not invoked on.
|
|
||||||
|
|
||||||
Nothing is sent to anybody until you configure a provider. With a local model,
|
| Feature | What leaves the instance |
|
||||||
nothing leaves your machine at all.
|
| --- | --- |
|
||||||
|
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
|
||||||
|
| Tasks in a note | That one note's text, and your project names |
|
||||||
|
| Today's plan | The titles and dates of what is due or overdue today |
|
||||||
|
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
|
||||||
|
| Ask | Your question, and the notes and tasks the search matched |
|
||||||
|
| Split a task | That task's title and notes |
|
||||||
|
| Weekly summary | The titles, dates and priorities of your open tasks |
|
||||||
|
|
||||||
|
Never comments, never attachments, never a project the feature was not invoked on,
|
||||||
|
and never anything belonging to somebody else. Nothing is sent to anybody until you
|
||||||
|
configure a provider, and with a local model nothing leaves your machine at all.
|
||||||
|
|
||||||
## Turning it off
|
## Turning it off
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
||||||
| `GET` `POST` | `/projects/{id}/sections` | |
|
| `GET` `POST` | `/projects/{id}/sections` | |
|
||||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||||
| `GET` | `/projects/{id}/members` | |
|
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
|
||||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
|
||||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
|
||||||
|
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
|
||||||
| `GET` | `/projects/{id}/activity` | |
|
| `GET` | `/projects/{id}/activity` | |
|
||||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# The install count
|
||||||
|
|
||||||
|
verdande reports that it exists. Two values, once a day:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
|
||||||
|
"version": "v0.44.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole message. No IP is stored by the collector, no hostname, no
|
||||||
|
domain, no account, no counts of anything you have made, nothing about what you
|
||||||
|
use it for. The payload is small enough to be printed in full on the settings page,
|
||||||
|
and it is printed there — a promise about telemetry is worth exactly as much as
|
||||||
|
your ability to check it.
|
||||||
|
|
||||||
|
## On by default, off in one click
|
||||||
|
|
||||||
|
The alternative that was considered first was compulsory, and the argument against
|
||||||
|
it is practical rather than principled: it cannot be enforced. The domain can be
|
||||||
|
blocked, the binary can be patched, and a fork removes it in its first commit. So
|
||||||
|
mandatory buys nothing over on-by-default while costing the trust the rest of this
|
||||||
|
program works for.
|
||||||
|
|
||||||
|
Which is why the first administrator to sign in is **told, unprompted**, with the
|
||||||
|
real id, the real version and the real address in front of them, and two buttons.
|
||||||
|
On by default is only defensible if it is said out loud without being asked; a
|
||||||
|
settings page where it is stated fully is not the same thing, because that page is
|
||||||
|
opened by people who already suspect there is something to read.
|
||||||
|
|
||||||
|
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
|
||||||
|
|
||||||
|
!!! note "This disagrees with the update check, on purpose"
|
||||||
|
|
||||||
|
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
|
||||||
|
a self-hosted app reaching out unbidden has broken the deal its operator made by
|
||||||
|
self-hosting. That is the same situation as this one, and the two defaults do
|
||||||
|
not agree. The case for treating them differently: the update check reveals an
|
||||||
|
instance to a third party (GitHub) and returns something you can act on, while
|
||||||
|
this returns nothing and goes to the project. Whether that is a distinction or
|
||||||
|
an excuse is a judgement call, and it was made deliberately.
|
||||||
|
|
||||||
|
## What the collector cannot see, and what it can
|
||||||
|
|
||||||
|
A request has a source address whether or not anybody writes it down. The collector
|
||||||
|
stores none and logs none, and the code is arranged so the address never reaches a
|
||||||
|
function that could — the table has nowhere to put one. But the request passes
|
||||||
|
through a reverse proxy and a tunnel on the way, and those keep their own logs.
|
||||||
|
"We send no IP" would be a half-truth; what is true is that nothing here records
|
||||||
|
one.
|
||||||
|
|
||||||
|
The ping is not authenticated either, so anybody can post a made-up id and inflate
|
||||||
|
the count. That is an accepted trade: a token would have to ship inside every
|
||||||
|
install anyway.
|
||||||
|
|
||||||
|
## Running your own count
|
||||||
|
|
||||||
|
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
|
||||||
|
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
|
||||||
|
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
|
||||||
|
nobody made a collector must not become a bucket anyone can post into.
|
||||||
|
|
||||||
|
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
|
||||||
|
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
|
||||||
|
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
|
||||||
|
published as a fact — a bad release, a DNS change that quietly stops the pings, or
|
||||||
|
lost rows all look like "fewer people use this now" — and a small number mostly says
|
||||||
|
how little it takes to move it.
|
||||||
|
|
||||||
|
## When it cannot reach
|
||||||
|
|
||||||
|
The reason is written down and shown on the settings page. A beacon that cannot
|
||||||
|
reach its collector otherwise fails in complete silence: it tries again tomorrow,
|
||||||
|
the page says "last sent" with a date that quietly gets older, and nobody notices.
|
||||||
@@ -127,11 +127,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
|
|||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
|
||||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
|
|
||||||
|
With the check on, administrators are told **once per version** — in the bell, in
|
||||||
|
push and on the app's badge — rather than having to open the settings page and
|
||||||
|
look. The notification leads to the page where the restart button is. With the
|
||||||
|
check off nothing is fetched and nothing is said; see
|
||||||
|
[Notifications](notifications.md).
|
||||||
|
|
||||||
All three together let **Settings → Notifications** restart this instance,
|
All three together let **Settings → Notifications** restart this instance,
|
||||||
which is also how it picks up a new version: a container cannot replace its own
|
which is also how it picks up a new version: a container cannot replace its own
|
||||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||||
|
|||||||
+22
-4
@@ -26,20 +26,38 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
The calendar draws each task to its length — drag the foot to resize, the body
|
The calendar draws each task to its length — drag the foot to resize, the body
|
||||||
to move it.
|
to move it.
|
||||||
|
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
|
||||||
|
settings themselves, by what they *do* rather than only by what they are called,
|
||||||
|
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
|
||||||
|
settings pages carry the same field at the top of their own list.
|
||||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||||
single note with a person, or file it in a project; `#project` and
|
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||||
`[[another note]]` are links, and a task shows what has been written about it.
|
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||||
|
and a task shows what has been written about it.
|
||||||
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||||
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||||
greyed, at the foot of the list until a moment you pick — without touching when
|
greyed, at the foot of the list until a moment you pick — without touching when
|
||||||
it is due.
|
it is due.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
under a group, drop a task on another project, or drop it on another day.
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||||
joins through an invite link.
|
account here you pick by name — the accounts on an instance are a closed,
|
||||||
|
invited set, so they are an address book rather than a directory to protect.
|
||||||
|
Anyone outside your instance joins through an invite link, and the invitation
|
||||||
|
stays visible until they arrive, so it does not get sent twice.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
|
- **[Told when it matters](notifications.md).** A bell for what other people did to
|
||||||
|
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||||
|
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
|
||||||
|
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
|
||||||
|
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
|
||||||
|
answers questions about your own notes with links to what the answer rests on —
|
||||||
|
every one of them as a line you accept, edit or refuse.
|
||||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||||
out as Web Push to an iPhone or a Mac.
|
out as Web Push to an iPhone or a Mac.
|
||||||
|
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
|
||||||
|
address, or a POST from a shortcut on your phone, a script, or any service that
|
||||||
|
can call a URL.
|
||||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Opening links
|
||||||
|
|
||||||
|
A link in a task or a note opens in whichever browser your system opens links in.
|
||||||
|
**Settings → Links** can try to change that — and the honest description of what it
|
||||||
|
does is *try*.
|
||||||
|
|
||||||
|
## What a web page cannot do
|
||||||
|
|
||||||
|
Two things, and they set the limits of this feature:
|
||||||
|
|
||||||
|
- **It cannot see which browsers are installed.** The list is deliberately closed:
|
||||||
|
it would be a fingerprint, distinctive enough to follow somebody from site to
|
||||||
|
site.
|
||||||
|
- **It cannot ask the system to use a particular one.** A link goes to the default
|
||||||
|
browser, and there is no API that says otherwise.
|
||||||
|
|
||||||
|
The only handle that exists is the URL schemes browsers register for themselves —
|
||||||
|
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
|
||||||
|
way to ask whether one exists. You can only try it.
|
||||||
|
|
||||||
|
## So the setting is an attempt
|
||||||
|
|
||||||
|
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
|
||||||
|
link, that scheme is tried first; if nothing has happened within about half a
|
||||||
|
second, the link opens the ordinary way.
|
||||||
|
|
||||||
|
The fallback is the whole reason this is safe to turn on. Without it, a setting
|
||||||
|
that works on your phone would leave links doing nothing on your desktop.
|
||||||
|
|
||||||
|
In practice: **it works on a phone**, where browsers register these schemes, and
|
||||||
|
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
|
||||||
|
the usual exception. There is a **Prøv det** button for exactly this reason — you
|
||||||
|
cannot ask whether a scheme exists, so it is better to find out on example.dk than
|
||||||
|
on a link you meant to follow.
|
||||||
|
|
||||||
|
## Writing your own
|
||||||
|
|
||||||
|
Three placeholders, because browsers disagree about the shape they want the address
|
||||||
|
in:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `{url}` | The address as it is — `x-safari-https://example.dk` |
|
||||||
|
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
|
||||||
|
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
|
||||||
|
|
||||||
|
## It belongs to the machine, not the account
|
||||||
|
|
||||||
|
The choice is kept in the browser you are sitting at, not on your account — the
|
||||||
|
same rule the sidebar's width follows. `firefox://` works on the phone and does
|
||||||
|
nothing on the Mac, so a choice that followed the account would be wrong in one of
|
||||||
|
the two places every time.
|
||||||
@@ -67,3 +67,36 @@ immediately.
|
|||||||
|
|
||||||
!!! tip "Keep the address to yourself"
|
!!! tip "Keep the address to yourself"
|
||||||
Treat it like a secret. If you publish it somewhere, rotate it.
|
Treat it like a secret. If you publish it somewhere, rotate it.
|
||||||
|
|
||||||
|
## Pushing from another program
|
||||||
|
|
||||||
|
The address above needs a mail server. When what you want is a shortcut on your
|
||||||
|
phone, a script, or any service that can call a URL, there is a second way in:
|
||||||
|
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
|
||||||
|
POST a line of text to.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://todo.example.dk/inbound/hook/<token> \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "Ring til Anders i morgen p1 #Firma"
|
||||||
|
```
|
||||||
|
|
||||||
|
The body is read three ways, because those are the three you meet: plain text from
|
||||||
|
a shortcut that just sends what was selected, JSON with a `text` field from a
|
||||||
|
service, or a form field from a webhook form. The first line becomes the task and
|
||||||
|
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
|
||||||
|
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
|
||||||
|
top of the app.
|
||||||
|
|
||||||
|
**The address is a key.** It has its own token rather than sharing the mail one:
|
||||||
|
the two are the same kind of secret but they leak separately, and a URL that has
|
||||||
|
been sitting in a shortcut on a lost phone has to be replaceable without changing
|
||||||
|
the address other people have in their address books. A token here can do one
|
||||||
|
thing — put a task in your inbox — which is why it is not an
|
||||||
|
[API token](api.md), which can do everything. Change it under the same setting; the
|
||||||
|
old one stops working at once.
|
||||||
|
|
||||||
|
**POST only.** A GET would be easier to call, and that is exactly the problem: a
|
||||||
|
browser prefetch, a link check in a chat, and a revisited history entry would all
|
||||||
|
create tasks nobody asked for.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
|
|||||||
it is a document that can run script, and a note is not a place to run somebody
|
it is a document that can run script, and a note is not a place to run somebody
|
||||||
else's.
|
else's.
|
||||||
|
|
||||||
|
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||||
|
column — a photo off a phone is four thousand pixels across — so what you are
|
||||||
|
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||||
|
button that copies the picture to the clipboard, and a link that opens it in its
|
||||||
|
own tab. Escape closes it and puts the cursor back where it was.
|
||||||
|
|
||||||
|
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||||
|
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||||
|
either fails or succeeds with something nothing will paste.
|
||||||
|
|
||||||
### Code blocks
|
### Code blocks
|
||||||
|
|
||||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||||
@@ -79,6 +89,37 @@ There is no folder tree. Projects, groups and labels already exist, are already
|
|||||||
shared and already have roles — a second hierarchy to file things in is one too
|
shared and already have roles — a second hierarchy to file things in is one too
|
||||||
many, and a note in a project inherits every one of those answers.
|
many, and a note in a project inherits every one of those answers.
|
||||||
|
|
||||||
|
### Sharing one note with one person
|
||||||
|
|
||||||
|
Filing a note in a project is the right tool when the note belongs with a body of
|
||||||
|
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
|
||||||
|
at the foot of the note does the second: this note, this person, as reader or
|
||||||
|
editor. Only the note's owner can share it.
|
||||||
|
|
||||||
|
Type an **email address** rather than picking a name, and one of two things
|
||||||
|
happens. An address that already has an account is shared with straight away. One
|
||||||
|
that does not gets an invitation — a link that creates the account and the share
|
||||||
|
together, so the note is theirs the moment they sign up. Invitations wait in the
|
||||||
|
panel under the note, where they can be withdrawn; that is the only way back, since
|
||||||
|
only the token's hash is stored and a link sent to the wrong address cannot be
|
||||||
|
looked up.
|
||||||
|
|
||||||
|
### Naming somebody in a note
|
||||||
|
|
||||||
|
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
|
||||||
|
note *is* wanting them to read it, and doing it as two acts — write the name, then
|
||||||
|
open the panel and find the person — meant the second was forgotten, and the note
|
||||||
|
sat there addressing somebody who could not see it. The editor suggests names after
|
||||||
|
`@`, the same way `#` suggests projects.
|
||||||
|
|
||||||
|
- **Only the owner's mentions share.** An editor may write the name; giving
|
||||||
|
somebody else's note away is the owner's alone.
|
||||||
|
- **As a reader**, which is the least that satisfies "look at this". A role you
|
||||||
|
have raised in the panel is never lowered by a mention.
|
||||||
|
- **Only a name that has just appeared** counts. A note is saved on every typing
|
||||||
|
pause, so without that every save would be another share and another notification
|
||||||
|
about the same name.
|
||||||
|
|
||||||
## Finding
|
## Finding
|
||||||
|
|
||||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||||
@@ -97,6 +138,14 @@ Search finds a word wherever it is:
|
|||||||
body. A word in the title is what the note is about; the same word once in a long
|
body. A word in the title is what the note is about; the same word once in a long
|
||||||
body is a mention.
|
body is a mention.
|
||||||
|
|
||||||
|
Two places to search from, and they now agree. The field above the list filters it
|
||||||
|
in place. **⌘K** searches everything at once — tasks, projects, notes and the
|
||||||
|
settings — and picking a note from it opens that note *with the rest of the matches
|
||||||
|
still in the list beside it*. It used to drop you on the ordinary list, so the
|
||||||
|
other results existed only in the popup you had just closed. The address carries
|
||||||
|
the search, which also means a search worth keeping can be bookmarked or sent to
|
||||||
|
somebody.
|
||||||
|
|
||||||
## Putting notes away
|
## Putting notes away
|
||||||
|
|
||||||
Two different things, and the difference matters:
|
Two different things, and the difference matters:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
What other people did that you need to know about, and what the day has in it.
|
||||||
|
Verdande tells you in three places at once, and they are the same thing said three
|
||||||
|
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
|
||||||
|
the app's icon.
|
||||||
|
|
||||||
|
## What causes one
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||||
|
| Somebody shared a note with you | Once, when it happens |
|
||||||
|
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||||
|
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||||
|
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||||
|
| Somebody commented | |
|
||||||
|
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
|
||||||
|
| A new version is out | Administrators only, once per version |
|
||||||
|
|
||||||
|
Not your own actions: the server does not tell anybody what they just did
|
||||||
|
themselves. And not everything that happens — a bell that rings at every change in
|
||||||
|
a shared project is a bell people stop looking at.
|
||||||
|
|
||||||
|
**Repeat edits are folded.** A note is saved on every typing pause, so one person
|
||||||
|
writing for five minutes would otherwise be twenty identical lines. While the
|
||||||
|
message is unread, a further edit moves it back to the top and says who touched it
|
||||||
|
last. Once you have read it, the next edit is a new thing to be told.
|
||||||
|
|
||||||
|
## Push, and the badge
|
||||||
|
|
||||||
|
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
|
||||||
|
device: the phone in your pocket and the laptop you have open are different
|
||||||
|
answers, and a permission granted on one is not the other's.
|
||||||
|
|
||||||
|
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
|
||||||
|
PC — needs the app to be installed (added to the Dock, or installed as an app in
|
||||||
|
Chrome or Edge). Half the point of being told is being told while you are doing
|
||||||
|
something else, and the icon is the only place the app can say anything then. It
|
||||||
|
counts unread messages and goes down by itself as you read them. Firefox does not
|
||||||
|
support it; nothing else changes there.
|
||||||
|
|
||||||
|
The sentence you see is written in your browser from *what happened* and *who did
|
||||||
|
it*, so an English interface gets an English bell. The server's own wording is
|
||||||
|
Danish and is used only for push, which has no dictionary to look a phrase up in.
|
||||||
|
|
||||||
|
## Telling you about a new version
|
||||||
|
|
||||||
|
If the [update check](configuration.md) is on, administrators get one notification
|
||||||
|
per version — never one a day about the same one — and it leads to
|
||||||
|
**Settings → Beskeder**, where the button that restarts the instance to update it
|
||||||
|
sits. With the check off, nothing is fetched and nothing is said.
|
||||||
+105
-8
@@ -2307,7 +2307,15 @@ paths:
|
|||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
get:
|
get:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Who the project is shared with.
|
summary: Who the project is shared with, who can be added, and who has been invited.
|
||||||
|
description: >
|
||||||
|
Three lists in one answer, so the share panel opens complete in a single
|
||||||
|
round-trip. `candidates` is everybody with an account here who is not
|
||||||
|
already on the project — the instance's accounts are a closed, invited set,
|
||||||
|
so listing them to each other is the address book the feature needs, not a
|
||||||
|
disclosure. `pending` is the invitations sent to people who have no account
|
||||||
|
yet: without them an invitation sent yesterday is in neither of the other
|
||||||
|
two lists, and gets sent a second time.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2319,6 +2327,17 @@ paths:
|
|||||||
members:
|
members:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Member" }
|
items: { $ref: "#/components/schemas/Member" }
|
||||||
|
candidates:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Person" }
|
||||||
|
pending:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
|
role: { type: string, enum: [editor, viewer] }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}/members/{userID}:
|
/api/v1/projects/{projectID}/members/{userID}:
|
||||||
@@ -2360,8 +2379,19 @@ paths:
|
|||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
delete:
|
delete:
|
||||||
tags: [projects]
|
tags: [projects]
|
||||||
summary: Remove somebody from a project. Its owner only.
|
summary: Remove somebody from a project — its owner, or yourself.
|
||||||
description: |
|
description: |
|
||||||
|
Two things through one route: the owner taking somebody off, and anybody
|
||||||
|
taking themselves off. The second is what makes a share something you can
|
||||||
|
undo. You are let into a project without being asked, so a way out that
|
||||||
|
does not go through asking its owner is the other half of that.
|
||||||
|
|
||||||
|
The owner cannot leave their own project. That is a transfer of ownership,
|
||||||
|
not an exit, and there is no transfer yet.
|
||||||
|
|
||||||
|
Pointing at somebody else's membership without owning the project answers
|
||||||
|
404 rather than 403: saying "you may not" confirms the membership exists.
|
||||||
|
|
||||||
Their tasks are unassigned rather than left pointing at somebody who can
|
Their tasks are unassigned rather than left pointing at somebody who can
|
||||||
no longer see the project — which is why changing a role is a PATCH and
|
no longer see the project — which is why changing a role is a PATCH and
|
||||||
not a remove-and-reinvite.
|
not a remove-and-reinvite.
|
||||||
@@ -2384,13 +2414,32 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [email, role]
|
|
||||||
properties:
|
properties:
|
||||||
email: { type: string, format: email }
|
user_id:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Somebody who already has an account here, picked from the
|
||||||
|
candidates in GET /members. One of user_id or email.
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: >
|
||||||
|
An address. If it belongs to an account they are added at
|
||||||
|
once; otherwise an invitation is sent.
|
||||||
role: { type: string, enum: [editor, viewer] }
|
role: { type: string, enum: [editor, viewer] }
|
||||||
responses:
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Added straight away — the person already had an account.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
added: { type: boolean }
|
||||||
|
user: { $ref: "#/components/schemas/Person" }
|
||||||
|
role: { type: string, enum: [editor, viewer] }
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Invited. No account yet, so a link was made.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -2399,8 +2448,30 @@ paths:
|
|||||||
link: { type: string }
|
link: { type: string }
|
||||||
emailed: { type: boolean }
|
emailed: { type: boolean }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/projects/{projectID}/invites/{inviteID}:
|
||||||
|
delete:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Withdraw an invitation to a project before it is used. Its owner only.
|
||||||
|
description: >
|
||||||
|
The only way back. Only the token's hash is stored, so a link sent to the
|
||||||
|
wrong address cannot be looked up and invalidated — only the row it belongs
|
||||||
|
to can.
|
||||||
|
parameters:
|
||||||
|
- name: projectID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: inviteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Withdrawn }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}/activity:
|
/api/v1/projects/{projectID}/activity:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -3688,7 +3759,21 @@ paths:
|
|||||||
/api/v1/ai/plan/now:
|
/api/v1/ai/plan/now:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
summary: Send today's plan now, to whoever asks.
|
summary: Make today's plan now, to whoever asks.
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
silent:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
Make and store it without sending a notification — the card on
|
||||||
|
Today asking for a plan while somebody is looking at it. A
|
||||||
|
notification about something in front of you is the kind that
|
||||||
|
teaches people to ignore the bell.
|
||||||
description: |
|
description: |
|
||||||
The counts come from the database and the sentence from the model, in that
|
The counts come from the database and the sentence from the model, in that
|
||||||
order: a model that counted for itself would sometimes count wrong, and a
|
order: a model that counted for itself would sometimes count wrong, and a
|
||||||
@@ -4545,8 +4630,9 @@ components:
|
|||||||
DailyPlanSettings:
|
DailyPlanSettings:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
One person's morning plan. Off by default — a notification at seven in the
|
One person's morning plan, and today's plan itself. One resource, because
|
||||||
morning that nobody asked for is not a help.
|
it is one thing: the plan is the resource and the setting is when it gets
|
||||||
|
made.
|
||||||
properties:
|
properties:
|
||||||
enabled: { type: boolean }
|
enabled: { type: boolean }
|
||||||
hour:
|
hour:
|
||||||
@@ -4555,6 +4641,17 @@ components:
|
|||||||
last_sent:
|
last_sent:
|
||||||
type: string
|
type: string
|
||||||
description: The date it last went out, in that person's own timezone.
|
description: The date it last went out, in that person's own timezone.
|
||||||
|
plan:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Today's plan as it was sent — kept rather than recomputed on every
|
||||||
|
look. A plan that says something different when you look at it than it
|
||||||
|
said when it arrived is worse than either. Absent when the stored plan
|
||||||
|
is from another day.
|
||||||
|
plan_date: { type: string }
|
||||||
|
plan_at:
|
||||||
|
type: integer
|
||||||
|
description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.
|
||||||
|
|
||||||
AISuggestion:
|
AISuggestion:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
|
|||||||
anywhere: the first person to open a fresh instance creates the administrator
|
anywhere: the first person to open a fresh instance creates the administrator
|
||||||
account, and after that people arrive by invite link.
|
account, and after that people arrive by invite link.
|
||||||
|
|
||||||
|
## Being let into a project
|
||||||
|
|
||||||
|
Two different situations, one panel — **the project's ⋯ menu → Share**.
|
||||||
|
|
||||||
|
Somebody who **already has an account here** you pick from a list by name. You do
|
||||||
|
not need to know their email address to share a project with a colleague whose
|
||||||
|
name you know. The accounts on an instance are a closed set — there is no open
|
||||||
|
signup, every one of them was invited — so listing them to each other is the
|
||||||
|
address book the feature needs, not a disclosure.
|
||||||
|
|
||||||
|
Somebody who is **not here yet** you invite by email. If the address turns out to
|
||||||
|
belong to an account it becomes an ordinary share straight away; otherwise it
|
||||||
|
becomes a link that creates their account and their membership at once. With no
|
||||||
|
mail server configured the link is shown in the panel instead of being sent, which
|
||||||
|
is the correct behaviour on a one-person instance rather than an error.
|
||||||
|
|
||||||
|
**You are told, and you can leave.** Being added to a project is not something
|
||||||
|
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||||
|
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||||
|
the project* button. That is deliberate rather than an approval step — an
|
||||||
|
invitation waiting on a yes means the person who shared thinks it is done while
|
||||||
|
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||||
|
added without being asked, without being told, and with no way out except asking
|
||||||
|
the owner. The owner cannot leave their own project; that is a transfer of
|
||||||
|
ownership, and there is no transfer yet.
|
||||||
|
|
||||||
|
An invitation that has not been taken up stays listed under the members, greyed,
|
||||||
|
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||||
|
so they are neither a member nor somebody you can pick — and the usual next move
|
||||||
|
is to send it a second time. The second link works exactly as well as the first,
|
||||||
|
and then there are two to keep track of.
|
||||||
|
|
||||||
## A code on top of it
|
## A code on top of it
|
||||||
|
|
||||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ type planSettings struct {
|
|||||||
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
||||||
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
||||||
LastSent string `json:"last_sent,omitempty"`
|
LastSent string `json:"last_sent,omitempty"`
|
||||||
|
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
|
||||||
|
//
|
||||||
|
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
|
||||||
|
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
|
||||||
|
// andet end den besked, man fik i morges, er værre end begge dele hver for
|
||||||
|
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
|
||||||
|
// gæt hver gang man kigger.
|
||||||
|
//
|
||||||
|
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
|
||||||
|
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
|
||||||
|
Plan string `json:"plan,omitempty"`
|
||||||
|
PlanDate string `json:"plan_date,omitempty"`
|
||||||
|
PlanAt int64 `json:"plan_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
||||||
@@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings,
|
|||||||
if v, ok := stored["last_sent"].(string); ok {
|
if v, ok := stored["last_sent"].(string); ok {
|
||||||
out.LastSent = v
|
out.LastSent = v
|
||||||
}
|
}
|
||||||
|
if v, ok := stored["plan"].(string); ok {
|
||||||
|
out.Plan = v
|
||||||
|
}
|
||||||
|
if v, ok := stored["plan_date"].(string); ok {
|
||||||
|
out.PlanDate = v
|
||||||
|
}
|
||||||
|
if v, ok := stored["plan_at"].(float64); ok {
|
||||||
|
out.PlanAt = int64(v)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||||
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
||||||
|
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetPlanSettings er både indstillingen og dagens plan.
|
||||||
|
//
|
||||||
|
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
|
||||||
|
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
|
||||||
|
// to steder at huske at rydde en plan fra i går.
|
||||||
|
//
|
||||||
|
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
|
||||||
|
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
|
||||||
|
// et tomt kort.
|
||||||
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
|
user := userFrom(r.Context())
|
||||||
|
p, err := s.planSettings(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "plan settings", err)
|
s.internal(w, r, "plan settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
|
||||||
|
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, p)
|
writeJSON(w, http.StatusOK, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, p)
|
writeJSON(w, http.StatusOK, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
|
type planNowRequest struct {
|
||||||
|
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
|
||||||
|
//
|
||||||
|
// En besked om noget, man står og ser på, er en besked, der lærer folk at
|
||||||
|
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
|
||||||
|
// hele dens formål: at vise, at push virker på det her apparat.
|
||||||
|
Silent bool `json:"silent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
|
||||||
//
|
//
|
||||||
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
||||||
// af, er en indstilling, ingen tør slå til.
|
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
|
||||||
|
// lave en plan for den, der har beskeden slået fra.
|
||||||
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req planNowRequest
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
plan, err := s.buildPlan(r.Context(), user)
|
plan, err := s.buildPlan(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "build plan", err)
|
s.internal(w, r, "build plan", err)
|
||||||
@@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.notify(r, &store.Notification{
|
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||||
UserID: user.ID, Kind: "daily.plan",
|
s.internal(w, r, "save plan", err)
|
||||||
Title: planTitle(user.Locale), Body: plan,
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := false
|
||||||
|
if !req.Silent {
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: user.ID, Kind: "daily.plan",
|
||||||
|
Title: planTitle(user.Locale), Body: plan,
|
||||||
|
})
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"plan": plan, "sent": sent,
|
||||||
|
"plan_at": time.Now().Unix(),
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
|
}
|
||||||
|
|
||||||
|
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
|
||||||
|
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
|
||||||
|
p, err := s.planSettings(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
p.Plan = plan
|
||||||
|
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
p.PlanAt = now.Unix()
|
||||||
|
return s.savePlanSettings(ctx, user.ID, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
||||||
@@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error {
|
|||||||
UserID: user.ID, Kind: "daily.plan",
|
UserID: user.ID, Kind: "daily.plan",
|
||||||
Title: planTitle(user.Locale), Body: plan,
|
Title: planTitle(user.Locale), Body: plan,
|
||||||
})
|
})
|
||||||
|
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
|
||||||
|
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
|
||||||
|
// ene overskrive den andens `last_sent`.
|
||||||
|
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
|
||||||
}
|
}
|
||||||
p.LastSent = today
|
p.LastSent = today
|
||||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
||||||
@@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
|
|||||||
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
|
||||||
|
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
|
||||||
|
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.quickTask(t, "noget i dag")
|
||||||
|
|
||||||
|
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
|
||||||
|
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
|
||||||
|
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("lav planen: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if made["sent"] != false {
|
||||||
|
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||||
|
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||||
|
if after["plan"] != made["plan"] {
|
||||||
|
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
|
||||||
|
}
|
||||||
|
if after["plan_at"] == nil {
|
||||||
|
t.Error("planen bærer ikke, hvornår den blev lavet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
|
||||||
|
// overskriften "i dag", er værre end et tomt kort.
|
||||||
|
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.quickTask(t, "noget i dag")
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("lav planen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sat en dag tilbage, som om den var lavet i går.
|
||||||
|
user := userID(t, ts, "kristian@example.dk")
|
||||||
|
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||||
|
if plan, _ := out["plan"].(string); plan != "" {
|
||||||
|
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||||
|
//
|
||||||
|
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||||
|
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||||
|
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||||
|
// opgaver til dig i det.
|
||||||
|
//
|
||||||
|
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||||
|
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||||
|
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||||
|
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||||
|
if project == nil || userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
if actor.ID == userID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||||
|
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||||
// it — folded into one line per note while it is unread.
|
// it — folded into one line per note while it is unread.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -452,22 +452,100 @@ type memberJSON struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
|
||||||
|
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
|
||||||
|
// dukker op.
|
||||||
|
type projectInviteJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMembers is everything the share panel needs, in one answer: who is on
|
||||||
|
// the project, who can be added, and who has been invited and not arrived.
|
||||||
|
//
|
||||||
|
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
|
||||||
|
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
|
||||||
|
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
|
||||||
|
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
|
||||||
|
//
|
||||||
|
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
|
||||||
|
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
|
||||||
|
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
|
||||||
|
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
|
||||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
members, err := s.db.ListMembers(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "list members", err)
|
s.internal(w, r, "list members", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
out := make([]memberJSON, 0, len(members))
|
||||||
|
already := map[string]bool{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
||||||
|
already[m.UserID] = true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
|
||||||
|
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
already[project.OwnerID] = true
|
||||||
|
|
||||||
|
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "share candidates", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates := make([]personJSON, 0, len(people))
|
||||||
|
for _, p := range people {
|
||||||
|
if already[p.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
|
}
|
||||||
|
|
||||||
|
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list project invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending := make([]projectInviteJSON, 0, len(invites))
|
||||||
|
for _, i := range invites {
|
||||||
|
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"members": out,
|
||||||
|
"candidates": candidates,
|
||||||
|
"pending": pending,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
|
||||||
|
//
|
||||||
|
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
|
||||||
|
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
|
||||||
|
// udløber, med et link, der virker hele tiden.
|
||||||
|
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "delete project invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteRequest struct {
|
type inviteRequest struct {
|
||||||
Email string `json:"email"`
|
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||||
Role string `json:"role"`
|
// på — og den er også den, der virker, når der ingen konto er endnu.
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteResponse struct {
|
type inviteResponse struct {
|
||||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
|||||||
Emailed bool `json:"emailed"`
|
Emailed bool `json:"emailed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
// handleInvite shares a project with somebody.
|
||||||
// anybody else gets a link that creates their account and their membership at once.
|
//
|
||||||
|
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
|
||||||
|
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
|
||||||
|
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
|
||||||
|
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
|
||||||
|
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
|
||||||
|
// kontoen og medlemskabet på én gang.
|
||||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
var req inviteRequest
|
var req inviteRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := store.NormalizeEmail(req.Email)
|
email := store.NormalizeEmail(req.Email)
|
||||||
if email == "" || !strings.Contains(email, "@") {
|
if req.UserID == "" && email == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserID == "" && !strings.Contains(email, "@") {
|
||||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Somebody who already has an account is added straight away: sending them
|
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||||
// through a signup form they cannot complete would be a dead end.
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||||
if existing.ID == project.OwnerID {
|
if req.UserID == "" {
|
||||||
|
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||||
|
req.UserID = existing.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID != "" {
|
||||||
|
if req.UserID == project.OwnerID {
|
||||||
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
|
||||||
|
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
|
||||||
|
// tom plads, ingen kan fjerne.
|
||||||
|
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
|
||||||
s.internal(w, r, "add member", err)
|
s.internal(w, r, "add member", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||||
|
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||||
|
s.notifyProjectShared(r, project, person.ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"added": true,
|
||||||
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||||
|
// og derfor ikke selv ved, hvem den ramte.
|
||||||
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||||
|
"role": string(role),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
||||||
|
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
||||||
|
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||||
|
// anybody removing themselves.
|
||||||
|
//
|
||||||
|
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||||
|
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||||
|
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||||
|
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||||
|
//
|
||||||
|
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||||
|
// overdragelse, og den findes ikke endnu.
|
||||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, r, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||||
|
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||||
|
if me.ID != owner && userID != me.ID {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||||
|
return
|
||||||
|
}
|
||||||
if userID == owner {
|
if userID == owner {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
"the owner cannot be removed from their own project")
|
"the owner cannot be removed from their own project")
|
||||||
|
|||||||
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||||
|
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||||
|
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||||
|
// beskytte ved at lade være med at vise dem.
|
||||||
|
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
// Først står de i listen over dem, der kan tilføjes.
|
||||||
|
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if !hasPerson(before["candidates"], anden.ID) {
|
||||||
|
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delt ved at pege, ikke ved at stave.
|
||||||
|
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||||
|
}
|
||||||
|
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||||
|
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||||
|
t.Errorf("user = %v, want Anden", added["user"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||||
|
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if hasPerson(after["candidates"], anden.ID) {
|
||||||
|
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, raw := range after["members"].([]any) {
|
||||||
|
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||||
|
found = true
|
||||||
|
if m["role"] != "editor" {
|
||||||
|
t.Errorf("role = %v, want editor", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||||
|
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if hasPerson(body["candidates"], me.ID) {
|
||||||
|
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||||
|
//
|
||||||
|
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||||
|
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||||
|
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||||
|
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
pending, _ := body["pending"].([]any)
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||||
|
}
|
||||||
|
first := pending[0].(map[string]any)
|
||||||
|
if first["email"] != "fremmed@example.dk" {
|
||||||
|
t.Errorf("email = %v", first["email"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et andet link til den samme indbakke hjælper ingen.
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||||
|
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||||
|
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||||
|
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||||
|
func hasPerson(raw any, id string) bool {
|
||||||
|
list, _ := raw.([]any)
|
||||||
|
for _, item := range list {
|
||||||
|
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||||
|
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||||
|
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
anden := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
andenUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||||
|
|
||||||
|
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||||
|
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||||
|
var shared map[string]any
|
||||||
|
for _, raw := range inbox["notifications"].([]any) {
|
||||||
|
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||||
|
shared = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared == nil {
|
||||||
|
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||||
|
}
|
||||||
|
if shared["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||||
|
shared["project_id"], projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen.
|
||||||
|
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||||
|
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
en := ts.newUser(t, "en@example.dk", "En")
|
||||||
|
ts.newUser(t, "to@example.dk", "To")
|
||||||
|
|
||||||
|
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
toUser, err := ts.db.UserByEmail(t.Context(), "to@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"user_id": u, "role": "editor"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||||
|
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||||
|
if len(members["members"].([]any)) != 3 {
|
||||||
|
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||||
|
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE",
|
||||||
|
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("/", s.handleGetProject)
|
||||||
r.Get("/sections", s.handleListSections)
|
r.Get("/sections", s.handleListSections)
|
||||||
r.Get("/members", s.handleListMembers)
|
r.Get("/members", s.handleListMembers)
|
||||||
|
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||||
|
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||||
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
r.Get("/activity", s.handleActivity)
|
r.Get("/activity", s.handleActivity)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -506,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateProject)
|
r.Patch("/", s.handleUpdateProject)
|
||||||
r.Delete("/", s.handleDeleteProject)
|
r.Delete("/", s.handleDeleteProject)
|
||||||
r.Post("/invites", s.handleInvite)
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -354,6 +354,89 @@ func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- invitationer til ét projekt --------------------------------------------------
|
||||||
|
|
||||||
|
// ProjectInvite is somebody invited to a project who has not arrived yet.
|
||||||
|
//
|
||||||
|
// The same shape as NoteInvite and deliberately not the same type: the two rows
|
||||||
|
// differ in which column carries the target, and one struct covering both would
|
||||||
|
// need a field that is empty half the time — which is how a caller ends up
|
||||||
|
// reading the wrong one.
|
||||||
|
type ProjectInvite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
ExpiresAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjectInvites is who has been invited to a project and not yet taken it up.
|
||||||
|
//
|
||||||
|
// The panel needs them beside the members for the same reason the note panel does:
|
||||||
|
// an invitation sent yesterday is otherwise invisible, so it gets sent again — a
|
||||||
|
// second link to the same inbox, and no way to take either back. That mattered
|
||||||
|
// less while inviting meant typing an address; it matters more now that the panel
|
||||||
|
// offers a list to pick from, because the people who are *missing* from that list
|
||||||
|
// are exactly the ones already invited.
|
||||||
|
//
|
||||||
|
// Expired ones are left out, the same rule the administrator's list follows.
|
||||||
|
func (db *DB) ListProjectInvites(ctx context.Context, projectID string) ([]ProjectInvite, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, email, role, created_at, expires_at
|
||||||
|
FROM invites
|
||||||
|
WHERE project_id = ? AND note_id IS NULL AND accepted_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY created_at`, projectID, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []ProjectInvite{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ProjectInvite
|
||||||
|
var created, expires int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectInvite withdraws an invitation to a project, and refuses to
|
||||||
|
// withdraw anything else.
|
||||||
|
//
|
||||||
|
// The project id is in the WHERE rather than checked beforehand, for the same
|
||||||
|
// reason DeleteNoteInvite puts the note id there: the caller's ownership has been
|
||||||
|
// established for *this* project, and a statement that can only touch this
|
||||||
|
// project's rows cannot be talked into deleting somebody else's invite by id.
|
||||||
|
func (db *DB) DeleteProjectInvite(ctx context.Context, projectID, inviteID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM invites WHERE id = ? AND project_id = ? AND note_id IS NULL`,
|
||||||
|
inviteID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingProjectInvite reports whether this address already has a live invitation
|
||||||
|
// to this project, so a second attempt can say so instead of sending another link.
|
||||||
|
func (db *DB) PendingProjectInvite(ctx context.Context, projectID, email string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*) FROM invites
|
||||||
|
WHERE project_id = ? AND note_id IS NULL AND lower(email) = lower(?)
|
||||||
|
AND accepted_at IS NULL AND expires_at > ?`,
|
||||||
|
projectID, email, time.Now().Unix()).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// PendingNoteInvite reports whether this address already has a live invitation to
|
// PendingNoteInvite reports whether this address already has a live invitation to
|
||||||
// this note, so a second attempt can say so instead of sending a second link.
|
// this note, so a second attempt can say so instead of sending a second link.
|
||||||
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ nav:
|
|||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
- Quick add: quick-add.md
|
- Quick add: quick-add.md
|
||||||
- Notes: notes.md
|
- Notes: notes.md
|
||||||
|
- Notifications: notifications.md
|
||||||
|
- Opening links: links.md
|
||||||
- Filters: filters.md
|
- Filters: filters.md
|
||||||
- Importing from Todoist: import.md
|
- Importing from Todoist: import.md
|
||||||
- Connecting things:
|
- Connecting things:
|
||||||
@@ -60,6 +62,8 @@ nav:
|
|||||||
- Mailboxes: mailboxes.md
|
- Mailboxes: mailboxes.md
|
||||||
- Claude (MCP): mcp.md
|
- Claude (MCP): mcp.md
|
||||||
- AI features: ai.md
|
- AI features: ai.md
|
||||||
|
- Privacy:
|
||||||
|
- The install count: beacon.md
|
||||||
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
|
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
|
||||||
# file, and api.md links to it. A nav entry expects a page to render.
|
# file, and api.md links to it. A nav entry expects a page to render.
|
||||||
- Reference:
|
- Reference:
|
||||||
|
|||||||
+58
-11
@@ -1326,6 +1326,64 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.47</span>
|
||||||
|
Click a picture in a note and it opens at full size, with a button that
|
||||||
|
copies it to the clipboard. In the note it is scaled to the width of the
|
||||||
|
column — a photo off a phone is four thousand pixels across — so what you
|
||||||
|
were looking at was a thumbnail that did not look like one.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.47</span>
|
||||||
|
Search in ⌘K, pick a note, and the rest of the matches stay in the list
|
||||||
|
beside it. They used to exist only in the popup you had just closed.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.46</span>
|
||||||
|
Share a project with somebody who already has an account by picking their
|
||||||
|
name, rather than by spelling an email address you would have to know.
|
||||||
|
Invitations that have not been taken up stay visible so they do not get
|
||||||
|
sent twice, and can be withdrawn.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.46</span>
|
||||||
|
You are told when a project is shared with you, and there is a
|
||||||
|
<strong>Leave the project</strong> button. Being let in without being
|
||||||
|
asked, without being told, and with no way out was three things at once.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.45</span>
|
||||||
|
Search for a setting instead of remembering which tab it was under — in
|
||||||
|
the settings themselves and in ⌘K. It knows them by what they do, so
|
||||||
|
<em>beacon</em> finds the install count even though that is not its name.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.45</span>
|
||||||
|
Today's plan is a card on <strong>I dag</strong> rather than only a
|
||||||
|
message in the morning that was gone if you did not read it at once. And
|
||||||
|
a link can be told which browser to open in.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.44</span>
|
||||||
|
An optional AI layer, on your own key: it tidies the inbox, pulls the
|
||||||
|
tasks out of a meeting note, writes a morning plan, turns
|
||||||
|
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
|
||||||
|
for, and answers questions about your own notes with links to what the
|
||||||
|
answer rests on. Every one of them is a line you accept, edit or refuse —
|
||||||
|
it suggests, it never writes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.44</span>
|
||||||
|
Administrators are told in the bell when a new version is out, once per
|
||||||
|
version, and the message leads to the button that restarts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.43</span>
|
||||||
|
Anything that can call a URL can put a task in your inbox — a shortcut on
|
||||||
|
your phone, a script, a service. Notes can be handed to somebody who has
|
||||||
|
no account yet, and writing <code>@Sofie</code> in a note shares it with
|
||||||
|
her and tells her. Unread counts show on the app's icon in the Dock.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="ver">0.42</span>
|
<span class="ver">0.42</span>
|
||||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||||
@@ -1362,17 +1420,6 @@
|
|||||||
body to move it. A single note can be shared with one person, and a
|
body to move it. A single note can be shared with one person, and a
|
||||||
<code>[[note]]</code> is a link you follow inline.
|
<code>[[note]]</code> is a link you follow inline.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<span class="ver">0.34</span>
|
|
||||||
Sections can be dragged into the order the work actually happens in. The
|
|
||||||
sidebar keeps its head still while the projects scroll.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.33</span>
|
|
||||||
Notes grew an archive, selection of many at once, pictures you can paste
|
|
||||||
or drag in, a source view, and a copy button on code blocks. Search
|
|
||||||
stopped answering by date and started answering by relevance.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indekset over indstillingerne skal passe på siderne.
|
||||||
|
*
|
||||||
|
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
|
||||||
|
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
|
||||||
|
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
|
||||||
|
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
|
||||||
|
*
|
||||||
|
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
|
||||||
|
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
|
||||||
|
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
|
||||||
|
* ville melde fejl på det, der med rette var skjult.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const routes = join(here, '..', 'src', 'routes');
|
||||||
|
const locales = join(here, '..', 'src', 'lib', 'locales');
|
||||||
|
|
||||||
|
/** `/indstillinger/data` → filen, den side tegnes af. */
|
||||||
|
function pageFile(path) {
|
||||||
|
return join(routes, path, '+page.svelte');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hvert afsnit i indekset findes på den side, det peger på', () => {
|
||||||
|
const missing = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
const src = readFileSync(pageFile(entry.path), 'utf8');
|
||||||
|
const hits = src.split(`id="${entry.anchor}"`).length - 1;
|
||||||
|
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('to afsnit deler ikke et anker', () => {
|
||||||
|
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
|
||||||
|
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
|
||||||
|
const seen = new Set();
|
||||||
|
const dupes = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
const key = `${entry.path}#${entry.anchor}`;
|
||||||
|
if (seen.has(key)) dupes.push(key);
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
expect(dupes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
|
||||||
|
const missing = [];
|
||||||
|
for (const locale of ['da', 'en']) {
|
||||||
|
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
for (const key of [entry.key, entry.tab]) {
|
||||||
|
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('søgningen finder det, man ville skrive', () => {
|
||||||
|
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
|
||||||
|
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
|
||||||
|
const t = (key) => key;
|
||||||
|
const found = (q, admin = true) =>
|
||||||
|
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
|
||||||
|
|
||||||
|
expect(found('beacon')).toContain('/indstillinger/data#beacon');
|
||||||
|
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
|
||||||
|
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
|
||||||
|
expect(found('2fa')).toContain('/indstillinger#to-faktor');
|
||||||
|
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
|
||||||
|
|
||||||
|
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
|
||||||
|
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
|
||||||
|
expect(found('')).toEqual([]);
|
||||||
|
expect(found(' ')).toEqual([]);
|
||||||
|
|
||||||
|
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
|
||||||
|
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
|
||||||
|
// kan holde.
|
||||||
|
expect(found('backup', false)).toEqual([]);
|
||||||
|
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
|
||||||
|
});
|
||||||
+484
-1
@@ -1104,7 +1104,9 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
|
|
||||||
await projectAction(page, 'Del');
|
await projectAction(page, 'Del');
|
||||||
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
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();
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||||
const link = await page.locator('.link-out code').textContent();
|
const link = await page.locator('.link-out code').textContent();
|
||||||
|
|
||||||
@@ -4272,3 +4274,484 @@ test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', asyn
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke findes', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Links' })).toBeVisible();
|
||||||
|
|
||||||
|
// Som udgangspunkt gør indstillingen ingenting, og der er intet skemafelt at
|
||||||
|
// skrive i: et felt til en adresse, der ikke bliver brugt, er en invitation
|
||||||
|
// til at udfylde den og undre sig.
|
||||||
|
await expect(page.getByLabel('Skema')).toBeHidden();
|
||||||
|
|
||||||
|
// Et skema, der med sikkerhed ikke findes på nogen maskine. Pointen er ikke,
|
||||||
|
// at det virker — pointen er, hvad der sker, når det ikke gør.
|
||||||
|
await page.getByLabel('Åbn links i').selectOption('custom');
|
||||||
|
await page.getByLabel('Skema').fill('verdande-findes-ikke://{url}');
|
||||||
|
await page.getByLabel('Skema').blur();
|
||||||
|
|
||||||
|
// Valget hører til maskinen, ikke kontoen, så det står i localStorage.
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('verdande:link-opener'));
|
||||||
|
expect(stored, 'valget blev ikke gemt på apparatet').toContain('verdande-findes-ikke');
|
||||||
|
|
||||||
|
// Om browseren rent faktisk tegner en fane, er browserens sag og ikke appens —
|
||||||
|
// en headless Chromium åbner den uden at fortælle nogen om det. Det, der er
|
||||||
|
// vores, er beslutningen: prøv skemaet, og åbn linket alligevel, når det ikke
|
||||||
|
// findes. Så det er dét, der bliver målt.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__opened = [];
|
||||||
|
window.open = (url) => {
|
||||||
|
window.__opened.push(url);
|
||||||
|
return { closed: false, close() {} };
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Prøv det' }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.__opened), { timeout: 5000 })
|
||||||
|
.toEqual(['https://example.dk']);
|
||||||
|
|
||||||
|
// Og appen blev, hvor den var. Faldbacket må ikke kapre den fane, man står i:
|
||||||
|
// et link, der smider en ud af programmet, er værre end et, der åbner et sted,
|
||||||
|
// man ikke havde valgt.
|
||||||
|
await expect(page).toHaveURL(/\/indstillinger$/);
|
||||||
|
|
||||||
|
// Tilbage til systemets standard, så resten af prøverne klikker på links som
|
||||||
|
// alle andre.
|
||||||
|
await page.getByLabel('Åbn links i').selectOption('');
|
||||||
|
await expect(page.getByLabel('Skema')).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagens plan står på I dag, og ikke kun som en besked', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill('ring til planlæggeren i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// Uden en model er planen to tal — og det er stadig det, man havde brug for at
|
||||||
|
// vide om morgenen. Knappen er der, fordi morgenbeskeden er slået fra som
|
||||||
|
// udgangspunkt: den, der ikke vil vækkes, skal stadig kunne få planen.
|
||||||
|
await page.getByRole('button', { name: 'Lav dagens plan' }).click();
|
||||||
|
|
||||||
|
const card = page.locator('.plan');
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card).toContainText('forfalder i dag');
|
||||||
|
// Klokkeslættet står på kortet: en plan fra klokken syv er en anden slags
|
||||||
|
// oplysning klokken fire end klokken otte.
|
||||||
|
await expect(card.locator('.made')).toBeVisible();
|
||||||
|
|
||||||
|
// Og den bliver stående — det er hele forskellen på et kort og en besked.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.plan')).toContainText('forfalder i dag');
|
||||||
|
|
||||||
|
// Skjult for i dag, ikke slettet: kortet er en påmindelse, og den der har læst
|
||||||
|
// den skal kunne få den af vejen.
|
||||||
|
await page.locator('.plan .close').click();
|
||||||
|
await expect(page.locator('.plan')).toBeHidden();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.plan')).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresserne på integrationssiden kan ses helt på en telefon.
|
||||||
|
*
|
||||||
|
* En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
|
||||||
|
* ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
|
||||||
|
* prøven her holder øje med dem begge:
|
||||||
|
*
|
||||||
|
* Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
|
||||||
|
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
|
||||||
|
* et vandret rullepanel, ikke bare den ene linje.
|
||||||
|
*
|
||||||
|
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
|
||||||
|
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
|
||||||
|
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
|
||||||
|
*
|
||||||
|
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
|
||||||
|
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
|
||||||
|
* der fejler en dag af en anden grund end sin egen.
|
||||||
|
*/
|
||||||
|
test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.route('**/api/v1/calendar', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
connected: false,
|
||||||
|
calendars: [],
|
||||||
|
has_client: false,
|
||||||
|
read_only: true,
|
||||||
|
redirect_uri: 'http://localhost:8097/api/v1/calendar/callback',
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: '0198f000-0000-7000-8000-000000000001',
|
||||||
|
provider: 'ics',
|
||||||
|
account: 'kw@netic.dk',
|
||||||
|
url:
|
||||||
|
'https://calendar.google.com/calendar/ical/kw%40netic.dk/private-3f9a1c7e5b2d4a6f8c0e1d2b3a4f5e6d/basic.ics',
|
||||||
|
created_at: '2026-09-01T09:00:00+02:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// En iPhone i stående format. Bredden er det eneste, der betyder noget her.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/indstillinger/integrationer');
|
||||||
|
await expect(page.getByText('kw@netic.dk')).toBeVisible();
|
||||||
|
|
||||||
|
// `.content` og ikke dokumentet. Skallen holder selv siden på skærmens bredde,
|
||||||
|
// så documentElement måler 390 uanset hvad — det er indholdsspalten indeni, der
|
||||||
|
// ruller, og det er den, man skubber med tommelfingeren. En prøve på dokumentet
|
||||||
|
// består med fejlen i behold; den er prøvet.
|
||||||
|
const spill = await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.content');
|
||||||
|
const url = document.querySelector('.subs-list .url');
|
||||||
|
return {
|
||||||
|
content: el.scrollWidth - el.clientWidth,
|
||||||
|
past: Math.round(url.getBoundingClientRect().right) - document.documentElement.clientWidth
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(spill.content).toBe(0);
|
||||||
|
expect(spill.past).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
|
||||||
|
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
|
||||||
|
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
|
||||||
|
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
|
||||||
|
// præcis det, der måles her.
|
||||||
|
//
|
||||||
|
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
|
||||||
|
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
|
||||||
|
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
|
||||||
|
const clipped = await page.evaluate(() =>
|
||||||
|
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
|
||||||
|
.map((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return `${id}: findes ikke`;
|
||||||
|
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
expect(clipped).toEqual([]);
|
||||||
|
|
||||||
|
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
|
||||||
|
// læses af, hvis man taster den ind i en genvej på telefonen.
|
||||||
|
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En indstilling kan findes ved at søge efter den.
|
||||||
|
*
|
||||||
|
* Indstillingerne er ti faner med en snes afsnit, og den eneste vej til et
|
||||||
|
* bestemt af dem var at huske, hvilken fane det lå under. Prøven går begge veje
|
||||||
|
* ind: feltet øverst i indstillingerne, og ⌘K, som ikke kunne andet end det, man
|
||||||
|
* selv har skrevet.
|
||||||
|
*
|
||||||
|
* "beacon" er valgt med vilje. Afsnittet hedder "Tælling af installationer" på
|
||||||
|
* dansk, så en søgning, der kun læste overskrifterne, ville ikke finde det — og
|
||||||
|
* "beacon" er præcis det ord, man kender det ved. Det er derfor indekset har et
|
||||||
|
* felt med søgeord, ingen får at se.
|
||||||
|
*/
|
||||||
|
test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
|
||||||
|
const find = page.getByPlaceholder('Søg i indstillinger');
|
||||||
|
await find.fill('beacon');
|
||||||
|
|
||||||
|
const hit = page.locator('.hits button', { hasText: 'Tælling af installationer' });
|
||||||
|
await expect(hit).toBeVisible();
|
||||||
|
// Hvor det ligger, står ved siden af hvad det hedder: halvdelen af det, man
|
||||||
|
// søgte efter, er svaret på hvilken fane.
|
||||||
|
await expect(hit).toContainText('Data');
|
||||||
|
await hit.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/indstillinger\/data#beacon$/);
|
||||||
|
const section = page.locator('#beacon');
|
||||||
|
await expect(section.getByRole('heading', { name: 'Tælling af installationer' })).toBeVisible();
|
||||||
|
// Rullet derhen, ikke bare tegnet: afsnittet er det syvende på siden, så en
|
||||||
|
// side, der bare blev åbnet, ville vise toppen af den.
|
||||||
|
await expect(section).toBeInViewport();
|
||||||
|
|
||||||
|
// Feltet tømmer sig selv, når man er landet. En søgning, der bliver stående
|
||||||
|
// over det, man fandt, dækker det.
|
||||||
|
await expect(find).toHaveValue('');
|
||||||
|
|
||||||
|
// Og det, der ikke findes, siger det frem for at vise en tom liste.
|
||||||
|
await find.fill('hestevogn');
|
||||||
|
await expect(page.getByText('Ingen indstilling hedder det.')).toBeVisible();
|
||||||
|
|
||||||
|
// ⌘K spørger det samme indeks. Her er det den anden vej ind, og "webhook" er
|
||||||
|
// igen et ord, der ikke står i overskriften.
|
||||||
|
await page.goto('/');
|
||||||
|
// Knappen og ikke tastetrykket: ⌘K lyttes der efter i en effekt, så et tryk
|
||||||
|
// sendt lige efter goto kan nå frem, før siden er hydreret. Knappen venter
|
||||||
|
// Playwright på af sig selv — og den er også den, man har på en telefon.
|
||||||
|
await page.getByRole('button', { name: /Søg/ }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('textbox').fill('webhook');
|
||||||
|
const row = page.getByRole('dialog').locator('li button', { hasText: 'Skub fra andre programmer' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row).toContainText('Indstilling');
|
||||||
|
await row.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/indstillinger\/integrationer#krog$/);
|
||||||
|
await expect(page.locator('#krog')).toBeInViewport();
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
+8
-2
@@ -338,7 +338,11 @@ export const api = {
|
|||||||
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
||||||
|
|
||||||
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
||||||
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
|
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
|
||||||
|
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
|
||||||
|
// hører til en konto her.
|
||||||
|
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
|
||||||
|
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
|
||||||
setMemberRole: (projectId, userId, role) =>
|
setMemberRole: (projectId, userId, role) =>
|
||||||
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
patch(`/projects/${projectId}/members/${userId}`, { role }),
|
||||||
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
|
||||||
@@ -496,7 +500,9 @@ export const api = {
|
|||||||
// Dagens plan om morgenen.
|
// Dagens plan om morgenen.
|
||||||
planSettings: () => get('/ai/plan'),
|
planSettings: () => get('/ai/plan'),
|
||||||
setPlanSettings: (body) => put('/ai/plan', body),
|
setPlanSettings: (body) => put('/ai/plan', body),
|
||||||
planNow: () => post('/ai/plan/now'),
|
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
|
||||||
|
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
|
||||||
|
planNow: (options) => post('/ai/plan/now', options ?? {}),
|
||||||
|
|
||||||
version: () => get('/version'),
|
version: () => get('/version'),
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { humanMessage } from '$lib/api.js';
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false) } = $props();
|
||||||
|
|
||||||
@@ -68,7 +69,20 @@
|
|||||||
}, 140);
|
}, 140);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indstillingerne, og hvorfor de ligger øverst.
|
||||||
|
*
|
||||||
|
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
|
||||||
|
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
|
||||||
|
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
|
||||||
|
* modsat en søgning i noget, man selv har skrevet.
|
||||||
|
*/
|
||||||
|
let settings = $derived(
|
||||||
|
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
|
||||||
|
);
|
||||||
|
|
||||||
let results = $derived([
|
let results = $derived([
|
||||||
|
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||||
...tasks.map((t) => ({
|
...tasks.map((t) => ({
|
||||||
@@ -108,7 +122,16 @@
|
|||||||
|
|
||||||
function choose(item) {
|
function choose(item) {
|
||||||
open = false;
|
open = false;
|
||||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||||
|
// op, den ER stedet.
|
||||||
|
if (item.kind === 'setting') return goto(item.id);
|
||||||
|
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
||||||
|
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
||||||
|
// lige lukkede.
|
||||||
|
if (item.kind === 'note') {
|
||||||
|
const q = query.trim();
|
||||||
|
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
||||||
|
}
|
||||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -640,9 +640,69 @@
|
|||||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Et billede i en note er et billede, man har indsat for at kunne se det. I
|
||||||
|
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
|
||||||
|
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
|
||||||
|
// der ikke ser ud som et.
|
||||||
|
const image = event.target.closest?.('img');
|
||||||
|
if (image) {
|
||||||
|
event.preventDefault();
|
||||||
|
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||||
|
copied = false;
|
||||||
|
copyError = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
colourCode();
|
colourCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- billedet i fuld størrelse ------------------------------------------------
|
||||||
|
|
||||||
|
let showing = $state(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
let copyError = $state('');
|
||||||
|
|
||||||
|
function closeImage() {
|
||||||
|
showing = null;
|
||||||
|
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||||
|
// findes længere, og den næste tast går ingen steder.
|
||||||
|
editor?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billedet som PNG, uanset hvad det kom som.
|
||||||
|
*
|
||||||
|
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
|
||||||
|
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
|
||||||
|
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
|
||||||
|
*/
|
||||||
|
async function asPNG(src) {
|
||||||
|
const blob = await (await fetch(src)).blob();
|
||||||
|
if (blob.type === 'image/png') return blob;
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImage() {
|
||||||
|
copyError = '';
|
||||||
|
try {
|
||||||
|
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
|
||||||
|
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
|
||||||
|
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
|
||||||
|
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
|
||||||
|
copyError = t('notes.copyFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole thing. */
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
function accept(item) {
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
@@ -1068,7 +1128,102 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
|
||||||
|
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
|
||||||
|
af netop det ark, det skal lægge sig over. -->
|
||||||
|
{#if showing}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<img src={showing.src} alt={showing.alt} />
|
||||||
|
<div class="tools">
|
||||||
|
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||||
|
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||||
|
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||||
|
</div>
|
||||||
|
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (showing && e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeImage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||||
|
står alene — det er hele grunden til at åbne det. */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgb(0 0 0 / 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||||
|
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||||
|
passe sig ind i den. */
|
||||||
|
.shot img {
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 8rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools button,
|
||||||
|
.tools a {
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools button:hover,
|
||||||
|
.tools a:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
|
||||||
|
.page :global(img) {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
'note.shared': 'notif.noteShared',
|
'note.shared': 'notif.noteShared',
|
||||||
|
'project.shared': 'notif.projectShared',
|
||||||
'note.mention': 'notif.mention',
|
'note.mention': 'notif.mention',
|
||||||
'daily.plan': 'notif.dailyPlan',
|
'daily.plan': 'notif.dailyPlan',
|
||||||
'update.available': 'notif.update',
|
'update.available': 'notif.update',
|
||||||
@@ -56,6 +57,9 @@
|
|||||||
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
||||||
// til at gøre noget ved den sidder.
|
// til at gøre noget ved den sidder.
|
||||||
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
||||||
|
// Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er
|
||||||
|
// påmindelsen, kortet er stedet.
|
||||||
|
if (n.kind === 'daily.plan') return goto('/');
|
||||||
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
else if (n.task_id) app.openDetail(n.task_id);
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
@@ -111,7 +115,10 @@
|
|||||||
<li>
|
<li>
|
||||||
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
<span class="what">{headline(n)}</span>
|
<span class="what">{headline(n)}</span>
|
||||||
{#if n.body}<span class="about">{n.body}</span>{/if}
|
<!-- Dagens plan er tre linjer og ikke en overskrift. Klippet af
|
||||||
|
efter én linje sagde den "2 forfalder i dag, 1 er sprun…",
|
||||||
|
hvilket er den halve oplysning og hele støjen. -->
|
||||||
|
{#if n.body}<span class="about" class:full={n.kind === 'daily.plan'}>{n.body}</span>{/if}
|
||||||
<span class="ago">{when(n.created_at)}</span>
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -260,6 +267,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about.full {
|
||||||
|
white-space: pre-line;
|
||||||
|
text-overflow: clip;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.ago {
|
.ago {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Hvilken browser et link åbner i.
|
||||||
|
*
|
||||||
|
* # Hvad der ikke kan lade sig gøre
|
||||||
|
*
|
||||||
|
* En webside kan hverken se, hvilke browsere der er installeret, eller bede
|
||||||
|
* systemet om at åbne et link i en bestemt af dem. Listen over installerede
|
||||||
|
* programmer er lukket land med vilje — den er et fingeraftryk, der ville følge
|
||||||
|
* folk rundt på nettet — og et almindeligt link går altid til standardbrowseren.
|
||||||
|
*
|
||||||
|
* Det eneste håndtag, der findes, er de URL-skemaer, browserne selv registrerer:
|
||||||
|
* `firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…`. Der er ingen
|
||||||
|
* måde at spørge, om et skema findes; man kan kun prøve.
|
||||||
|
*
|
||||||
|
* # Hvad der så sker
|
||||||
|
*
|
||||||
|
* Vi prøver skemaet og holder øje med, om siden forsvinder. Åbnede en anden
|
||||||
|
* browser, mister denne fane fokus, og så er der ikke mere at gøre. Sker der
|
||||||
|
* ingenting inden for et sekund — hvilket er, hvad en Mac gør, hvor Chrome og
|
||||||
|
* Firefox ikke registrerer noget skema — åbnes linket helt normalt.
|
||||||
|
*
|
||||||
|
* Faldbacket er ikke en detalje: uden det ville en indstilling, der ser ud til at
|
||||||
|
* virke på telefonen, gøre links uklikbare på skrivebordet.
|
||||||
|
*
|
||||||
|
* # Hvorfor det står i localStorage
|
||||||
|
*
|
||||||
|
* Samme grund som sidebjælkens bredde: det er en egenskab ved maskinen, man
|
||||||
|
* sidder ved, ikke ved personen. `firefox://` virker på telefonen og gør
|
||||||
|
* ingenting på Mac'en, så et valg, der fulgte kontoen, ville være forkert det ene
|
||||||
|
* af de to steder hver gang.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = 'verdande:link-opener';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De skemaer, der er værd at tilbyde, og hvad de faktisk gør.
|
||||||
|
*
|
||||||
|
* `{url}` erstattes af adressen. Nogle skemaer vil have den kodet som en parameter
|
||||||
|
* (Firefox), andre sætter den bag skemaet, som den er (Chrome, Edge) — derfor er
|
||||||
|
* det en skabelon og ikke et navn, og derfor kan man skrive sin egen.
|
||||||
|
*/
|
||||||
|
export const OPENERS = [
|
||||||
|
{ id: '', label: 'link.openerDefault', template: '' },
|
||||||
|
{ id: 'safari', label: 'link.openerSafari', template: 'x-safari-{url}' },
|
||||||
|
{ id: 'chrome', label: 'link.openerChrome', template: 'googlechrome{stripped}' },
|
||||||
|
{ id: 'firefox', label: 'link.openerFirefox', template: 'firefox://open-url?url={encoded}' },
|
||||||
|
{ id: 'edge', label: 'link.openerEdge', template: 'microsoft-edge:{url}' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Det gemte valg: en af id'erne ovenfor, eller en skabelon man selv har skrevet. */
|
||||||
|
export function stored() {
|
||||||
|
if (typeof localStorage === 'undefined') return { id: '', template: '' };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return { id: '', template: '' };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { id: parsed.id ?? '', template: parsed.template ?? '' };
|
||||||
|
} catch {
|
||||||
|
return { id: '', template: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save(choice) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(choice));
|
||||||
|
} catch {
|
||||||
|
// Privat vindue; valget holder til fanen lukkes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skabelonen, der gælder nu — enten den valgte eller den, man selv har skrevet. */
|
||||||
|
function template() {
|
||||||
|
const choice = stored();
|
||||||
|
if (choice.id === 'custom') return choice.template;
|
||||||
|
return OPENERS.find((o) => o.id === choice.id)?.template ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skabelonen fyldt ud med adressen.
|
||||||
|
*
|
||||||
|
* `{url}` er adressen, som den er — det er dét, `x-safari-https://…` og
|
||||||
|
* `microsoft-edge:https://…` vil have. `{encoded}` er den som parameterværdi, til
|
||||||
|
* Firefox. `{stripped}` er den uden `https://` foran, som Chrome vil have den:
|
||||||
|
* `googlechrome://example.dk`.
|
||||||
|
*/
|
||||||
|
export function expand(tpl, url) {
|
||||||
|
if (!tpl) return '';
|
||||||
|
return tpl
|
||||||
|
.replace('{encoded}', encodeURIComponent(url))
|
||||||
|
.replace('{stripped}', '://' + url.replace(/^https?:\/\//, ''))
|
||||||
|
.replace('{url}', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Åbner en adresse i den valgte browser, og ellers helt normalt.
|
||||||
|
*
|
||||||
|
* Returnerer, om skemaet blev forsøgt. Kalderen skal stadig lade være med at
|
||||||
|
* følge linket selv — det er derfor, klikket bliver stoppet, inden vi kommer
|
||||||
|
* hertil.
|
||||||
|
*/
|
||||||
|
export function open(url) {
|
||||||
|
const tpl = template();
|
||||||
|
if (!tpl) {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handled = false;
|
||||||
|
// Forsvinder siden, åbnede noget andet den. Begge signaler, fordi de to
|
||||||
|
// platforme ikke er enige om hvilket der kommer: en telefon skjuler siden, en
|
||||||
|
// pc flytter fokus.
|
||||||
|
const gone = () => {
|
||||||
|
handled = true;
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', gone, { once: true });
|
||||||
|
document.addEventListener('visibilitychange', gone, { once: true });
|
||||||
|
|
||||||
|
// Adresselinjen og ikke et skjult iframe.
|
||||||
|
//
|
||||||
|
// Iframet er den kendte opskrift, og den kan ikke bruges her: appens egen
|
||||||
|
// Content-Security-Policy siger `default-src 'self'`, så en ramme med et
|
||||||
|
// fremmed skema bliver blokeret, inden browseren når at se på den. Målt i
|
||||||
|
// e2e-prøven, som fangede CSP-fejlen i konsollen — uden den ville skemaet
|
||||||
|
// aldrig være blevet forsøgt i produktion, og indstillingen ville se ud til
|
||||||
|
// at virke, fordi faldbacket åbnede linket alligevel.
|
||||||
|
//
|
||||||
|
// At sætte location på et ukendt skema afbryder navigationen frem for at
|
||||||
|
// forlade siden: dokumentet bliver stående, og det er dét, der gør, at
|
||||||
|
// faldbacket nedenfor stadig har en side at køre i. Nogle browsere siger til
|
||||||
|
// om en adresse, de ikke forstår, og det er prisen for at kunne prøve.
|
||||||
|
location.href = expand(tpl, url);
|
||||||
|
|
||||||
|
// Kort, og det er ikke en smagssag.
|
||||||
|
//
|
||||||
|
// Faldbacket åbner en fane, og en browser tillader kun det et øjeblik efter et
|
||||||
|
// klik — Chrome og Firefox regner med omtrent fem sekunder, Safari med
|
||||||
|
// betydeligt mindre. Ventes der for længe, bliver faldbacket blokeret som en
|
||||||
|
// pop-up, og så gør linket ingenting overhovedet. Seks hundrede millisekunder
|
||||||
|
// er rigeligt til, at en anden browser når at tage over, og godt inden for
|
||||||
|
// det, der stadig tæller som "fordi nogen klikkede".
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('blur', gone);
|
||||||
|
document.removeEventListener('visibilitychange', gone);
|
||||||
|
if (handled || document.hidden) return;
|
||||||
|
|
||||||
|
// Skemaet fandtes ikke her. Linket skal stadig åbne — alt andet ville gøre
|
||||||
|
// en indstilling, der virker på telefonen, til ødelagte links på
|
||||||
|
// skrivebordet.
|
||||||
|
//
|
||||||
|
// Uden `noopener`, og det er ikke en forglemmelse: med den flag returnerer
|
||||||
|
// window.open **altid** null, også når fanen åbnede fint — det står i
|
||||||
|
// specifikationen. Målt her, efter at faldbacket havde troet sig blokeret
|
||||||
|
// hver eneste gang og navigeret den aktuelle fane væk i stedet. Håndtaget
|
||||||
|
// er det eneste, der kan kende en blokeret fane fra en åbnet, så det skal
|
||||||
|
// bruges, og opener ryddes bagefter.
|
||||||
|
const tab = window.open(url, '_blank');
|
||||||
|
if (tab) {
|
||||||
|
try {
|
||||||
|
tab.opener = null;
|
||||||
|
} catch {
|
||||||
|
// En fane på et andet domæne lader sig ikke altid røre. Fanen er
|
||||||
|
// åbnet, og det var det, klikket handlede om.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Blev den alligevel blokeret, går vi i den fane, vi står i. Et dårligere
|
||||||
|
// sted at ende end en ny fane, og et meget bedre sted end et link, der
|
||||||
|
// ikke gjorde noget.
|
||||||
|
location.href = url;
|
||||||
|
}, 600);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export const da = {
|
|||||||
'notif.assigned': '{name} gav dig en opgave',
|
'notif.assigned': '{name} gav dig en opgave',
|
||||||
'notif.noteChanged': '{name} rettede en note',
|
'notif.noteChanged': '{name} rettede en note',
|
||||||
'notif.noteShared': '{name} delte en note med dig',
|
'notif.noteShared': '{name} delte en note med dig',
|
||||||
|
'notif.projectShared': '{name} delte et projekt med dig',
|
||||||
'notif.mention': '{name} nævnte dig i en note',
|
'notif.mention': '{name} nævnte dig i en note',
|
||||||
'notif.dailyPlan': 'Dagens plan',
|
'notif.dailyPlan': 'Dagens plan',
|
||||||
'notif.update': 'Der er kommet en ny version',
|
'notif.update': 'Der er kommet en ny version',
|
||||||
@@ -96,6 +97,10 @@ export const da = {
|
|||||||
'ai.planHint':
|
'ai.planHint':
|
||||||
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
|
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
|
||||||
'ai.planHour': 'Klokken',
|
'ai.planHour': 'Klokken',
|
||||||
|
'ai.planMade': 'lavet {time}',
|
||||||
|
'ai.planAgain': 'Forny planen',
|
||||||
|
'ai.planMake': 'Lav dagens plan',
|
||||||
|
'ai.planDismiss': 'Skjul planen i dag',
|
||||||
'ai.planNow': 'Send den nu',
|
'ai.planNow': 'Send den nu',
|
||||||
'ai.planSent': 'Sendt — kig i klokken.',
|
'ai.planSent': 'Sendt — kig i klokken.',
|
||||||
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
||||||
@@ -105,6 +110,23 @@ export const da = {
|
|||||||
'ai.skip': 'Nej tak',
|
'ai.skip': 'Nej tak',
|
||||||
'ai.applied': 'Gjort.',
|
'ai.applied': 'Gjort.',
|
||||||
'ai.allDone': 'Ikke mere at tage stilling til.',
|
'ai.allDone': 'Ikke mere at tage stilling til.',
|
||||||
|
// Hvilken browser links åbner i. Se linkopen.js for, hvorfor det er et forsøg
|
||||||
|
// og ikke et løfte: en webside kan ikke se, hvilke browsere der findes.
|
||||||
|
'link.title': 'Links',
|
||||||
|
'link.hint':
|
||||||
|
'Hvilken browser et link åbner i. Der findes ingen måde at spørge systemet, hvilke browsere der er installeret, så valget er et forsøg: virker det ikke på denne maskine, åbner linket helt normalt. I praksis virker det på telefonen og sjældent på en Mac eller pc.',
|
||||||
|
'link.opener': 'Åbn links i',
|
||||||
|
'link.openerDefault': 'Systemets standard',
|
||||||
|
'link.openerSafari': 'Safari',
|
||||||
|
'link.openerChrome': 'Chrome',
|
||||||
|
'link.openerFirefox': 'Firefox',
|
||||||
|
'link.openerEdge': 'Edge',
|
||||||
|
'link.openerCustom': 'Min egen adresse …',
|
||||||
|
'link.template': 'Skema',
|
||||||
|
'link.templateHint':
|
||||||
|
'{url} bliver til adressen som den er, {encoded} til den som parameterværdi, og {stripped} til den uden http foran.',
|
||||||
|
'link.try': 'Prøv det',
|
||||||
|
'link.tryHint': 'Åbner example.dk, så du kan se, om det virker her.',
|
||||||
'beacon.title': 'Tælling af installationer',
|
'beacon.title': 'Tælling af installationer',
|
||||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||||
'beacon.send': 'Send en anonym daglig melding',
|
'beacon.send': 'Send en anonym daglig melding',
|
||||||
@@ -151,6 +173,10 @@ export const da = {
|
|||||||
'notes.copyCode': 'Kopiér',
|
'notes.copyCode': 'Kopiér',
|
||||||
'notes.copied': 'Kopieret',
|
'notes.copied': 'Kopieret',
|
||||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||||
|
'notes.imageTitle': 'Billede',
|
||||||
|
'notes.imageCopy': 'Kopiér billedet',
|
||||||
|
'notes.imageCopied': 'Kopieret',
|
||||||
|
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.code': 'Kode',
|
'notes.code': 'Kode',
|
||||||
'notes.showSource': 'Vis kilden (Markdown)',
|
'notes.showSource': 'Vis kilden (Markdown)',
|
||||||
@@ -189,6 +215,7 @@ export const da = {
|
|||||||
'palette.project': 'Projekt',
|
'palette.project': 'Projekt',
|
||||||
'palette.task': 'Opgave',
|
'palette.task': 'Opgave',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Indstilling',
|
||||||
'nav.today': 'I dag',
|
'nav.today': 'I dag',
|
||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
@@ -199,7 +226,7 @@ export const da = {
|
|||||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||||
'nav.main': 'Hovedmenu',
|
'nav.main': 'Hovedmenu',
|
||||||
'nav.search': 'Søg',
|
'nav.search': 'Søg',
|
||||||
'nav.searchLong': 'Søg i opgaver og projekter',
|
'nav.searchLong': 'Søg i opgaver, noter, projekter og indstillinger',
|
||||||
'nav.noResults': 'Ingen resultater.',
|
'nav.noResults': 'Ingen resultater.',
|
||||||
'nav.showSidebar': 'Vis sidebjælken',
|
'nav.showSidebar': 'Vis sidebjælken',
|
||||||
'nav.hideSidebar': 'Skjul sidebjælken',
|
'nav.hideSidebar': 'Skjul sidebjælken',
|
||||||
@@ -280,6 +307,8 @@ export const da = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Indstillinger',
|
'settings.title': 'Indstillinger',
|
||||||
|
'settings.search': 'Søg i indstillinger',
|
||||||
|
'settings.searchNone': 'Ingen indstilling hedder det.',
|
||||||
'settings.tab.account': 'Konto',
|
'settings.tab.account': 'Konto',
|
||||||
'settings.tab.notifications': 'Notifikationer',
|
'settings.tab.notifications': 'Notifikationer',
|
||||||
'settings.tab.integrations': 'Integrationer',
|
'settings.tab.integrations': 'Integrationer',
|
||||||
@@ -884,6 +913,14 @@ export const da = {
|
|||||||
'project.canEdit': 'Kan redigere',
|
'project.canEdit': 'Kan redigere',
|
||||||
'project.canView': 'Kan kun se',
|
'project.canView': 'Kan kun se',
|
||||||
'project.invite': 'Inviter',
|
'project.invite': 'Inviter',
|
||||||
|
'project.pickPerson': 'Vælg en person…',
|
||||||
|
'project.leave': 'Forlad projektet',
|
||||||
|
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
|
||||||
|
'project.roleForPicked': 'Rolle for den valgte',
|
||||||
|
'project.shareWith': 'Del',
|
||||||
|
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||||
|
'project.invited': 'Inviteret, ikke kommet endnu',
|
||||||
|
'project.withdrawInvite': 'Træk invitationen til {email} tilbage',
|
||||||
'project.inviteByEmail': 'Inviter via e-mail',
|
'project.inviteByEmail': 'Inviter via e-mail',
|
||||||
'project.emailAddress': 'e-mailadresse',
|
'project.emailAddress': 'e-mailadresse',
|
||||||
'project.role': 'Rolle',
|
'project.role': 'Rolle',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const en = {
|
|||||||
'notif.assigned': '{name} gave you a task',
|
'notif.assigned': '{name} gave you a task',
|
||||||
'notif.noteChanged': '{name} edited a note',
|
'notif.noteChanged': '{name} edited a note',
|
||||||
'notif.noteShared': '{name} shared a note with you',
|
'notif.noteShared': '{name} shared a note with you',
|
||||||
|
'notif.projectShared': '{name} shared a project with you',
|
||||||
'notif.mention': '{name} mentioned you in a note',
|
'notif.mention': '{name} mentioned you in a note',
|
||||||
'notif.dailyPlan': "Today's plan",
|
'notif.dailyPlan': "Today's plan",
|
||||||
'notif.update': 'A new version is out',
|
'notif.update': 'A new version is out',
|
||||||
@@ -93,6 +94,10 @@ export const en = {
|
|||||||
'ai.planHint':
|
'ai.planHint':
|
||||||
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
|
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
|
||||||
'ai.planHour': 'At',
|
'ai.planHour': 'At',
|
||||||
|
'ai.planMade': 'made at {time}',
|
||||||
|
'ai.planAgain': 'Make it again',
|
||||||
|
'ai.planMake': "Make today's plan",
|
||||||
|
'ai.planDismiss': 'Hide the plan for today',
|
||||||
'ai.planNow': 'Send it now',
|
'ai.planNow': 'Send it now',
|
||||||
'ai.planSent': 'Sent — look in the bell.',
|
'ai.planSent': 'Sent — look in the bell.',
|
||||||
'ai.planNothing': 'There is nothing to plan for today.',
|
'ai.planNothing': 'There is nothing to plan for today.',
|
||||||
@@ -102,6 +107,23 @@ export const en = {
|
|||||||
'ai.skip': 'No thanks',
|
'ai.skip': 'No thanks',
|
||||||
'ai.applied': 'Done.',
|
'ai.applied': 'Done.',
|
||||||
'ai.allDone': 'Nothing left to decide on.',
|
'ai.allDone': 'Nothing left to decide on.',
|
||||||
|
// Which browser links open in. See linkopen.js for why this is an attempt and
|
||||||
|
// not a promise: a web page cannot see which browsers exist.
|
||||||
|
'link.title': 'Links',
|
||||||
|
'link.hint':
|
||||||
|
'Which browser a link opens in. There is no way to ask the system which browsers are installed, so the choice is an attempt: if it does not work on this machine, the link opens the usual way. In practice it works on a phone and rarely on a Mac or PC.',
|
||||||
|
'link.opener': 'Open links in',
|
||||||
|
'link.openerDefault': 'The system default',
|
||||||
|
'link.openerSafari': 'Safari',
|
||||||
|
'link.openerChrome': 'Chrome',
|
||||||
|
'link.openerFirefox': 'Firefox',
|
||||||
|
'link.openerEdge': 'Edge',
|
||||||
|
'link.openerCustom': 'My own address …',
|
||||||
|
'link.template': 'Scheme',
|
||||||
|
'link.templateHint':
|
||||||
|
'{url} becomes the address as it is, {encoded} the address as a parameter value, and {stripped} the address without the http in front.',
|
||||||
|
'link.try': 'Try it',
|
||||||
|
'link.tryHint': 'Opens example.dk, so you can see whether it works here.',
|
||||||
'beacon.title': 'Counting installations',
|
'beacon.title': 'Counting installations',
|
||||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||||
'beacon.send': 'Send an anonymous daily report',
|
'beacon.send': 'Send an anonymous daily report',
|
||||||
@@ -148,6 +170,10 @@ export const en = {
|
|||||||
'notes.copyCode': 'Copy',
|
'notes.copyCode': 'Copy',
|
||||||
'notes.copied': 'Copied',
|
'notes.copied': 'Copied',
|
||||||
'notes.copyFailed': 'Could not copy',
|
'notes.copyFailed': 'Could not copy',
|
||||||
|
'notes.imageTitle': 'Image',
|
||||||
|
'notes.imageCopy': 'Copy the image',
|
||||||
|
'notes.imageCopied': 'Copied',
|
||||||
|
'notes.imageOpen': 'Open in a new tab',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.code': 'Code',
|
'notes.code': 'Code',
|
||||||
'notes.showSource': 'Show the source (Markdown)',
|
'notes.showSource': 'Show the source (Markdown)',
|
||||||
@@ -186,6 +212,7 @@ export const en = {
|
|||||||
'palette.project': 'Project',
|
'palette.project': 'Project',
|
||||||
'palette.task': 'Task',
|
'palette.task': 'Task',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Setting',
|
||||||
'nav.today': 'Today',
|
'nav.today': 'Today',
|
||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
@@ -196,7 +223,7 @@ export const en = {
|
|||||||
'nav.signOutConfirm': 'Click again to sign out',
|
'nav.signOutConfirm': 'Click again to sign out',
|
||||||
'nav.main': 'Main menu',
|
'nav.main': 'Main menu',
|
||||||
'nav.search': 'Search',
|
'nav.search': 'Search',
|
||||||
'nav.searchLong': 'Search tasks and projects',
|
'nav.searchLong': 'Search tasks, notes, projects and settings',
|
||||||
'nav.noResults': 'Nothing found.',
|
'nav.noResults': 'Nothing found.',
|
||||||
'nav.showSidebar': 'Show the sidebar',
|
'nav.showSidebar': 'Show the sidebar',
|
||||||
'nav.hideSidebar': 'Hide the sidebar',
|
'nav.hideSidebar': 'Hide the sidebar',
|
||||||
@@ -277,6 +304,8 @@ export const en = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
|
'settings.search': 'Search settings',
|
||||||
|
'settings.searchNone': 'No setting goes by that name.',
|
||||||
'settings.tab.account': 'Account',
|
'settings.tab.account': 'Account',
|
||||||
'settings.tab.notifications': 'Notifications',
|
'settings.tab.notifications': 'Notifications',
|
||||||
'settings.tab.integrations': 'Integrations',
|
'settings.tab.integrations': 'Integrations',
|
||||||
@@ -878,6 +907,14 @@ export const en = {
|
|||||||
'project.canEdit': 'Can edit',
|
'project.canEdit': 'Can edit',
|
||||||
'project.canView': 'Can only view',
|
'project.canView': 'Can only view',
|
||||||
'project.invite': 'Invite',
|
'project.invite': 'Invite',
|
||||||
|
'project.pickPerson': 'Pick somebody…',
|
||||||
|
'project.leave': 'Leave the project',
|
||||||
|
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
|
||||||
|
'project.roleForPicked': 'Role for the person picked',
|
||||||
|
'project.shareWith': 'Share',
|
||||||
|
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||||
|
'project.invited': 'Invited, not arrived yet',
|
||||||
|
'project.withdrawInvite': 'Withdraw the invitation to {email}',
|
||||||
'project.inviteByEmail': 'Invite by email',
|
'project.inviteByEmail': 'Invite by email',
|
||||||
'project.emailAddress': 'email address',
|
'project.emailAddress': 'email address',
|
||||||
'project.role': 'Role',
|
'project.role': 'Role',
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Hvor hver indstilling står, så den kan findes ved at søge efter den.
|
||||||
|
*
|
||||||
|
* Indstillingerne er ti sider med en snes afsnit i alt, og der var ingen vej til
|
||||||
|
* et bestemt af dem andet end at huske, hvilken fane det lå under. ⌘K kunne
|
||||||
|
* opgaver, projekter og noter — altså alt det, man selv har skrevet, og intet af
|
||||||
|
* det, programmet består af.
|
||||||
|
*
|
||||||
|
* Ét indeks og ikke to søgninger. Feltet øverst i indstillingerne og paletten
|
||||||
|
* stiller det samme spørgsmål, og to lister over de samme afsnit ville være to
|
||||||
|
* steder at glemme et nyt et.
|
||||||
|
*
|
||||||
|
* Overskrifterne står ikke her. Hver post peger på den nøgle, afsnittets egen
|
||||||
|
* overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig
|
||||||
|
* selv — og søgningen kan begge sprog uden at kende nogen af dem.
|
||||||
|
*
|
||||||
|
* `words` er derimod skrevet her, og det er med vilje: det er søgeord, ingen får
|
||||||
|
* at se. De er de tekniske navne — `imap`, `webcal`, `2fa`, `webhook` — som er de
|
||||||
|
* samme på dansk og engelsk, og som er dem, man taster, når man leder efter det,
|
||||||
|
* man kender andetsteds fra. Det, der skal oversættes, er overskriften, og den
|
||||||
|
* ligger i sprogfilen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hver post: siden, ankeret på den, overskriftens nøgle, fanens nøgle. */
|
||||||
|
export const SETTINGS = [
|
||||||
|
// Konto
|
||||||
|
{ path: '/indstillinger', anchor: 'profil', key: 'account.profile', tab: 'settings.tab.account', words: 'profil profile navn name email mail tidszone timezone sprog language' },
|
||||||
|
{ path: '/indstillinger', anchor: 'udseende', key: 'account.appearance', tab: 'settings.tab.account', words: 'tema theme dark light farve color' },
|
||||||
|
{ path: '/indstillinger', anchor: 'look', key: 'account.look', tab: 'settings.tab.account', words: 'serif terminal' },
|
||||||
|
{ path: '/indstillinger', anchor: 'stoerrelser', key: 'account.sizes', tab: 'settings.tab.account', words: 'skrift font zoom' },
|
||||||
|
{ path: '/indstillinger', anchor: 'links', key: 'link.title', tab: 'settings.tab.account', words: 'browser safari chrome firefox' },
|
||||||
|
{ path: '/indstillinger', anchor: 'adgangskode', key: 'account.password', tab: 'settings.tab.account', words: 'password kodeord' },
|
||||||
|
{ path: '/indstillinger', anchor: 'to-faktor', key: 'account.totp', tab: 'settings.tab.account', words: '2fa totp authenticator to-faktor' },
|
||||||
|
{ path: '/indstillinger', anchor: 'passkey', key: 'passkey.title', tab: 'settings.tab.account', words: 'passkey webauthn touch id face' },
|
||||||
|
{ path: '/indstillinger', anchor: 'enheder', key: 'account.devices', tab: 'settings.tab.account', words: 'session sessioner devices log ud' },
|
||||||
|
{ path: '/indstillinger', anchor: 'stoette', key: 'account.support', tab: 'settings.tab.account', words: 'donate kaffe coffee' },
|
||||||
|
|
||||||
|
// Notifikationer
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||||
|
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||||
|
|
||||||
|
// Integrationer
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'gmail', key: 'int.gmail', tab: 'settings.tab.integrations', words: 'gmail google oauth' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'kalender', key: 'int.calendar', tab: 'settings.tab.integrations', words: 'google calendar' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'abonnementer', key: 'int.subscriptions', tab: 'settings.tab.integrations', words: 'ics webcal abonnement subscribe' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'kalenderfeed', key: 'int.feed', tab: 'settings.tab.integrations', words: 'ical ics feed apple thunderbird' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'mail-til-opgave', key: 'int.mailToTask', tab: 'settings.tab.integrations', words: 'mail email forward videresend' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'krog', key: 'int.hookToTask', tab: 'settings.tab.integrations', words: 'webhook hook curl shortcut genvej' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'caldav-server', key: 'int.caldav', tab: 'settings.tab.integrations', words: 'caldav reminders' },
|
||||||
|
{ path: '/indstillinger/integrationer', anchor: 'postkasser', key: 'int.mailboxes', tab: 'settings.tab.integrations', words: 'imap icloud fastmail postkasse' },
|
||||||
|
|
||||||
|
// AI
|
||||||
|
{ path: '/indstillinger/ai', anchor: 'ai', key: 'ai.title', tab: 'settings.tab.ai', words: 'ai model anthropic openai claude gpt gemini api key' },
|
||||||
|
{ path: '/indstillinger/ai', anchor: 'ugentlig', key: 'ai.weekly', tab: 'settings.tab.ai', words: 'uge weekly opsamling' },
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
{ path: '/indstillinger/tokens', anchor: 'tokens', key: 'tokens.title', tab: 'settings.tab.tokens', words: 'token api mcp connector bearer' },
|
||||||
|
|
||||||
|
// Brugere, historik, fejl — kun for administratorer
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'invitation', key: 'users.invite', tab: 'settings.tab.users', words: 'invite invitation bruger user', admin: true },
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'afventende', key: 'users.pending', tab: 'settings.tab.users', words: 'pending afventer', admin: true },
|
||||||
|
{ path: '/indstillinger/brugere', anchor: 'konti', key: 'users.accounts', tab: 'settings.tab.users', words: 'konti accounts admin', admin: true },
|
||||||
|
{ path: '/indstillinger/historik', anchor: 'historik', key: 'history.title', tab: 'settings.tab.history', words: 'aktivitet activity log', admin: true },
|
||||||
|
{ path: '/indstillinger/fejl', anchor: 'fejl', key: 'errors.title', tab: 'settings.tab.errors', words: 'error 500 log', admin: true },
|
||||||
|
|
||||||
|
// Data
|
||||||
|
{ path: '/indstillinger/data', anchor: 'import', key: 'data.import', tab: 'settings.tab.data', words: 'import todoist csv json' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'faerdige', key: 'done.title', tab: 'settings.tab.data', words: 'done completed oprydning' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'papirkurv', key: 'data.trash', tab: 'settings.tab.data', words: 'trash slettet deleted' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'sikkerhedskopier', key: 'data.backups', tab: 'settings.tab.data', words: 'backup sikkerhedskopi', admin: true },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'eksport', key: 'data.export', tab: 'settings.tab.data', words: 'export eksport zip markdown' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'skabeloner', key: 'data.templates', tab: 'settings.tab.data', words: 'template skabelon' },
|
||||||
|
{ path: '/indstillinger/data', anchor: 'beacon', key: 'beacon.title', tab: 'settings.tab.data', words: 'beacon tæller collector telemetri' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samme foldning som serverens `fold`-kolonne.
|
||||||
|
*
|
||||||
|
* `remove_diacritics` i SQLite gør intet ved ø, æ og å — de er egne bogstaver og
|
||||||
|
* ikke accenter — og en dansk søgning, der ikke kan finde "sikkerhedskopier" fra
|
||||||
|
* "sikkerhedskopier" skrevet på et fremmed tastatur, er brudt for sine egne
|
||||||
|
* brugere. Derfor samme oversættelse her som der, og `normalize` ovenpå til alt
|
||||||
|
* det, Unicode faktisk regner for accenter.
|
||||||
|
*/
|
||||||
|
export function fold(text) {
|
||||||
|
return (text ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ø/g, 'o')
|
||||||
|
.replace(/æ/g, 'ae')
|
||||||
|
.replace(/å/g, 'aa')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indstillingerne, der svarer til det skrevne.
|
||||||
|
*
|
||||||
|
* Alle ordene skal findes, hvert af dem som begyndelsen af et ord — "sik kop"
|
||||||
|
* finder sikkerhedskopierne. Den, der søger, skriver forfra; en søgning, der også
|
||||||
|
* matcher midt i et ord, finder "opgave" i "hovedopgaven" og larmer.
|
||||||
|
*
|
||||||
|
* `t` gives med udefra frem for at blive importeret. Det holder filen fri af
|
||||||
|
* Sveltes runes, så den kan læses af en almindelig prøve uden en komponent
|
||||||
|
* omkring sig.
|
||||||
|
*/
|
||||||
|
export function findSettings(query, { t, isAdmin = false, limit = 8 } = {}) {
|
||||||
|
const terms = fold(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (!terms.length) return [];
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
for (const entry of SETTINGS) {
|
||||||
|
if (entry.admin && !isAdmin) continue;
|
||||||
|
|
||||||
|
const title = t(entry.key);
|
||||||
|
const tab = t(entry.tab);
|
||||||
|
const words = fold(`${title} ${tab} ${entry.words}`)
|
||||||
|
.split(/[\s/·,-]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Alle ordene skal findes, og hvert af dem forfra i et ord: "sikker" finder
|
||||||
|
// sikkerhedskopierne, "kopier" gør ikke. Den, der søger, skriver
|
||||||
|
// begyndelsen af det, den leder efter — en søgning, der også rammer midt i
|
||||||
|
// et ord, finder "opgave" inde i "hovedopgaven" og larmer.
|
||||||
|
if (!terms.every((term) => words.some((word) => word.startsWith(term)))) continue;
|
||||||
|
|
||||||
|
// Det, der hedder det, man søgte efter, før det, der blot nævner det. Uden
|
||||||
|
// den her ligger rækkefølgen fast som filens, og "beacon" ville få hele
|
||||||
|
// Data-siden ned over sig, fordi den står først.
|
||||||
|
const folded = fold(title);
|
||||||
|
const rank = folded.startsWith(terms[0]) ? 0 : folded.includes(terms[0]) ? 1 : 2;
|
||||||
|
hits.push({ rank, entry: { ...entry, title, tab, href: `${entry.path}#${entry.anchor}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((hit) => hit.entry);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { setBadge } from '$lib/badge.js';
|
import { setBadge } from '$lib/badge.js';
|
||||||
|
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -55,6 +56,37 @@
|
|||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||||
|
*
|
||||||
|
* Ét sted frem for ét pr. komponent. Links står i opgavetitler, i
|
||||||
|
* beskrivelser, i noter og i indstillingerne, og en regel, der skulle
|
||||||
|
* gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
|
||||||
|
*
|
||||||
|
* Kun udadgående. Programmets egne ruter går gennem SvelteKit og skal blive
|
||||||
|
* ved med at gøre det: en fane mere til ens egen notesside er ikke noget,
|
||||||
|
* nogen har bedt om.
|
||||||
|
*
|
||||||
|
* Er der ikke valgt nogen browser — hvilket er udgangspunktet — bliver
|
||||||
|
* klikket ikke rørt, og systemet gør, som det plejer.
|
||||||
|
*/
|
||||||
|
function onLinkClick(event) {
|
||||||
|
if (event.defaultPrevented || event.button !== 0) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
if (!storedOpener().id) return;
|
||||||
|
|
||||||
|
const link = event.target?.closest?.('a[href]');
|
||||||
|
if (!link || link.target === '_self') return;
|
||||||
|
const href = link.getAttribute('href') ?? '';
|
||||||
|
if (!/^https?:\/\//i.test(href)) return;
|
||||||
|
// Vores egen adresse er ikke et link ud af programmet, uanset hvor den
|
||||||
|
// står — en fane mere til den side, man allerede er på, er ingen hjælp.
|
||||||
|
if (new URL(href, location.href).origin === location.origin) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
openInChosenBrowser(href);
|
||||||
|
}
|
||||||
|
|
||||||
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
|
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
|
||||||
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
|
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
|
||||||
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
|
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
|
||||||
@@ -125,7 +157,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window {onkeydown} />
|
<svelte:window
|
||||||
|
onclick={onLinkClick} {onkeydown} />
|
||||||
|
|
||||||
{#if app.loading}
|
{#if app.loading}
|
||||||
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
@@ -17,6 +18,72 @@
|
|||||||
).padStart(2, '0')}`;
|
).padStart(2, '0')}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dagens plan, dér hvor man kigger, når man starter dagen.
|
||||||
|
*
|
||||||
|
* Hentet og ikke regnet forfra: det er den samme tekst, beskeden i morges bar.
|
||||||
|
* En plan, der siger noget andet, når man kigger på den, end den sagde, da den
|
||||||
|
* kom, er værre end begge dele hver for sig — og en model pr. sidevisning er
|
||||||
|
* dyr for noget, der ikke har ændret sig.
|
||||||
|
*
|
||||||
|
* Lukket for i dag frem for slettet. Kortet er en påmindelse, ikke en opgave,
|
||||||
|
* og den, der har læst den, skal kunne få den af vejen uden at miste den —
|
||||||
|
* derfor står lukningen i localStorage med dagens dato og ikke på kontoen.
|
||||||
|
*/
|
||||||
|
let plan = $state(null);
|
||||||
|
let planBusy = $state(false);
|
||||||
|
let planError = $state('');
|
||||||
|
const DISMISS_KEY = 'verdande:plan-dismissed';
|
||||||
|
|
||||||
|
let dismissed = $state(
|
||||||
|
typeof localStorage !== 'undefined' && localStorage.getItem(DISMISS_KEY) === isoToday
|
||||||
|
);
|
||||||
|
|
||||||
|
function dismissPlan() {
|
||||||
|
dismissed = true;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, isoToday);
|
||||||
|
} catch {
|
||||||
|
// Privat vindue; kortet er væk til næste indlæsning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePlan() {
|
||||||
|
if (planBusy) return;
|
||||||
|
planBusy = true;
|
||||||
|
planError = '';
|
||||||
|
try {
|
||||||
|
// Stille: man står og kigger på den. En besked om noget, man har foran
|
||||||
|
// sig, er den slags, der lærer folk at ignorere klokken.
|
||||||
|
const made = await api.planNow({ silent: true });
|
||||||
|
plan = { ...plan, plan: made.plan, plan_at: made.plan_at };
|
||||||
|
dismissed = false;
|
||||||
|
} catch (e) {
|
||||||
|
planError = humanMessage(e);
|
||||||
|
} finally {
|
||||||
|
planBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.planSettings()
|
||||||
|
.then((p) => (plan = p))
|
||||||
|
.catch(() => {
|
||||||
|
// Et kort, der ikke kunne hentes, må ikke stå i vejen for listen.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const planMade = $derived(
|
||||||
|
plan?.plan_at
|
||||||
|
? new Date(plan.plan_at * 1000).toLocaleTimeString(tag(), {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await api.today();
|
const data = await api.today();
|
||||||
app.tasks = [...data.overdue, ...data.today];
|
app.tasks = [...data.overdue, ...data.today];
|
||||||
@@ -54,6 +121,33 @@
|
|||||||
|
|
||||||
<QuickAdd />
|
<QuickAdd />
|
||||||
|
|
||||||
|
{#if plan?.plan && !dismissed}
|
||||||
|
<!-- Teksten som den blev sendt, og klokkeslættet den blev lavet: en plan fra
|
||||||
|
klokken syv er en anden slags oplysning klokken fire end klokken otte. -->
|
||||||
|
<section class="plan">
|
||||||
|
<header>
|
||||||
|
<h2>{t('ai.plan')}</h2>
|
||||||
|
{#if planMade}<span class="made">{t('ai.planMade', { time: planMade })}</span>{/if}
|
||||||
|
<button class="close" onclick={dismissPlan} aria-label={t('ai.planDismiss')}>×</button>
|
||||||
|
</header>
|
||||||
|
<p>{plan.plan}</p>
|
||||||
|
<button class="again" onclick={makePlan} disabled={planBusy}>
|
||||||
|
{planBusy ? t('ai.thinking') : t('ai.planAgain')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
{:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)}
|
||||||
|
<!-- Ingen plan i dag: enten er beskeden slået fra, eller også er timen ikke
|
||||||
|
kommet endnu. Knappen frem for ingenting, så den, der har slået
|
||||||
|
morgenbeskeden fra, stadig kan bede om planen. Kun når der er noget at
|
||||||
|
lægge en plan om — en tom dag har ikke brug for en knap. -->
|
||||||
|
<p class="planask">
|
||||||
|
<button class="again" onclick={makePlan} disabled={planBusy}>
|
||||||
|
{planBusy ? t('ai.thinking') : t('ai.planMake')}
|
||||||
|
</button>
|
||||||
|
{#if planError}<span class="planerror">{planError}</span>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if liveOverdue.length}
|
{#if liveOverdue.length}
|
||||||
<section>
|
<section>
|
||||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||||
@@ -93,6 +187,77 @@
|
|||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kortet ligger mellem feltet og listen: under det, man skriver i, og over det,
|
||||||
|
man skal se på. Dæmpet frem for fremhævet — det er en påmindelse om, hvad
|
||||||
|
dagen indeholder, ikke en opgave, der skal laves. */
|
||||||
|
.plan {
|
||||||
|
margin: 0 0 var(--s5);
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan h2 {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan .made {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan .close {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linjeskift bevaret: planen er to-tre linjer, og de er skrevet som linjer. */
|
||||||
|
.plan p {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.again {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.again:disabled {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planask {
|
||||||
|
margin: 0 0 var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planerror {
|
||||||
|
margin-left: var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
* is six places for them to drift apart.
|
* is six places for them to drift apart.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -37,6 +39,69 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Søgningen efter en indstilling.
|
||||||
|
*
|
||||||
|
* Ti faner med en snes afsnit i alt, og den eneste vej til et bestemt af dem
|
||||||
|
* var at huske, hvilken fane det lå under. Feltet her og ⌘K spørger det samme
|
||||||
|
* indeks — se settingsindex.js — så et nyt afsnit kun skal skrives ét sted for
|
||||||
|
* at kunne findes to.
|
||||||
|
*/
|
||||||
|
let query = $state('');
|
||||||
|
let hits = $derived(findSettings(query, { t, isAdmin: app.user?.is_admin ?? false }));
|
||||||
|
|
||||||
|
function go(hit) {
|
||||||
|
query = '';
|
||||||
|
goto(hit.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
query = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && hits.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
go(hits[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruller hen til afsnittet, når adressen nævner det, og blinker det.
|
||||||
|
*
|
||||||
|
* Browserens egen springen til et anker kan ikke bruges her: siden hentes af
|
||||||
|
* ruteren, og afsnittet findes først, når dens data er hjemme — så et spring,
|
||||||
|
* der sker med det samme, lander på en side, der endnu er tom. Derfor prøves
|
||||||
|
* det, indtil elementet er der, og gives op efter to sekunder frem for at
|
||||||
|
* prøve for evigt.
|
||||||
|
*
|
||||||
|
* Og blinket er ikke pynt. Man er landet et sted, man ikke selv rullede hen
|
||||||
|
* til, midt i en side med otte afsnit, der ligner hinanden; uden det skal man
|
||||||
|
* læse sig frem til, hvor man er.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const anchor = $page.url.hash.slice(1);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
let tries = 0;
|
||||||
|
let timer;
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const look = () => {
|
||||||
|
const el = document.getElementById(anchor);
|
||||||
|
if (!el) {
|
||||||
|
if (tries++ < 40) timer = setTimeout(look, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' });
|
||||||
|
el.classList.add('landed');
|
||||||
|
timer = setTimeout(() => el.classList.remove('landed'), 1600);
|
||||||
|
};
|
||||||
|
look();
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
@@ -44,6 +109,34 @@
|
|||||||
<h1>{t('settings.title')}</h1>
|
<h1>{t('settings.title')}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="find">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={query}
|
||||||
|
{onkeydown}
|
||||||
|
placeholder={t('settings.search')}
|
||||||
|
aria-label={t('settings.search')}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
{#if query.trim()}
|
||||||
|
{#if hits.length}
|
||||||
|
<ul class="hits">
|
||||||
|
{#each hits as hit (hit.href)}
|
||||||
|
<li>
|
||||||
|
<button onclick={() => go(hit)}>
|
||||||
|
<span class="where">{hit.tab}</span>
|
||||||
|
<span class="what">{hit.title}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="nohits">{t('settings.searchNone')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav aria-label={t('settings.title')}>
|
<nav aria-label={t('settings.title')}>
|
||||||
{#each sections as section (section.href)}
|
{#each sections as section (section.href)}
|
||||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||||
@@ -109,9 +202,108 @@
|
|||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.find {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.find input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.find input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Over indholdet frem for at skubbe det ned: listen kommer og går for hvert
|
||||||
|
bogstav, og en side, der hopper under det, man læser, er svær at sigte i. */
|
||||||
|
.hits {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: var(--s1) 0 0;
|
||||||
|
padding: var(--s1);
|
||||||
|
list-style: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hits button {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hits button:hover {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fanen står før navnet, fordi svaret på "hvor ligger det?" er halvdelen af
|
||||||
|
det, man søgte efter. */
|
||||||
|
.where {
|
||||||
|
flex: none;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nohits {
|
||||||
|
margin: var(--s2) 0 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings :global(h3[id]) {
|
||||||
|
scroll-margin-top: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinket, når man er landet et sted, man ikke selv rullede hen til. Kanten og
|
||||||
|
ikke baggrunden: en baggrund, der skifter, gør teksten svær at læse i netop
|
||||||
|
det øjeblik, man er kommet for at læse den. */
|
||||||
|
.settings :global(.landed) {
|
||||||
|
animation: landed 1.6s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes landed {
|
||||||
|
0%,
|
||||||
|
60% {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 2px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.settings :global(.landed) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- shared form chrome, for the child routes ------------------------------- */
|
/* --- shared form chrome, for the child routes ------------------------------- */
|
||||||
|
|
||||||
.settings :global(section.panel) {
|
.settings :global(section.panel) {
|
||||||
|
/* Der er en fast linje øverst på skærmen. Uden den her lander afsnittets
|
||||||
|
overskrift bag den, når man springer hertil fra en søgning. */
|
||||||
|
scroll-margin-top: var(--s5);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -151,7 +343,8 @@
|
|||||||
|
|
||||||
.settings :global(.field input),
|
.settings :global(.field input),
|
||||||
.settings :global(.field select),
|
.settings :global(.field select),
|
||||||
.settings :global(.field textarea) {
|
.settings :global(.field textarea),
|
||||||
|
.settings :global(.field output) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--s2) var(--s3);
|
padding: var(--s2) var(--s3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
@@ -168,6 +361,22 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
|
||||||
|
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
|
||||||
|
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
|
||||||
|
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
|
||||||
|
er tekst, der ombryder.
|
||||||
|
|
||||||
|
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
|
||||||
|
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
|
||||||
|
.settings :global(.field output) {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* The field error sits under its own input rather than in a summary at the top:
|
/* The field error sits under its own input rather than in a summary at the top:
|
||||||
the server answers with a map of field to message, and putting each one where
|
the server answers with a map of field to message, and putting each one where
|
||||||
it belongs is the whole point of that shape. */
|
it belongs is the whole point of that shape. */
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
/** Konto: who you are, your password, and the second factor. */
|
/** Konto: who you are, your password, and the second factor. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import {
|
||||||
|
OPENERS,
|
||||||
|
stored as storedOpener,
|
||||||
|
save as saveOpenerChoice,
|
||||||
|
open as openLink
|
||||||
|
} from '$lib/linkopen.js';
|
||||||
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
|
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
|
||||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||||
import { supported, register } from '$lib/passkey.js';
|
import { supported, register } from '$lib/passkey.js';
|
||||||
@@ -8,6 +14,20 @@
|
|||||||
|
|
||||||
// --- profile ---------------------------------------------------------------
|
// --- profile ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Hvilken browser links åbner i. På apparatet, ikke på kontoen: `firefox://`
|
||||||
|
// virker på telefonen og gør ingenting på Mac'en, så et valg, der fulgte
|
||||||
|
// kontoen, ville være forkert det ene af de to steder hver gang.
|
||||||
|
let opener = $state(storedOpener());
|
||||||
|
|
||||||
|
function saveOpener(choice) {
|
||||||
|
opener = choice;
|
||||||
|
saveOpenerChoice(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseOpener(id) {
|
||||||
|
saveOpener({ id, template: id === 'custom' ? opener.template : '' });
|
||||||
|
}
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let timezone = $state('');
|
let timezone = $state('');
|
||||||
let locale = $state('da');
|
let locale = $state('da');
|
||||||
@@ -256,7 +276,7 @@
|
|||||||
*/
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="profil">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.profile')}</h2>
|
<h2>{t('account.profile')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -312,7 +332,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="udseende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.appearance')}</h2>
|
<h2>{t('account.appearance')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -346,7 +366,7 @@
|
|||||||
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||||
they are separate settings because they are separate questions. -->
|
they are separate settings because they are separate questions. -->
|
||||||
<div class="looks">
|
<div class="looks">
|
||||||
<h3>{t('account.look')}</h3>
|
<h3 id="look">{t('account.look')}</h3>
|
||||||
<p class="hint">{t('account.lookHint')}</p>
|
<p class="hint">{t('account.lookHint')}</p>
|
||||||
<div class="look-row">
|
<div class="look-row">
|
||||||
{#each LOOKS as option (option.id)}
|
{#each LOOKS as option (option.id)}
|
||||||
@@ -370,7 +390,7 @@
|
|||||||
Separate on purpose — one is a glance down a list of names, the other is a
|
Separate on purpose — one is a glance down a list of names, the other is a
|
||||||
paragraph you sit and read. -->
|
paragraph you sit and read. -->
|
||||||
<div class="sizes">
|
<div class="sizes">
|
||||||
<h3>{t('account.sizes')}</h3>
|
<h3 id="stoerrelser">{t('account.sizes')}</h3>
|
||||||
<p class="hint">{t('account.sizesHint')}</p>
|
<p class="hint">{t('account.sizesHint')}</p>
|
||||||
<div class="size-row">
|
<div class="size-row">
|
||||||
<div class="size-field">
|
<div class="size-field">
|
||||||
@@ -401,7 +421,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="links">
|
||||||
|
<header>
|
||||||
|
<h2>{t('link.title')}</h2>
|
||||||
|
<p class="hint">{t('link.hint')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="opener">{t('link.opener')}</label>
|
||||||
|
<select id="opener" value={opener.id} onchange={(e) => chooseOpener(e.currentTarget.value)}>
|
||||||
|
{#each OPENERS as option (option.id)}
|
||||||
|
<option value={option.id}>{t(option.label)}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="custom">{t('link.openerCustom')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if opener.id === 'custom'}
|
||||||
|
<div class="field">
|
||||||
|
<label for="openertpl">{t('link.template')}</label>
|
||||||
|
<input
|
||||||
|
id="openertpl"
|
||||||
|
class="mono"
|
||||||
|
value={opener.template}
|
||||||
|
placeholder="firefox://open-url?url={'{encoded}'}"
|
||||||
|
onchange={(e) => saveOpener({ id: 'custom', template: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<p class="hint">{t('link.templateHint')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if opener.id}
|
||||||
|
<!-- Et forsøg frem for et løfte. Der er ingen måde at spørge, om et skema
|
||||||
|
findes — man kan kun prøve, og så er det bedre at prøve på en adresse,
|
||||||
|
man selv har valgt, end at opdage det på et rigtigt link. -->
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={() => openLink('https://example.dk')}>
|
||||||
|
{t('link.try')}
|
||||||
|
</button>
|
||||||
|
<span class="hint">{t('link.tryHint')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="adgangskode">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.password')}</h2>
|
<h2>{t('account.password')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -443,7 +506,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="to-faktor">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.totp')}</h2>
|
<h2>{t('account.totp')}</h2>
|
||||||
<p class="hint">{t('account.totpHint')}</p>
|
<p class="hint">{t('account.totpHint')}</p>
|
||||||
@@ -553,7 +616,7 @@
|
|||||||
|
|
||||||
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||||
is a way in, and it is a thing on a device you might later want to revoke. -->
|
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||||
<section class="panel">
|
<section class="panel" id="passkey">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('passkey.title')}</h2>
|
<h2>{t('passkey.title')}</h2>
|
||||||
<p class="hint">{t('passkey.hint')}</p>
|
<p class="hint">{t('passkey.hint')}</p>
|
||||||
@@ -592,7 +655,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="enheder">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.devices')}</h2>
|
<h2>{t('account.devices')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -624,7 +687,7 @@
|
|||||||
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
||||||
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||||
snippet for why not their generated button. -->
|
snippet for why not their generated button. -->
|
||||||
<section class="panel">
|
<section class="panel" id="stoette">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.support')}</h2>
|
<h2>{t('account.support')}</h2>
|
||||||
<p class="hint">{t('account.supportHint')}</p>
|
<p class="hint">{t('account.supportHint')}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="ai">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.title')}</h2>
|
<h2>{t('ai.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if settings?.provider && settings?.has_key}
|
{#if settings?.provider && settings?.has_key}
|
||||||
<section class="panel">
|
<section class="panel" id="ugentlig">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.weekly')}</h2>
|
<h2>{t('ai.weekly')}</h2>
|
||||||
<p class="hint">{t('ai.weeklyHint')}</p>
|
<p class="hint">{t('ai.weeklyHint')}</p>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
const expires = shortDate;
|
const expires = shortDate;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="invitation">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.invite')}</h2>
|
<h2>{t('users.invite')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if invites.length}
|
{#if invites.length}
|
||||||
<section class="panel">
|
<section class="panel" id="afventende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.pending')}</h2>
|
<h2>{t('users.pending')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="konti">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.accounts')}</h2>
|
<h2>{t('users.accounts')}</h2>
|
||||||
<p class="hint">{t('users.accountsHint')}</p>
|
<p class="hint">{t('users.accountsHint')}</p>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="import">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.import')}</h2>
|
<h2>{t('data.import')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||||||
to the four that are read every day, and it belongs with the other "where did
|
to the four that are read every day, and it belongs with the other "where did
|
||||||
that go" questions rather than above them. -->
|
that go" questions rather than above them. -->
|
||||||
<section class="panel">
|
<section class="panel" id="faerdige">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('done.title')}</h2>
|
<h2>{t('done.title')}</h2>
|
||||||
<p class="hint">{t('data.doneHint')}</p>
|
<p class="hint">{t('data.doneHint')}</p>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="papirkurv">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.trash')}</h2>
|
<h2>{t('data.trash')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
||||||
is a complete copy of the database, so this panel hands out everybody's data. -->
|
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<section class="panel">
|
<section class="panel" id="sikkerhedskopier">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.backups')}</h2>
|
<h2>{t('data.backups')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="eksport">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.export')}</h2>
|
<h2>{t('data.export')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="skabeloner">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.templates')}</h2>
|
<h2>{t('data.templates')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||||||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||||
<section class="panel">
|
<section class="panel" id="beacon">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('beacon.title')}</h2>
|
<h2>{t('beacon.title')}</h2>
|
||||||
<p class="hint">{t('beacon.hint')}</p>
|
<p class="hint">{t('beacon.hint')}</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="fejl">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('errors.title')}</h2>
|
<h2>{t('errors.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="historik">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('history.title')}</h2>
|
<h2>{t('history.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -416,7 +416,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="gmail">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.gmail')}</h2>
|
<h2>{t('int.gmail')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
it has to match Google's copy exactly, and the error Google gives
|
it has to match Google's copy exactly, and the error Google gives
|
||||||
when it does not names neither value. Google afviser desuden
|
when it does not names neither value. Google afviser desuden
|
||||||
private IP-adresser over http — det skal være https. -->
|
private IP-adresser over http — det skal være https. -->
|
||||||
<input id="redirect" readonly value={client.redirect_uri} />
|
<output id="redirect">{client.redirect_uri}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={saveClient}>
|
<form onsubmit={saveClient}>
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalender">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.calendar')}</h2>
|
<h2>{t('int.calendar')}</h2>
|
||||||
<p class="hint">{t('int.calendarHint')}</p>
|
<p class="hint">{t('int.calendarHint')}</p>
|
||||||
@@ -546,7 +546,7 @@
|
|||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
|
||||||
<p class="hint">{t('int.calendarSameClient')}</p>
|
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !calendar.has_client}
|
{:else if !calendar.has_client}
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||||
{#if calendar !== null}
|
{#if calendar !== null}
|
||||||
<div class="subs">
|
<div class="subs">
|
||||||
<h3>{t('int.subscriptions')}</h3>
|
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
|
||||||
<p class="hint">{t('int.subscriptionsHint')}</p>
|
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||||
|
|
||||||
{#if calendar.subscriptions?.length}
|
{#if calendar.subscriptions?.length}
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalenderfeed">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.feed')}</h2>
|
<h2>{t('int.feed')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="feed">{t('int.address')}</label>
|
<label for="feed">{t('int.address')}</label>
|
||||||
<input id="feed" class="mono" value={feedURL} readonly />
|
<output id="feed" class="mono">{feedURL}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -665,7 +665,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="mail-til-opgave">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailToTask')}</h2>
|
<h2>{t('int.mailToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -681,7 +681,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="mail">{t('int.yourAddress')}</label>
|
<label for="mail">{t('int.yourAddress')}</label>
|
||||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
<output id="mail" class="mono">{mailAddress}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="krog">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.hookToTask')}</h2>
|
<h2>{t('int.hookToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -700,7 +700,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hook">{t('int.yourHook')}</label>
|
<label for="hook">{t('int.yourHook')}</label>
|
||||||
<input id="hook" class="mono" value={hookURL} readonly />
|
<output id="hook" class="mono">{hookURL}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.hookHow')}
|
{t('int.hookHow')}
|
||||||
</p>
|
</p>
|
||||||
@@ -711,8 +711,8 @@
|
|||||||
kroppen bare er den linje, opgaven skal hedde. -->
|
kroppen bare er den linje, opgaven skal hedde. -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hookcurl">{t('int.hookExample')}</label>
|
<label for="hookcurl">{t('int.hookExample')}</label>
|
||||||
<textarea id="hookcurl" class="mono" rows="3" readonly
|
<output id="hookcurl" class="mono"
|
||||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
|
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="caldav-server">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.caldav')}</h2>
|
<h2>{t('int.caldav')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -732,7 +732,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="caldav">{t('int.server')}</label>
|
<label for="caldav">{t('int.server')}</label>
|
||||||
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
|
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.caldavAuth')}
|
{t('int.caldavAuth')}
|
||||||
</p>
|
</p>
|
||||||
@@ -743,7 +743,7 @@
|
|||||||
rather than a second mode inside the Gmail one, because they are not the same
|
rather than a second mode inside the Gmail one, because they are not the same
|
||||||
thing wearing different clothes — one is an app you sign in through, the other
|
thing wearing different clothes — one is an app you sign in through, the other
|
||||||
is a password you hold. -->
|
is a password you hold. -->
|
||||||
<section class="panel">
|
<section class="panel" id="postkasser">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailboxes')}</h2>
|
<h2>{t('int.mailboxes')}</h2>
|
||||||
<p class="hint">{t('int.mailboxesHint')}</p>
|
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||||
@@ -817,7 +817,7 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup input[readonly] {
|
.setup output {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -884,4 +884,38 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The bullet list this was is why the page scrolled sideways on a phone. A
|
||||||
|
subscription address is one word with nothing to break at, so it cannot wrap
|
||||||
|
on its own — and the widest element on a page sets the width of the page, not
|
||||||
|
of its own line. Hence `anywhere`, which breaks mid-word when there is no
|
||||||
|
other opportunity, plus `min-width: 0` so the flex item is allowed to be
|
||||||
|
narrower than its content in the first place. */
|
||||||
|
.subs-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list .url {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list button {
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="dagens-plan">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.plan')}</h2>
|
<h2>{t('ai.plan')}</h2>
|
||||||
<p class="hint">{t('ai.planHint')}</p>
|
<p class="hint">{t('ai.planHint')}</p>
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="push">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.title')}</h2>
|
<h2>{t('push.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="seneste">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.recent')}</h2>
|
<h2>{t('push.recent')}</h2>
|
||||||
{#if unread > 0}
|
{#if unread > 0}
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if version}
|
{#if version}
|
||||||
<section class="panel">
|
<section class="panel" id="version">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.version')}</h2>
|
<h2>{t('push.version')}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
const expired = (iso) => iso && new Date(iso) < new Date();
|
const expired = (iso) => iso && new Date(iso) < new Date();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="tokens">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('tokens.title')}</h2>
|
<h2>{t('tokens.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { untrack } from 'svelte';
|
||||||
|
import { goto, replaceState } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -483,6 +484,49 @@
|
|||||||
load(query);
|
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 —
|
// 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.
|
// opens it rather than dropping the person on a list to find it again.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
let asked = $derived($page.url.searchParams.get('note'));
|
||||||
@@ -1279,7 +1323,7 @@
|
|||||||
lovet i en besked, der ikke kom af sted. -->
|
lovet i en besked, der ikke kom af sted. -->
|
||||||
<p class="invitelink">
|
<p class="invitelink">
|
||||||
{t('notes.inviteNoMail')}
|
{t('notes.inviteNoMail')}
|
||||||
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
<output>{shareInviteLink}</output>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -2216,11 +2260,23 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invitelink input {
|
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
|
||||||
|
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
|
||||||
|
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
|
||||||
|
god til — ét tryk tager hele linket. */
|
||||||
|
.invitelink output {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: var(--text-xs);
|
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addshare {
|
.addshare {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteLink = $state('');
|
let inviteLink = $state('');
|
||||||
let inviteError = $state('');
|
let inviteError = $state('');
|
||||||
|
// De to andre halvdele af delingspanelet: hvem der kan tilføjes, og hvem der er
|
||||||
|
// inviteret og ikke kommet endnu. Begge kommer fra det samme svar som
|
||||||
|
// medlemmerne, så panelet åbner færdigt i ét kald.
|
||||||
|
let candidates = $state([]);
|
||||||
|
let pending = $state([]);
|
||||||
|
let sharePick = $state('');
|
||||||
|
let pickRole = $state('editor');
|
||||||
|
|
||||||
let id = $derived($page.params.id);
|
let id = $derived($page.params.id);
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showShare && project) {
|
if (showShare && project) {
|
||||||
api.listMembers(project.id).then((r) => (members = r.members));
|
api.listMembers(project.id).then(readMembers);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,23 +519,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invite(event) {
|
// Ét sted at læse svaret, fordi der nu er tre lister i det og fire steder, der
|
||||||
event.preventDefault();
|
// henter det. Tre tildelinger skrevet fire gange er tre, der kan blive glemt.
|
||||||
|
function readMembers(r) {
|
||||||
|
members = r.members;
|
||||||
|
candidates = r.candidates ?? [];
|
||||||
|
pending = r.pending ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share(who, role) {
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
inviteLink = '';
|
inviteLink = '';
|
||||||
try {
|
try {
|
||||||
const result = await api.invite(project.id, inviteEmail, inviteRole);
|
const result = await api.invite(project.id, who, role);
|
||||||
inviteEmail = '';
|
|
||||||
if (result.link && !result.emailed) {
|
if (result.link && !result.emailed) {
|
||||||
// No mail server, or delivery failed. The link is the only way this
|
// No mail server, or delivery failed. The link is the only way this
|
||||||
// invite reaches anybody, so it is shown rather than swallowed.
|
// invite reaches anybody, so it is shown rather than swallowed.
|
||||||
inviteLink = result.link;
|
inviteLink = result.link;
|
||||||
}
|
}
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
inviteError = humanMessage(e);
|
inviteError = humanMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invite(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = inviteEmail.trim();
|
||||||
|
if (!email) return;
|
||||||
|
await share({ email }, inviteRole);
|
||||||
|
if (!inviteError) inviteEmail = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
|
||||||
|
// være en vej ud, der ikke går gennem at bede ejeren om det.
|
||||||
|
async function leave() {
|
||||||
|
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
|
||||||
|
try {
|
||||||
|
await api.removeMember(project.id, app.user.id);
|
||||||
|
// Væk fra en side, man ikke længere må se: bliver man stående, er det
|
||||||
|
// næste, siden gør, at hente den og få 404.
|
||||||
|
await goto('/');
|
||||||
|
await app.refreshProjects();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw(invite) {
|
||||||
|
try {
|
||||||
|
await api.withdrawInvite(project.id, invite.id);
|
||||||
|
readMembers(await api.listMembers(project.id));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||||
@@ -765,7 +810,7 @@
|
|||||||
class="remove"
|
class="remove"
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
await api.removeMember(project.id, member.user_id);
|
await api.removeMember(project.id, member.user_id);
|
||||||
members = (await api.listMembers(project.id)).members;
|
readMembers(await api.listMembers(project.id));
|
||||||
}}
|
}}
|
||||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||||
>
|
>
|
||||||
@@ -774,7 +819,66 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- De inviterede står ved siden af medlemmerne. Uden dem er en invitation
|
||||||
|
sendt i går usynlig: personen har ingen konto, så de er hverken
|
||||||
|
medlem eller nogen, man kan vælge — og så bliver den sendt igen. -->
|
||||||
|
{#if pending.length}
|
||||||
|
<ul class="members invited">
|
||||||
|
{#each pending as invite (invite.id)}
|
||||||
|
<li>
|
||||||
|
<span class="avatar waiting">@</span>
|
||||||
|
<span class="member-name">{invite.email}</span>
|
||||||
|
<span class="role">{t('project.invited')}</span>
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
class="remove"
|
||||||
|
onclick={() => withdraw(invite)}
|
||||||
|
aria-label={t('project.withdrawInvite', { email: invite.email })}>×</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
|
||||||
|
projekt — det ville være en overdragelse, ikke en udgang. -->
|
||||||
|
{#if !isOwner}
|
||||||
|
<button class="leave" onclick={leave}>{t('project.leave')}</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
|
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
||||||
|
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||||
|
en kollega, man kender navnet på — og konti her bliver kun til ved
|
||||||
|
invitation, så listen er en adressebog og ikke en udlevering. -->
|
||||||
|
{#if candidates.length}
|
||||||
|
<div class="addshare">
|
||||||
|
<select bind:value={sharePick} aria-label={t('project.pickPerson')}>
|
||||||
|
<option value="">{t('project.pickPerson')}</option>
|
||||||
|
{#each candidates as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={pickRole} aria-label={t('project.roleForPicked')}>
|
||||||
|
<option value="editor">{t('project.canEdit')}</option>
|
||||||
|
<option value="viewer">{t('project.canView')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!sharePick}
|
||||||
|
onclick={async () => {
|
||||||
|
const id = sharePick;
|
||||||
|
sharePick = '';
|
||||||
|
await share({ user_id: id }, pickRole);
|
||||||
|
}}>{t('project.shareWith')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Og den, der ikke er her endnu. Samme felt til begge udfald: har
|
||||||
|
adressen en konto, bliver det en deling med det samme, og ellers en
|
||||||
|
invitation. Hvem der har en konto, skal man ikke vide for at dele. -->
|
||||||
|
<p class="byemail-hint">{t('project.orByEmail')}</p>
|
||||||
<form onsubmit={invite}>
|
<form onsubmit={invite}>
|
||||||
<input
|
<input
|
||||||
bind:value={inviteEmail}
|
bind:value={inviteEmail}
|
||||||
@@ -1396,6 +1500,45 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* De inviterede står under medlemmerne med en tynd streg imellem: de er på vej
|
||||||
|
ind, ikke inde, og en liste uden skel læses som om de allerede var det. */
|
||||||
|
.invited {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingen farve at give dem: en invitation har en adresse og ellers ingenting,
|
||||||
|
og en tilfældig farve ville love et menneske, der ikke findes endnu. */
|
||||||
|
.avatar.waiting {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addshare {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
|
||||||
|
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
|
||||||
|
.leave {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byemail-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user