Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cf184caa6 | ||
|
|
3c14906db2 | ||
|
|
70d7398d5a | ||
|
|
1cabbb7a78 | ||
|
|
5eb689e4c2 | ||
|
|
e931ddc313 | ||
|
|
2210dc082a | ||
|
|
9fa47822c1 | ||
|
|
366bed3a15 | ||
|
|
07fc170b91 | ||
|
|
8139003b35 | ||
|
|
96e1fa251b | ||
|
|
40102d6038 | ||
|
|
0cf5c8c281 |
@@ -3,6 +3,11 @@ name: Documentation
|
||||
# The docs are built on every push so a broken build is caught immediately, but
|
||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
||||
# owner decides to publish, this job builds and stops.
|
||||
#
|
||||
# And on pull requests, where the build is the only check there is: `mkdocs build
|
||||
# --strict` fails on a link to a page that does not exist, and building only after
|
||||
# the merge means finding that out on `main`. Nothing is published from a pull
|
||||
# request — the `deploy` job below runs on a manual dispatch and nothing else.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -10,6 +15,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -17,6 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
|
||||
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
|
||||
# commit'en uden den.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
@@ -60,7 +65,28 @@ jobs:
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# failed to build.
|
||||
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
|
||||
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
|
||||
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
|
||||
# commit-overskrifter, som er skrevet til dem, der læser koden.
|
||||
#
|
||||
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||
# side, der linker til sig selv, hjælper ingen.
|
||||
- id: notes
|
||||
run: |
|
||||
{
|
||||
echo 'body<<VERDANDE_EOF'
|
||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
||||
echo VERDANDE_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: ${{ steps.notes.outputs.body }}
|
||||
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
|
||||
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
|
||||
# ændringer det var, kan læse det samme sted.
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
@@ -37,17 +37,32 @@ and an MCP endpoint so Claude can work your task list directly.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project, by
|
||||
handing it to one person — including somebody who has no account yet, who gets an
|
||||
invitation that becomes the share when they sign up — or by writing `@Sofie` in
|
||||
the text, which shares it with her and tells her. `#project` and
|
||||
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
everybody arrives through an invite link, either to a project or — from
|
||||
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
||||
by somebody else.
|
||||
everybody arrives through an invite link — to a project, to a single note, or
|
||||
from **Settings → Brugere** to the instance itself. Nobody's password is ever
|
||||
chosen by somebody else.
|
||||
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||
- **Notifications that are worth reading.** A bell for what other people did to what
|
||||
you share — folded, so one person typing for five minutes is one line — pushed to
|
||||
your devices and counted on the app's icon in the Dock.
|
||||
- **AI that suggests and never writes.** Optional, off by default, your own key and
|
||||
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
|
||||
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
|
||||
action the mail asks for, and answers questions about your own notes with links to
|
||||
what the answer rests on. Every one of them arrives as a line you accept, edit or
|
||||
refuse — a model that edits your tasks directly has to be right every time to be
|
||||
worth having.
|
||||
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
|
||||
or a POST of one line of text from a shortcut on your phone, a script or any
|
||||
service that can call a URL.
|
||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
||||
|
||||
@@ -104,6 +104,8 @@ func run() error {
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.DailyPlans = api.DailyPlans
|
||||
runner.NotifyOfUpdates = api.NotifyOfUpdates
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
+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
|
||||
screenshot.
|
||||
|
||||
## The shape of all of it
|
||||
|
||||
**The model proposes; you decide.** Nothing is written until you say yes, and the
|
||||
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
|
||||
p1 #Firma` — sitting in a field you can edit before you accept it.
|
||||
|
||||
That is not caution for its own sake. A model that edits your tasks directly has to
|
||||
be right *every* time to be worth having. One that suggests only has to be right
|
||||
often enough to save you some typing, and when it is wrong you have already seen it.
|
||||
|
||||
It is also why the suggestion is a line rather than a set of fields: there is one
|
||||
reading of "i morgen" in this program, and a suggestion goes through the same
|
||||
[quick add](quick-add.md) parser as anything you type — including after you have
|
||||
edited it by hand.
|
||||
|
||||
The model is given the names of the projects that exist and may name no others. A
|
||||
`#Projekt` it invented would create one by accident the first time you accepted it,
|
||||
and a guess must not be able to move a task somewhere you will not look for it.
|
||||
|
||||
## What it can do
|
||||
|
||||
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
|
||||
seven concrete actions and creates them as sub-tasks. Written in your language.
|
||||
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
|
||||
line it suggests how you would have written it if you had finished it — project,
|
||||
date, priority — with one sentence on what it read out of the task. Accept, edit, or
|
||||
skip, one line at a time.
|
||||
|
||||
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
|
||||
somebody committed to or was asked to do; a heading is not a task and a topic that
|
||||
was discussed is not a task. Each suggestion quotes the words it came from, so a
|
||||
wrong one can be seen through rather than merely being wrong. The note itself is
|
||||
never edited — the task carries `[[the note's title]]` in its description instead.
|
||||
|
||||
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
|
||||
default; pick the hour yourself. One notification about what is due today and what
|
||||
has been missed, and what to start with — and the same text as a card at the top of
|
||||
**I dag**, where you are looking anyway when you start the day. The notification is
|
||||
the reminder; the card is where it stays.
|
||||
|
||||
The card shows the plan *as it was sent*, with the time it was made, rather than a
|
||||
new one each time you look: a plan that says something different when you look at it
|
||||
than it said when it arrived is worse than either. **Forny planen** makes a new one
|
||||
when the day has moved, and it does not send a notification about something you are
|
||||
already looking at. With the morning notification off, the card offers to make one
|
||||
on the spot.
|
||||
|
||||
The counts are done in the database and handed to the model as facts it may not
|
||||
recompute — a plan that says four tasks where there are three is worse than no plan.
|
||||
With no model configured the counts stand on their own, which is what you needed to
|
||||
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
|
||||
|
||||
**Mail that becomes a task you can act on.** Where a
|
||||
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
||||
12"*, the model writes the action the mail asks for — and leaves the mail alone if
|
||||
it asks for nothing. The subject stays in the description, so a mail it misread can
|
||||
still be read. If the model is unavailable the task is named sender and subject as
|
||||
before: a model that is down must not be able to stop the post.
|
||||
|
||||
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
|
||||
Anders i august?"* — search finds what contains the words, this answers the
|
||||
question, and the answer links to the notes and tasks it rests on. If nothing
|
||||
matches, it says so; a guess would be useless here, because you are asking precisely
|
||||
because you cannot remember.
|
||||
|
||||
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
|
||||
actions, created as sub-tasks.
|
||||
|
||||
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
||||
what appears to be slipping. At most six lines — it is a prompt to think, not a
|
||||
report.
|
||||
what appears to be slipping. At most six lines — a prompt to think, not a report.
|
||||
|
||||
## What is sent
|
||||
|
||||
Only what the feature needs: for a split, the task's title and notes; for a
|
||||
summary, the titles, dates and priorities of your open tasks. Never comments,
|
||||
attachments, or anything from a project the feature was not invoked on.
|
||||
Only what the feature needs, and only when you invoke it:
|
||||
|
||||
Nothing is sent to anybody until you configure a provider. With a local model,
|
||||
nothing leaves your machine at all.
|
||||
| Feature | What leaves the instance |
|
||||
| --- | --- |
|
||||
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
|
||||
| Tasks in a note | That one note's text, and your project names |
|
||||
| Today's plan | The titles and dates of what is due or overdue today |
|
||||
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
|
||||
| Ask | Your question, and the notes and tasks the search matched |
|
||||
| Split a task | That task's title and notes |
|
||||
| Weekly summary | The titles, dates and priorities of your open tasks |
|
||||
|
||||
Never comments, never attachments, never a project the feature was not invoked on,
|
||||
and never anything belonging to somebody else. Nothing is sent to anybody until you
|
||||
configure a provider, and with a local model nothing leaves your machine at all.
|
||||
|
||||
## Turning it off
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
With the check on, administrators are told **once per version** — in the bell, in
|
||||
push and on the app's badge — rather than having to open the settings page and
|
||||
look. The notification leads to the page where the restart button is. With the
|
||||
check off nothing is fetched and nothing is said; see
|
||||
[Notifications](notifications.md).
|
||||
|
||||
All three together let **Settings → Notifications** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||
|
||||
+13
-2
@@ -27,8 +27,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move it.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||
single note with a person, or file it in a project; `#project` and
|
||||
`[[another note]]` are links, and a task shows what has been written about it.
|
||||
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||
greyed, at the foot of the list until a moment you pick — without touching when
|
||||
@@ -38,8 +39,18 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **[Told when it matters](notifications.md).** A bell for what other people did to
|
||||
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
|
||||
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
|
||||
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
|
||||
answers questions about your own notes with links to what the answer rests on —
|
||||
every one of them as a line you accept, edit or refuse.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
|
||||
address, or a POST from a shortcut on your phone, a script, or any service that
|
||||
can call a URL.
|
||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Opening links
|
||||
|
||||
A link in a task or a note opens in whichever browser your system opens links in.
|
||||
**Settings → Links** can try to change that — and the honest description of what it
|
||||
does is *try*.
|
||||
|
||||
## What a web page cannot do
|
||||
|
||||
Two things, and they set the limits of this feature:
|
||||
|
||||
- **It cannot see which browsers are installed.** The list is deliberately closed:
|
||||
it would be a fingerprint, distinctive enough to follow somebody from site to
|
||||
site.
|
||||
- **It cannot ask the system to use a particular one.** A link goes to the default
|
||||
browser, and there is no API that says otherwise.
|
||||
|
||||
The only handle that exists is the URL schemes browsers register for themselves —
|
||||
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
|
||||
way to ask whether one exists. You can only try it.
|
||||
|
||||
## So the setting is an attempt
|
||||
|
||||
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
|
||||
link, that scheme is tried first; if nothing has happened within about half a
|
||||
second, the link opens the ordinary way.
|
||||
|
||||
The fallback is the whole reason this is safe to turn on. Without it, a setting
|
||||
that works on your phone would leave links doing nothing on your desktop.
|
||||
|
||||
In practice: **it works on a phone**, where browsers register these schemes, and
|
||||
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
|
||||
the usual exception. There is a **Prøv det** button for exactly this reason — you
|
||||
cannot ask whether a scheme exists, so it is better to find out on example.dk than
|
||||
on a link you meant to follow.
|
||||
|
||||
## Writing your own
|
||||
|
||||
Three placeholders, because browsers disagree about the shape they want the address
|
||||
in:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `{url}` | The address as it is — `x-safari-https://example.dk` |
|
||||
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
|
||||
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
|
||||
|
||||
## It belongs to the machine, not the account
|
||||
|
||||
The choice is kept in the browser you are sitting at, not on your account — the
|
||||
same rule the sidebar's width follows. `firefox://` works on the phone and does
|
||||
nothing on the Mac, so a choice that followed the account would be wrong in one of
|
||||
the two places every time.
|
||||
@@ -67,3 +67,36 @@ immediately.
|
||||
|
||||
!!! tip "Keep the address to yourself"
|
||||
Treat it like a secret. If you publish it somewhere, rotate it.
|
||||
|
||||
## Pushing from another program
|
||||
|
||||
The address above needs a mail server. When what you want is a shortcut on your
|
||||
phone, a script, or any service that can call a URL, there is a second way in:
|
||||
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
|
||||
POST a line of text to.
|
||||
|
||||
```bash
|
||||
curl -X POST https://todo.example.dk/inbound/hook/<token> \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Ring til Anders i morgen p1 #Firma"
|
||||
```
|
||||
|
||||
The body is read three ways, because those are the three you meet: plain text from
|
||||
a shortcut that just sends what was selected, JSON with a `text` field from a
|
||||
service, or a form field from a webhook form. The first line becomes the task and
|
||||
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
|
||||
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
|
||||
top of the app.
|
||||
|
||||
**The address is a key.** It has its own token rather than sharing the mail one:
|
||||
the two are the same kind of secret but they leak separately, and a URL that has
|
||||
been sitting in a shortcut on a lost phone has to be replaceable without changing
|
||||
the address other people have in their address books. A token here can do one
|
||||
thing — put a task in your inbox — which is why it is not an
|
||||
[API token](api.md), which can do everything. Change it under the same setting; the
|
||||
old one stops working at once.
|
||||
|
||||
**POST only.** A GET would be easier to call, and that is exactly the problem: a
|
||||
browser prefetch, a link check in a chat, and a revisited history entry would all
|
||||
create tasks nobody asked for.
|
||||
|
||||
|
||||
@@ -79,6 +79,37 @@ There is no folder tree. Projects, groups and labels already exist, are already
|
||||
shared and already have roles — a second hierarchy to file things in is one too
|
||||
many, and a note in a project inherits every one of those answers.
|
||||
|
||||
### Sharing one note with one person
|
||||
|
||||
Filing a note in a project is the right tool when the note belongs with a body of
|
||||
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
|
||||
at the foot of the note does the second: this note, this person, as reader or
|
||||
editor. Only the note's owner can share it.
|
||||
|
||||
Type an **email address** rather than picking a name, and one of two things
|
||||
happens. An address that already has an account is shared with straight away. One
|
||||
that does not gets an invitation — a link that creates the account and the share
|
||||
together, so the note is theirs the moment they sign up. Invitations wait in the
|
||||
panel under the note, where they can be withdrawn; that is the only way back, since
|
||||
only the token's hash is stored and a link sent to the wrong address cannot be
|
||||
looked up.
|
||||
|
||||
### Naming somebody in a note
|
||||
|
||||
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
|
||||
note *is* wanting them to read it, and doing it as two acts — write the name, then
|
||||
open the panel and find the person — meant the second was forgotten, and the note
|
||||
sat there addressing somebody who could not see it. The editor suggests names after
|
||||
`@`, the same way `#` suggests projects.
|
||||
|
||||
- **Only the owner's mentions share.** An editor may write the name; giving
|
||||
somebody else's note away is the owner's alone.
|
||||
- **As a reader**, which is the least that satisfies "look at this". A role you
|
||||
have raised in the panel is never lowered by a mention.
|
||||
- **Only a name that has just appeared** counts. A note is saved on every typing
|
||||
pause, so without that every save would be another share and another notification
|
||||
about the same name.
|
||||
|
||||
## Finding
|
||||
|
||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 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.
|
||||
+365
-1
@@ -1564,7 +1564,15 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
installs:
|
||||
type: [integer, "null"]
|
||||
description: |
|
||||
Null below the collector's floor (`publish_min`, 25 by
|
||||
default). A number that *falls* cannot be published as a
|
||||
fact — a bad release, a DNS change that quietly stops the
|
||||
pings, or lost rows all look like "fewer people use this
|
||||
now" — and a small number mostly says how little it takes
|
||||
to move it.
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
@@ -1590,7 +1598,19 @@ paths:
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min:
|
||||
type: integer
|
||||
description: The floor under the published count.
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
last_error:
|
||||
type: string
|
||||
description: |
|
||||
Why the last attempt did not arrive; absent when the last
|
||||
one did. Shown rather than only logged: a beacon that
|
||||
cannot reach its collector otherwise fails in complete
|
||||
silence, and the only trace is a "last sent" date that
|
||||
quietly gets older.
|
||||
last_error_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
@@ -1610,12 +1630,74 @@ paths:
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min: { type: integer, minimum: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/beacon/notice:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether the disclosure is still owed, and what it says. Administrators only.
|
||||
description: |
|
||||
The beacon is on by default, and that is only defensible if it is said
|
||||
unprompted. A settings page where it is stated fully is not the same as
|
||||
saying it: that page is opened by people who already suspect there is
|
||||
something to read.
|
||||
|
||||
The three values are in the response — the real id, the real version, the
|
||||
real collector address — so the notice can show what is sent rather than
|
||||
say "anonymous data" and ask to be believed.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: boolean
|
||||
description: False once anybody has answered, panel-wide.
|
||||
enabled: { type: boolean }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
collector_url: { type: string }
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Answer the notice. Administrators only.
|
||||
description: |
|
||||
One call for both answers — "keep it on" and "turn it off" are the same
|
||||
act, somebody deciding, and two endpoints would mean one of them could
|
||||
forget to record that a decision was made.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [keep]
|
||||
properties:
|
||||
keep:
|
||||
type: boolean
|
||||
description: |
|
||||
Required, and rejected when absent. An empty body must not be
|
||||
able to mean "turn it off": an opt-out nobody chose is as much
|
||||
a broken promise as an opt-in nobody chose.
|
||||
responses:
|
||||
"200":
|
||||
description: Recorded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
"422":
|
||||
description: No answer was given.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
@@ -3450,6 +3532,247 @@ paths:
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/inbox/tidy:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Suggest how the loose tasks in the inbox could have been written.
|
||||
description: |
|
||||
Suggests, never writes. Each suggestion is the line the person would have
|
||||
typed — quick-add syntax, the same one parser as everywhere else — so it
|
||||
can be read and edited by whoever it is put to, rather than being a set of
|
||||
fields a model filled in that you take or leave.
|
||||
|
||||
The model is given the projects that exist and may name no other: a
|
||||
`#Project` it invented would create one by accident the first time
|
||||
somebody said yes.
|
||||
responses:
|
||||
"200":
|
||||
description: The suggestions, one per task, and none for a task already filed well.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
suggestions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/inbox/tidy/apply:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Write one accepted suggestion into the task it is about.
|
||||
description: |
|
||||
The line is parsed here rather than in the browser, so "tomorrow" means
|
||||
what it means everywhere else — and a suggestion edited by hand before it
|
||||
was accepted is read exactly like one that was not.
|
||||
|
||||
Only what the line actually says is applied. A missing date means "the
|
||||
line said nothing about a date", never "clear the one that is there". A
|
||||
project the model invented moves nothing.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id, line]
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
line: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: The task as it now stands.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/notes/{noteID}/actions:
|
||||
parameters:
|
||||
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Pull what somebody committed to out of a note.
|
||||
description: |
|
||||
Only things somebody committed to or was asked to do — a heading is not a
|
||||
task, a topic discussed is not a task. An empty array is a useful answer
|
||||
and a made-up list is not.
|
||||
|
||||
Each suggestion quotes the words it came from, so a wrong one can be seen
|
||||
through rather than merely being wrong.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
suggestions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AISuggestion" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/ai/notes/{noteID}/actions/apply:
|
||||
parameters:
|
||||
- { name: noteID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Create one accepted task from a note.
|
||||
description: |
|
||||
The note is not touched. Writing the link back into it would make the two
|
||||
point at each other, and a text a program edits without being asked is a
|
||||
text people stop trusting — so the task carries where it came from, as a
|
||||
`[[note title]]` in its description, and the note is left alone.
|
||||
|
||||
The note's own project is the default; a `#Project` in the line wins,
|
||||
because typing it is a choice and inheriting is a circumstance.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [line]
|
||||
properties:
|
||||
line: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/plan:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Whether a morning plan is sent, and at what hour.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Turn the morning plan on or off, or move the hour.
|
||||
description: |
|
||||
The hour is local to the person. Moving it clears the "already sent today"
|
||||
mark, so somebody who moves the plan from 07 to 16 gets it this afternoon
|
||||
rather than tomorrow — a setting whose effect you have to wait a day to
|
||||
see is a setting nobody dares turn on.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
hour: { type: integer, minimum: 0, maximum: 23 }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/ai/plan/now:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Make today's plan now, to whoever asks.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
silent:
|
||||
type: boolean
|
||||
description: >
|
||||
Make and store it without sending a notification — the card on
|
||||
Today asking for a plan while somebody is looking at it. A
|
||||
notification about something in front of you is the kind that
|
||||
teaches people to ignore the bell.
|
||||
description: |
|
||||
The counts come from the database and the sentence from the model, in that
|
||||
order: a model that counted for itself would sometimes count wrong, and a
|
||||
plan that says four tasks where there are three is worse than no plan.
|
||||
With no model configured the counts stand on their own, which is what you
|
||||
needed to know at seven in the morning anyway.
|
||||
|
||||
Nothing due and nothing overdue sends nothing. A morning with nothing to
|
||||
do is a good morning, and a notification saying "there is nothing" teaches
|
||||
people to stop opening notifications.
|
||||
responses:
|
||||
"200":
|
||||
description: The plan, and whether it was sent.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
sent: { type: boolean }
|
||||
|
||||
/api/v1/ai/ask:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Ask a question of your own notes and tasks.
|
||||
description: |
|
||||
Search finds what contains the words. This answers the question — "what
|
||||
did I promise Anders in August?" — which is not the same: the answer is
|
||||
spread over three notes and a task, and none of them contains the word
|
||||
"promise".
|
||||
|
||||
Two halves, and the order is the whole of the safety in it. The database
|
||||
finds the candidates with the search that already exists and that already
|
||||
only sees what this person may see. The model is given those and nothing
|
||||
else — it does not look anything up and cannot ask for more.
|
||||
|
||||
The answer carries what it rests on. A model that says "you did, on the
|
||||
14th" is only useful if you can open the note it read that in.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [question]
|
||||
properties:
|
||||
question: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: |
|
||||
The answer, and the passages it rests on — only those, not everything
|
||||
that was looked at. An empty answer means nothing matched.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
answer: { type: string }
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
kind: { type: string, enum: [note, task] }
|
||||
id: { type: string }
|
||||
title: { type: string }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -4233,6 +4556,47 @@ components:
|
||||
note_id: { type: string }
|
||||
title: { type: string }
|
||||
|
||||
DailyPlanSettings:
|
||||
type: object
|
||||
description: >
|
||||
One person's morning plan, and today's plan itself. One resource, because
|
||||
it is one thing: the plan is the resource and the setting is when it gets
|
||||
made.
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
hour:
|
||||
type: integer
|
||||
description: Local to the person, because seven o'clock is seven different places.
|
||||
last_sent:
|
||||
type: string
|
||||
description: The date it last went out, in that person's own timezone.
|
||||
plan:
|
||||
type: string
|
||||
description: >
|
||||
Today's plan as it was sent — kept rather than recomputed on every
|
||||
look. A plan that says something different when you look at it than it
|
||||
said when it arrived is worse than either. Absent when the stored plan
|
||||
is from another day.
|
||||
plan_date: { type: string }
|
||||
plan_at:
|
||||
type: integer
|
||||
description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.
|
||||
|
||||
AISuggestion:
|
||||
type: object
|
||||
description: >
|
||||
One proposed line, in quick-add syntax, plus one sentence on why it looks
|
||||
like that. Nothing has happened yet — a suggestion becomes something when
|
||||
somebody says yes to it.
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
description: >
|
||||
The task the suggestion is about, when it is about one that already
|
||||
exists. Absent when the suggestion is a new task.
|
||||
line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
|
||||
why: { type: string }
|
||||
|
||||
NoteInvite:
|
||||
type: object
|
||||
description: >
|
||||
|
||||
+256
-12
@@ -395,22 +395,266 @@ back, do not congratulate, and do not offer productivity advice.`, language)
|
||||
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
}
|
||||
|
||||
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
|
||||
// tastet.
|
||||
//
|
||||
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
|
||||
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
|
||||
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
|
||||
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
|
||||
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
|
||||
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
|
||||
//
|
||||
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
|
||||
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
|
||||
type Suggestion struct {
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
|
||||
//
|
||||
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
|
||||
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
|
||||
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You tidy up a task inbox.
|
||||
|
||||
For each task you are given, write the line the person would have typed if they
|
||||
had filed it properly. Keep their own words for what the task is — you are filing
|
||||
it, not rewriting it.
|
||||
|
||||
The line may carry, in this syntax and no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, such as "i morgen", "on friday", "15/3"
|
||||
|
||||
Leave out anything the task does not tell you. A guessed deadline is worse than
|
||||
none: the person can add one, but they cannot see a wrong one you invented.
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
|
||||
saying what you read out of the task. Give one element per task, in the order you
|
||||
were given them, and leave a task out entirely if it is already filed well.`,
|
||||
known, language, today, language)
|
||||
|
||||
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
|
||||
return c.suggestions(ctx, system, prompt)
|
||||
}
|
||||
|
||||
// ActionsInNote trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
|
||||
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
|
||||
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
|
||||
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
|
||||
|
||||
Only things somebody committed to or was asked to do. A heading is not a task, a
|
||||
topic that was discussed is not a task, and an idea nobody agreed to is not a
|
||||
task. If the note contains none, reply with an empty array — that is a useful
|
||||
answer and a made-up list is not.
|
||||
|
||||
Write each one as the line the person would have typed, in %s, in this syntax and
|
||||
no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, only when the note actually gives one
|
||||
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why quotes the few words in the note that
|
||||
the task came from, so it can be checked.`, language, known, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Note:\n"+note)
|
||||
}
|
||||
|
||||
// DailyPlan skriver morgenens to-tre linjer.
|
||||
//
|
||||
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
|
||||
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
|
||||
// og en model, der tæller en liste, tæller den forkert af og til.
|
||||
//
|
||||
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
|
||||
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
|
||||
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You write somebody's morning note about their own day.
|
||||
|
||||
Write in %s. At most three short lines, no greeting, no sign-off, no
|
||||
encouragement. Say what to start with and why. Name at most one thing that could
|
||||
wait or be dropped, and only if something obviously can.
|
||||
|
||||
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
|
||||
recount them, do not contradict them, and do not list every task back.`,
|
||||
language, due, overdue)
|
||||
|
||||
return c.Complete(ctx, system, []Message{
|
||||
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
|
||||
})
|
||||
}
|
||||
|
||||
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
|
||||
//
|
||||
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
|
||||
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
|
||||
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
|
||||
// ikke lede efter kantede parenteser i en tekst.
|
||||
//
|
||||
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
|
||||
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
|
||||
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
|
||||
// var opfundet.
|
||||
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
|
||||
|
||||
Use only the passages given. If they do not answer the question, say so plainly —
|
||||
that is a useful answer, and a guess is not: the person is asking because they
|
||||
cannot remember, so they cannot tell an invented answer from a real one.
|
||||
|
||||
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
|
||||
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
|
||||
passages the answer actually rests on, and nothing else.`, language)
|
||||
|
||||
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
|
||||
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Answer string `json:"answer"`
|
||||
Sources []int `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
|
||||
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
|
||||
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
|
||||
// dag end en fejlmeddelelse ville være.
|
||||
return strings.TrimSpace(reply), nil, nil
|
||||
}
|
||||
return strings.TrimSpace(out.Answer), out.Sources, nil
|
||||
}
|
||||
|
||||
// TasksFromMail skriver én opgavelinje pr. mail.
|
||||
//
|
||||
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
|
||||
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
|
||||
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
|
||||
// lader være, hvis den ikke beder om nogen.
|
||||
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
|
||||
|
||||
For each numbered email, write the line the recipient would have typed for
|
||||
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
|
||||
name in it when it matters who it is about. Not a summary of the email: the
|
||||
action it asks for.
|
||||
|
||||
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
|
||||
email itself says so — "before friday" in the text is a date, an email that merely
|
||||
sounds urgent is not. Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "N. ..."} keeping the number the email was given, so the line can be
|
||||
put back on the right one. Leave an email out entirely if it asks for nothing.`,
|
||||
language, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
|
||||
}
|
||||
|
||||
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
|
||||
// der ikke er en linje.
|
||||
//
|
||||
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
|
||||
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
|
||||
// stykker, når den i virkeligheden var enig med dem.
|
||||
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Suggestion
|
||||
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
|
||||
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
|
||||
}
|
||||
|
||||
clean := make([]Suggestion, 0, len(out))
|
||||
for _, s := range out {
|
||||
s.Line = strings.TrimSpace(s.Line)
|
||||
s.Why = strings.TrimSpace(s.Why)
|
||||
if s.Line != "" {
|
||||
clean = append(clean, s)
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
|
||||
// den slags, der bliver rettet ét sted ud af fire.
|
||||
func languageOf(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "English"
|
||||
}
|
||||
return "Danish"
|
||||
}
|
||||
|
||||
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
|
||||
// omhyggeligt de bliver bedt om at lade være.
|
||||
func unfence(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
fence := strings.Index(reply, "```")
|
||||
if fence < 0 {
|
||||
return reply
|
||||
}
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
|
||||
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
|
||||
// bliver bedt om at lade være.
|
||||
func extractObject(reply string) string {
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '{')
|
||||
end := strings.LastIndexByte(reply, '}')
|
||||
if start >= 0 && end > start {
|
||||
return reply[start : end+1]
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractJSON pulls an array out of a reply that may have been wrapped in a code
|
||||
// fence or preceded by a sentence, which models do however firmly they are asked
|
||||
// not to.
|
||||
func extractJSON(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
|
||||
if fence := strings.Index(reply, "```"); fence >= 0 {
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
reply = strings.TrimSpace(rest[:end])
|
||||
}
|
||||
}
|
||||
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '[')
|
||||
end := strings.LastIndexByte(reply, ']')
|
||||
if start >= 0 && end > start {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog.
|
||||
//
|
||||
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
|
||||
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
|
||||
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
|
||||
//
|
||||
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
|
||||
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
|
||||
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
|
||||
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
|
||||
// nogen andens noter.
|
||||
//
|
||||
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
|
||||
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
|
||||
|
||||
type askRequest struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
type askSourceJSON struct {
|
||||
Kind string `json:"kind"` // "note" eller "task"
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
|
||||
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
||||
var req askRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
question := strings.TrimSpace(req.Question)
|
||||
if question == "" {
|
||||
writeFieldErrors(w, map[string]string{"question": "required"})
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Ord for ord, ikke hele spørgsmålet på én gang.
|
||||
//
|
||||
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
|
||||
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
|
||||
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
|
||||
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
|
||||
// eneste gang.
|
||||
//
|
||||
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
|
||||
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
|
||||
// mellem spørgsmålet og de noter, der faktisk findes.
|
||||
notes, tasks, err := s.candidatesFor(r, user.ID, question)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search for the question", err)
|
||||
return
|
||||
}
|
||||
if len(notes) == 0 && len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"answer": "", "sources": []askSourceJSON{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
|
||||
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
|
||||
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
|
||||
passages := make([]string, 0, len(notes)+len(tasks))
|
||||
for _, n := range notes {
|
||||
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
|
||||
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
|
||||
firstWords(n.Body, 300))
|
||||
}
|
||||
for _, t := range tasks {
|
||||
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
|
||||
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
|
||||
if t.DueDate != "" {
|
||||
line += " (" + t.DueDate + ")"
|
||||
}
|
||||
if t.Description != "" {
|
||||
line += "\n" + firstWords(t.Description, 60)
|
||||
}
|
||||
passages = append(passages, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai ask", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
|
||||
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
|
||||
// svaret står på.
|
||||
cited := make([]askSourceJSON, 0, len(used))
|
||||
for _, n := range used {
|
||||
if n >= 1 && n <= len(sources) {
|
||||
cited = append(cited, sources[n-1])
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
|
||||
}
|
||||
|
||||
func itoaSource(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
||||
}
|
||||
|
||||
// candidatesFor henter det, spørgsmålet kunne handle om.
|
||||
//
|
||||
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
|
||||
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
|
||||
// note, der indeholder begge de bærende, er den, det handler om.
|
||||
//
|
||||
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
|
||||
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
|
||||
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
|
||||
terms := store.SearchTerms(question)
|
||||
|
||||
type scored struct {
|
||||
note store.Note
|
||||
task store.Task
|
||||
hits int
|
||||
}
|
||||
noteHits := map[string]*scored{}
|
||||
taskHits := map[string]*scored{}
|
||||
|
||||
for _, term := range terms {
|
||||
if len([]rune(term)) < 3 {
|
||||
continue
|
||||
}
|
||||
found, err := s.db.SearchNotes(r.Context(), term, 10)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range found {
|
||||
if at, ok := noteHits[n.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
noteHits[n.ID] = &scored{note: n, hits: 1}
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
|
||||
Search: term, Limit: 10, IncludeCompleted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if at, ok := taskHits[t.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
taskHits[t.ID] = &scored{task: t, hits: 1}
|
||||
}
|
||||
}
|
||||
|
||||
notes := make([]store.Note, 0, len(noteHits))
|
||||
for _, at := range noteHits {
|
||||
notes = append(notes, at.note)
|
||||
}
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
|
||||
})
|
||||
|
||||
tasks := make([]store.Task, 0, len(taskHits))
|
||||
for _, at := range taskHits {
|
||||
tasks = append(tasks, at.task)
|
||||
}
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
|
||||
})
|
||||
|
||||
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
|
||||
// værd — og hvad et menneske ville have gidet læse igennem selv.
|
||||
return capped(notes, 12), capped(tasks, 20), nil
|
||||
}
|
||||
|
||||
func capped[T any](in []T, n int) []T {
|
||||
if len(in) > n {
|
||||
return in[:n]
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// En mail, der bliver til en brugbar opgave.
|
||||
//
|
||||
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
|
||||
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
|
||||
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
|
||||
// mail for at slippe for.
|
||||
//
|
||||
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
|
||||
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
|
||||
// der tager fem minutter, er et, der overlapper med det næste.
|
||||
//
|
||||
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
|
||||
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
|
||||
|
||||
// mailForAI er én mail, som modellen får den at se.
|
||||
type mailForAI struct {
|
||||
From string
|
||||
Subject string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
|
||||
//
|
||||
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
|
||||
// en model, der springer en mail over, ellers ville flytte alle de følgende
|
||||
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
|
||||
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
|
||||
// grund: en forskydning på én er den slags fejl, ingen opdager.
|
||||
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
|
||||
if len(mails) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(mails))
|
||||
for i, m := range mails {
|
||||
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
|
||||
if m.Snippet != "" {
|
||||
line += "\n " + firstWords(m.Snippet, 40)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
byIndex := map[int]string{}
|
||||
for i, sug := range out {
|
||||
n, line := numberedLine(sug.Line)
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
byIndex[n-1] = line
|
||||
}
|
||||
return byIndex
|
||||
}
|
||||
|
||||
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
|
||||
//
|
||||
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
|
||||
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
|
||||
// ingenting, for projektet slås op blandt dem, der findes.
|
||||
//
|
||||
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
|
||||
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
|
||||
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
|
||||
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
task.Content = parsed.Content
|
||||
if parsed.Priority != 0 {
|
||||
task.Priority = parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
task.DueDate = parsed.DueDate
|
||||
}
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
task.ProjectID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject != "" {
|
||||
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Dagens plan: én besked om morgenen om det, der venter i dag.
|
||||
//
|
||||
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
|
||||
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
|
||||
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
|
||||
// at der lå tre ting og forfaldt i går.
|
||||
//
|
||||
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
|
||||
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
|
||||
// er præcis det, dette program findes for at slippe for.
|
||||
|
||||
const planScope = "dailyplan"
|
||||
|
||||
// planSettings er, hvad én person har bedt om.
|
||||
//
|
||||
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
|
||||
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
|
||||
// at det her er tændt for at kunne gøre sit arbejde.
|
||||
type planSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
|
||||
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
|
||||
// baggrundsjob ikke har.
|
||||
Hour int `json:"hour"`
|
||||
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
|
||||
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
||||
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
||||
LastSent string `json:"last_sent,omitempty"`
|
||||
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
|
||||
//
|
||||
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
|
||||
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
|
||||
// andet end den besked, man fik i morges, er værre end begge dele hver for
|
||||
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
|
||||
// gæt hver gang man kigger.
|
||||
//
|
||||
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
|
||||
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
|
||||
Plan string `json:"plan,omitempty"`
|
||||
PlanDate string `json:"plan_date,omitempty"`
|
||||
PlanAt int64 `json:"plan_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
||||
out := planSettings{Hour: 7}
|
||||
stored, err := s.db.UserSettings(ctx, userID, planScope)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
out.Enabled = v
|
||||
}
|
||||
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
|
||||
out.Hour = int(v)
|
||||
}
|
||||
if v, ok := stored["last_sent"].(string); ok {
|
||||
out.LastSent = v
|
||||
}
|
||||
if v, ok := stored["plan"].(string); ok {
|
||||
out.Plan = v
|
||||
}
|
||||
if v, ok := stored["plan_date"].(string); ok {
|
||||
out.PlanDate = v
|
||||
}
|
||||
if v, ok := stored["plan_at"].(float64); ok {
|
||||
out.PlanAt = int64(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
||||
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetPlanSettings er både indstillingen og dagens plan.
|
||||
//
|
||||
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
|
||||
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
|
||||
// to steder at huske at rydde en plan fra i går.
|
||||
//
|
||||
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
|
||||
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
|
||||
// et tomt kort.
|
||||
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
return
|
||||
}
|
||||
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
|
||||
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
type planRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Hour *int `json:"hour"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req planRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Hour != nil {
|
||||
if *req.Hour < 0 || *req.Hour > 23 {
|
||||
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
|
||||
return
|
||||
}
|
||||
p.Hour = *req.Hour
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
|
||||
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
|
||||
// morgen — hvilket ligner, at indstillingen ikke virkede.
|
||||
p.LastSent = ""
|
||||
}
|
||||
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
|
||||
s.internal(w, r, "save plan settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
type planNowRequest struct {
|
||||
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
|
||||
//
|
||||
// En besked om noget, man står og ser på, er en besked, der lærer folk at
|
||||
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
|
||||
// hele dens formål: at vise, at push virker på det her apparat.
|
||||
Silent bool `json:"silent"`
|
||||
}
|
||||
|
||||
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
|
||||
//
|
||||
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
||||
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
|
||||
// lave en plan for den, der har beskeden slået fra.
|
||||
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
||||
var req planNowRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
plan, err := s.buildPlan(r.Context(), user)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build plan", err)
|
||||
return
|
||||
}
|
||||
if plan == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||
return
|
||||
}
|
||||
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||
s.internal(w, r, "save plan", err)
|
||||
return
|
||||
}
|
||||
|
||||
sent := false
|
||||
if !req.Silent {
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: user.ID, Kind: "daily.plan",
|
||||
Title: planTitle(user.Locale), Body: plan,
|
||||
})
|
||||
sent = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"plan": plan, "sent": sent,
|
||||
"plan_at": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
|
||||
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
|
||||
p, err := s.planSettings(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
p.Plan = plan
|
||||
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
p.PlanAt = now.Unix()
|
||||
return s.savePlanSettings(ctx, user.ID, p)
|
||||
}
|
||||
|
||||
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
||||
// endnu i dag.
|
||||
//
|
||||
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
|
||||
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
|
||||
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
|
||||
// egen tidszone afgør, hvornår det er morgen hos dem.
|
||||
func (s *Server) DailyPlans(ctx context.Context) error {
|
||||
users, err := s.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range users {
|
||||
user, err := s.db.UserByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := s.planSettings(ctx, user.ID)
|
||||
if err != nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().In(userLocation(user.Timezone))
|
||||
today := now.Format("2006-01-02")
|
||||
if p.LastSent == today || now.Hour() < p.Hour {
|
||||
continue
|
||||
}
|
||||
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
|
||||
// sende tre planer, når den kommer op — de to af dem er om dage, der er
|
||||
// gået, og en plan for i forgårs er ikke en plan.
|
||||
if now.Hour() > p.Hour+3 {
|
||||
p.LastSent = today
|
||||
_ = s.savePlanSettings(ctx, user.ID, p)
|
||||
continue
|
||||
}
|
||||
|
||||
plan, err := s.buildPlan(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("build daily plan", "err", err, "user", user.ID)
|
||||
continue
|
||||
}
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
|
||||
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
|
||||
// være med at åbne beskeder.
|
||||
if plan != "" {
|
||||
s.notifyDirect(ctx, &store.Notification{
|
||||
UserID: user.ID, Kind: "daily.plan",
|
||||
Title: planTitle(user.Locale), Body: plan,
|
||||
})
|
||||
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
|
||||
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
|
||||
// ene overskrive den andens `last_sent`.
|
||||
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
|
||||
}
|
||||
p.LastSent = today
|
||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||
s.log.Warn("save plan settings", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlan skriver dagens plan.
|
||||
//
|
||||
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
|
||||
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
|
||||
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
|
||||
// står tallene alene, og det er stadig det, man havde brug for at vide.
|
||||
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
|
||||
loc := userLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
today := now.Format("2006-01-02")
|
||||
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
|
||||
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
|
||||
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
|
||||
// overskredet, er en plan, man holder op med at læse.
|
||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
DueFrom: today, DueBefore: today, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(overdue) == 0 && len(due) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain := plainPlan(len(due), len(overdue), user.Locale)
|
||||
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(due)+len(overdue))
|
||||
for _, t := range overdue {
|
||||
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
|
||||
}
|
||||
for _, t := range due {
|
||||
lines = append(lines, "- "+t.Content)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
|
||||
if err != nil {
|
||||
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
|
||||
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
|
||||
// dårligere handel end en besked uden en velskrevet sætning.
|
||||
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
|
||||
return plain, nil
|
||||
}
|
||||
return strings.TrimSpace(written), nil
|
||||
}
|
||||
|
||||
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
|
||||
//
|
||||
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
|
||||
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
|
||||
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
|
||||
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
|
||||
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
|
||||
values, err := s.db.UserSettings(ctx, user.ID, "ai")
|
||||
if err != nil {
|
||||
return ai.Config{}, err
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
|
||||
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
|
||||
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
|
||||
if err := s.db.CreateNotification(ctx, n); err != nil {
|
||||
s.log.Warn("create notification", "err", err)
|
||||
return
|
||||
}
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
go s.pushToUser(n.UserID, n.Title, n.Body, "")
|
||||
}
|
||||
|
||||
func planTitle(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "Today"
|
||||
}
|
||||
return "I dag"
|
||||
}
|
||||
|
||||
func overdueWord(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "was due"
|
||||
}
|
||||
return "forfaldt"
|
||||
}
|
||||
|
||||
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
|
||||
//
|
||||
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
|
||||
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
|
||||
// om nogen har lagt en API-nøgle ind.
|
||||
func plainPlan(due, overdue int, locale string) string {
|
||||
english := strings.HasPrefix(strings.ToLower(locale), "en")
|
||||
var parts []string
|
||||
if due > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(due)+" due today")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
|
||||
}
|
||||
}
|
||||
if overdue > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" overdue")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
||||
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
|
||||
// får dem udleveret som kendsgerninger.
|
||||
func TestTheDailyPlanCountsForItself(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "ring til anders i dag")
|
||||
ts.quickTask(t, "køb mælk i dag")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
// Uden en model står tallene alene, og det er stadig det, man havde brug for
|
||||
// at vide klokken syv.
|
||||
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
|
||||
t.Errorf("plan = %q", plan)
|
||||
}
|
||||
|
||||
// Og den lander i klokken som en besked.
|
||||
list, unread := notifications(t, ts)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "daily.plan" {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
|
||||
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
|
||||
// beskeder.
|
||||
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["sent"] != false {
|
||||
t.Errorf("der blev sendt en plan om ingenting: %v", out)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("beskeder = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
|
||||
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
|
||||
// ligner, at indstillingen ikke virkede.
|
||||
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
|
||||
"enabled": true, "hour": 7,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("slå planen til")
|
||||
}
|
||||
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
|
||||
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("flyt timen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["hour"] != float64(16) {
|
||||
t.Errorf("time = %v", out["hour"])
|
||||
}
|
||||
if out["last_sent"] != nil && out["last_sent"] != "" {
|
||||
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
|
||||
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
|
||||
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
|
||||
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
|
||||
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
|
||||
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
|
||||
}
|
||||
|
||||
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("lav planen: %d", resp.StatusCode)
|
||||
}
|
||||
if made["sent"] != false {
|
||||
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||
if after["plan"] != made["plan"] {
|
||||
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
|
||||
}
|
||||
if after["plan_at"] == nil {
|
||||
t.Error("planen bærer ikke, hvornår den blev lavet")
|
||||
}
|
||||
}
|
||||
|
||||
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
|
||||
// overskriften "i dag", er værre end et tomt kort.
|
||||
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("lav planen")
|
||||
}
|
||||
|
||||
// Sat en dag tilbage, som om den var lavet i går.
|
||||
user := userID(t, ts, "kristian@example.dk")
|
||||
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||
if plan, _ := out["plan"].(string); plan != "" {
|
||||
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// AI'ens forslag, og hvad der sker med dem.
|
||||
//
|
||||
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
|
||||
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
|
||||
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
|
||||
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
|
||||
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
|
||||
// skal have ret tit nok til at spare et par tastetryk.
|
||||
//
|
||||
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
|
||||
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
|
||||
// gennem den samme som alt andet.
|
||||
|
||||
// aiSuggestionJSON er ét forslag på vej til fladen.
|
||||
type aiSuggestionJSON struct {
|
||||
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
|
||||
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
|
||||
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
|
||||
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
|
||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return nil, false
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
|
||||
// lægger sin egen nøgle ind.
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
|
||||
return nil, false
|
||||
}
|
||||
return ai.New(cfg), true
|
||||
}
|
||||
|
||||
// projectNames er de projekter, modellen må nævne.
|
||||
//
|
||||
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
|
||||
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
|
||||
// nogen sagde ja til forslaget.
|
||||
func (s *Server) projectNames(r *http.Request) []string {
|
||||
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
|
||||
if err != nil {
|
||||
s.log.Warn("projects for ai", "err", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
continue
|
||||
}
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
|
||||
// skrives færdig.
|
||||
//
|
||||
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
|
||||
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
|
||||
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
|
||||
// spørgsmål og et ja pr. linje.
|
||||
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inbox, err := s.db.InboxID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{inbox}, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "list inbox", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
|
||||
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
|
||||
// må ikke kunne flytte alle de andres forslag én plads — derfor står
|
||||
// nummeret i teksten og bliver læst tilbage.
|
||||
lines := make([]string, 0, len(tasks))
|
||||
for i, t := range tasks {
|
||||
line := strconv.Itoa(i+1) + ". " + t.Content
|
||||
if t.Description != "" {
|
||||
line += " — " + firstWords(t.Description, 30)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai tidy inbox", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
n, line := numberedLine(sug.Line)
|
||||
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
|
||||
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
|
||||
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(tasks) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
type applyLineRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Line string `json:"line"`
|
||||
}
|
||||
|
||||
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
|
||||
// om.
|
||||
//
|
||||
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
|
||||
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
|
||||
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
|
||||
// der ikke blev rørt.
|
||||
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if strings.TrimSpace(req.Line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
|
||||
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
|
||||
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
|
||||
// slette noget.
|
||||
update := store.TaskUpdate{Content: &parsed.Content}
|
||||
if parsed.Priority != 0 {
|
||||
update.Priority = &parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
update.SetDue, update.DueDate = true, parsed.DueDate
|
||||
}
|
||||
if parsed.Recurrence != "" {
|
||||
update.RecurrenceRule = &parsed.Recurrence
|
||||
}
|
||||
if len(parsed.Labels) > 0 {
|
||||
update.Labels, update.SetLabels = parsed.Labels, true
|
||||
}
|
||||
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
|
||||
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
|
||||
s.internal(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
|
||||
}
|
||||
|
||||
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
|
||||
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
|
||||
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
|
||||
// bare at være forkert.
|
||||
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(n.Body) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai note actions", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for _, sug := range suggestions {
|
||||
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
// handleAICreateFromNote laver én accepteret opgave fra en note.
|
||||
//
|
||||
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
|
||||
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
|
||||
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
|
||||
// den kom fra — og noten kan findes på sin titel.
|
||||
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
|
||||
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
|
||||
// linjen vinder, fordi det er valgt frem for arvet.
|
||||
projectID := n.ProjectID
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from note", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
|
||||
//
|
||||
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
|
||||
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
|
||||
// model, der springer en opgave over, ville så flytte alle de følgende forslag
|
||||
// én plads, og så bliver "sæt dato på den her" sat på en anden.
|
||||
func numberedLine(s string) (int, string) {
|
||||
s = strings.TrimSpace(s)
|
||||
dot := strings.IndexByte(s, '.')
|
||||
if dot <= 0 || dot > 3 {
|
||||
return 0, s
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s[:dot] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, s
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, strings.TrimSpace(s[dot+1:])
|
||||
}
|
||||
|
||||
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
|
||||
// fylde den med en hel note.
|
||||
func firstWords(s string, n int) string {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) <= n {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
return strings.Join(fields[:n], " ") + " …"
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
|
||||
//
|
||||
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
|
||||
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
|
||||
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
|
||||
func fakeModel(t *testing.T, reply string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]string{"content": reply}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
|
||||
// på en adresse i det private net, og bootstrap-kontoen er en.
|
||||
func (ts *testServer) useModel(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "base_url": url, "model": "prøvemodel",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
|
||||
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
|
||||
// anden opgave end den, den blev skrevet om.
|
||||
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
|
||||
]`))
|
||||
|
||||
first := ts.quickTask(t, "noget som helst")
|
||||
second := ts.quickTask(t, "anders 12345678")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
sug := list[0].(map[string]any)
|
||||
if sug["task_id"] != second {
|
||||
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
|
||||
}
|
||||
if sug["line"] != "Ring til Anders i morgen p1" {
|
||||
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
|
||||
}
|
||||
if sug["why"] == "" {
|
||||
t.Error("forslaget siger ikke hvorfor")
|
||||
}
|
||||
_ = first
|
||||
}
|
||||
|
||||
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
|
||||
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
|
||||
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders i morgen p1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Ring til Anders" {
|
||||
t.Errorf("indhold = %v", out["content"])
|
||||
}
|
||||
if out["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", out["priority"])
|
||||
}
|
||||
if out["due_date"] == nil || out["due_date"] == "" {
|
||||
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
|
||||
// opgave hen, hvor ejeren ikke leder efter den.
|
||||
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d", resp.StatusCode)
|
||||
}
|
||||
if after["project_id"] != before["project_id"] {
|
||||
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
|
||||
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
|
||||
// stole på.
|
||||
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
|
||||
]`))
|
||||
|
||||
body := "# Møde med Anders\njeg sender prislisten fredag"
|
||||
noteID := ts.createNote(t, body)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
line := list[0].(map[string]any)["line"].(string)
|
||||
|
||||
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
|
||||
map[string]any{"line": line})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["content"] != "Send prisliste til Anders" {
|
||||
t.Errorf("opgave = %v", task["content"])
|
||||
}
|
||||
// Opgaven bærer, hvor den kom fra, som et notelink.
|
||||
if task["description"] != "[[Møde med Anders]]" {
|
||||
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
|
||||
}
|
||||
|
||||
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
if note["body"] != body {
|
||||
t.Errorf("noten blev rettet:\n%v", note["body"])
|
||||
}
|
||||
}
|
||||
|
||||
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
|
||||
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
|
||||
// noget".
|
||||
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
|
||||
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
|
||||
func (ts *testServer) quickTask(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("quickadd gav intet id: %v", out)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
|
||||
// En liste over alt, der blev kigget i, er ikke en henvisning.
|
||||
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
|
||||
|
||||
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
|
||||
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["answer"] != "Du lovede Anders prislisten fredag." {
|
||||
t.Errorf("svar = %v", out["answer"])
|
||||
}
|
||||
sources, _ := out["sources"].([]any)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("kilder = %v, want én", sources)
|
||||
}
|
||||
src := sources[0].(map[string]any)
|
||||
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
|
||||
t.Errorf("kilde = %v", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
|
||||
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
|
||||
// svaret var gættet.
|
||||
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
|
||||
map[string]any{"question": "kvadratrodenafenbanan"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d", resp.StatusCode)
|
||||
}
|
||||
if out["answer"] != "" {
|
||||
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
|
||||
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
|
||||
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
|
||||
const defaultPublishMin = 25
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
@@ -30,6 +35,33 @@ type beaconConfig struct {
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
|
||||
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
|
||||
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
|
||||
// kan man ikke se en udrulning i.
|
||||
LastVersion string `json:"last_version,omitempty"`
|
||||
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
|
||||
//
|
||||
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
|
||||
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
|
||||
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
|
||||
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
|
||||
// alt er sendt.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt int64 `json:"last_error_at,omitempty"`
|
||||
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
|
||||
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
|
||||
// det, der husker, at det er blevet sagt.
|
||||
NoticeAck bool `json:"notice_ack"`
|
||||
// PublishMin er gulvet under det offentliggjorte tal.
|
||||
//
|
||||
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
|
||||
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
|
||||
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
|
||||
// der ingenting frem for et lille tal, ingen kan tolke.
|
||||
PublishMin int `json:"publish_min"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
@@ -40,7 +72,9 @@ type beaconConfig struct {
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
cfg := beaconConfig{
|
||||
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["last_version"].(string); ok {
|
||||
cfg.LastVersion = v
|
||||
}
|
||||
if v, ok := stored["last_error"].(string); ok {
|
||||
cfg.LastError = v
|
||||
}
|
||||
if v, ok := stored["last_error_at"].(float64); ok {
|
||||
cfg.LastErrorAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["notice_ack"].(bool); ok {
|
||||
cfg.NoticeAck = v
|
||||
}
|
||||
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
|
||||
cfg.PublishMin = int(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
"last_version": cfg.LastVersion,
|
||||
"last_error": cfg.LastError,
|
||||
"last_error_at": cfg.LastErrorAt,
|
||||
"notice_ack": cfg.NoticeAck,
|
||||
"publish_min": cfg.PublishMin,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
PublishMin int `json:"publish_min"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
|
||||
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
|
||||
// det eneste spor, et tavst beacon ellers efterlader.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt string `json:"last_error_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
PublishMin: cfg.PublishMin,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.LastError != "" {
|
||||
out.LastError = cfg.LastError
|
||||
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
@@ -129,6 +194,7 @@ type beaconRequest struct {
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
PublishMin *int `json:"publish_min"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.PublishMin != nil {
|
||||
if *req.PublishMin < 0 {
|
||||
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
|
||||
return
|
||||
}
|
||||
cfg.PublishMin = *req.PublishMin
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err == nil && n < cfg.PublishMin {
|
||||
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
|
||||
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
|
||||
// læst som "færre bruger det nu", også når det i virkeligheden var en
|
||||
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
|
||||
// blive ramt.
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
|
||||
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
|
||||
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
|
||||
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
|
||||
// et døgn efter at være opdateret.
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
|
||||
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
|
||||
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
|
||||
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
|
||||
// savne en.
|
||||
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
|
||||
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
|
||||
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
|
||||
// siden om noget, der virker.
|
||||
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
cfg.LastVersion = Version
|
||||
cfg.LastError, cfg.LastErrorAt = "", 0
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
|
||||
|
||||
// handleBeaconNotice says whether the disclosure is still owed, and what it would
|
||||
// say.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
|
||||
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
|
||||
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
|
||||
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
|
||||
// dem, der ved, at der er noget at slå fra.
|
||||
//
|
||||
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
|
||||
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
|
||||
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
|
||||
// løfte, der er noget værd: en, læseren selv kan kontrollere.
|
||||
//
|
||||
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
|
||||
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
|
||||
// ikke kan svare på det.
|
||||
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pending": !cfg.NoticeAck,
|
||||
"enabled": cfg.Enabled,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"version": Version,
|
||||
"collector_url": cfg.Collector,
|
||||
})
|
||||
}
|
||||
|
||||
type beaconNoticeRequest struct {
|
||||
// Keep er en peger og ikke en bool, og det er hele pointen.
|
||||
//
|
||||
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
|
||||
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
|
||||
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
|
||||
// tilsvarende ende og er blevet bidt af den.
|
||||
Keep *bool `json:"keep"`
|
||||
}
|
||||
|
||||
// handleAckBeaconNotice records that the notice has been seen, and turns the
|
||||
// beacon off if that was the answer.
|
||||
//
|
||||
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
|
||||
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
|
||||
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
|
||||
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconNoticeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Keep == nil {
|
||||
writeFieldErrors(w, map[string]string{"keep": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
cfg.NoticeAck = true
|
||||
cfg.Enabled = *req.Keep
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
|
||||
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
|
||||
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent besked: %d", resp.StatusCode)
|
||||
}
|
||||
if notice["pending"] != true {
|
||||
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
|
||||
}
|
||||
if notice["enabled"] != true {
|
||||
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
|
||||
}
|
||||
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
|
||||
settings := beaconSettingsOf(t, ts)
|
||||
if notice["instance_id"] != settings["instance_id"] {
|
||||
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
|
||||
notice["instance_id"], settings["instance_id"])
|
||||
}
|
||||
if notice["collector_url"] != settings["collector_url"] {
|
||||
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
|
||||
notice["collector_url"], settings["collector_url"])
|
||||
}
|
||||
if notice["version"] == "" || notice["version"] == nil {
|
||||
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
|
||||
}
|
||||
}
|
||||
|
||||
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
|
||||
// ved næste login.
|
||||
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
|
||||
t.Fatalf("behold: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
|
||||
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
|
||||
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
s := beaconSettingsOf(t, ts)
|
||||
if s["enabled"] != false {
|
||||
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
|
||||
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
|
||||
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
|
||||
t.Errorf("en tom krop talte som et svar: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
|
||||
// svares der ingenting frem for et lille tal, ingen kan tolke.
|
||||
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
|
||||
"is_collector": true, "publish_count": true,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("bliv collector")
|
||||
}
|
||||
|
||||
// Instansen tæller sig selv, så der er én. Gulvet er højere.
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tælling: %d", resp.StatusCode)
|
||||
}
|
||||
if out["installs"] != nil {
|
||||
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
|
||||
}
|
||||
|
||||
// Sættes gulvet ned, står tallet der.
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
|
||||
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("sænk gulvet")
|
||||
}
|
||||
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
|
||||
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -332,7 +332,16 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
projectID = inbox
|
||||
}
|
||||
|
||||
created := 0
|
||||
// Hentet først, skrevet bagefter. To gennemløb frem for ét, fordi modellen
|
||||
// skal se hele bunken på én gang: ét spørgsmål for femogtyve mails frem for
|
||||
// femogtyve spørgsmål er den samme oplysning til en femogtyvendedel af
|
||||
// ventetiden — og et gennemløb, der tager fem minutter, overlapper med det
|
||||
// næste.
|
||||
type fetched struct {
|
||||
id string
|
||||
msg gmail.Message
|
||||
}
|
||||
var pending []fetched
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
@@ -340,13 +349,27 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, fetched{id: id, msg: msg})
|
||||
}
|
||||
|
||||
forAI := make([]mailForAI, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
forAI = append(forAI, mailForAI{
|
||||
From: gmail.SenderName(f.msg.From), Subject: f.msg.Subject, Snippet: f.msg.Snippet,
|
||||
})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
for i, f := range pending {
|
||||
id, msg := f.id, f.msg
|
||||
|
||||
subject := strings.TrimSpace(msg.Subject)
|
||||
if subject == "" {
|
||||
@@ -367,6 +390,9 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// key exists to prevent.
|
||||
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her mail.
|
||||
s.applyMailLine(ctx, user, task, written[i], sender+": "+subject)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Somebody got there first — an earlier sweep, or another
|
||||
|
||||
@@ -173,13 +173,22 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Ét spørgsmål til modellen for hele gennemløbet, inden der bliver skrevet
|
||||
// noget. Er der ingen model, er kortet tomt, og hver opgave hedder afsender og
|
||||
// emne, som den altid har gjort.
|
||||
forAI := make([]mailForAI, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
forAI = append(forAI, mailForAI{From: msg.From, Subject: msg.Subject, Snippet: msg.Snippet})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
for i, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
// below has to be written for it: a run that created tasks and forgot how
|
||||
@@ -206,6 +215,11 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// one server, and two accounts will hand out the same small numbers.
|
||||
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her. Emnet bliver
|
||||
// stående i beskrivelsen: har den misforstået mailen, skal det oprindelige
|
||||
// kunne læses ved siden af.
|
||||
s.applyMailLine(ctx, user, task, written[i], content)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Already a task. Still count it as read, or last_uid never moves
|
||||
|
||||
@@ -352,6 +352,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
r.Post("/ask", s.handleAIAsk)
|
||||
})
|
||||
|
||||
r.Get("/version", s.handleVersion)
|
||||
@@ -512,6 +529,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
|
||||
// gruppe som resten: den, der kan slå beaconet fra, er den, der
|
||||
// skal have beskeden, og at spørge alle andre ville være at
|
||||
// stille et spørgsmål, de ikke kan svare på.
|
||||
r.Get("/beacon/notice", s.handleBeaconNotice)
|
||||
r.Post("/beacon/notice", s.handleAckBeaconNotice)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"github.com/kristianwind/verdande/internal/update"
|
||||
)
|
||||
|
||||
// Besked til administratorerne, når der er kommet en ny version.
|
||||
//
|
||||
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
|
||||
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
|
||||
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
|
||||
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
|
||||
//
|
||||
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
|
||||
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
|
||||
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
|
||||
|
||||
const updateScope = "update"
|
||||
|
||||
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
|
||||
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
|
||||
// slukket, opdager det ved den første time, den er vågen.
|
||||
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
|
||||
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
|
||||
}
|
||||
|
||||
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
|
||||
// prøves uden at spørge GitHub om noget.
|
||||
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
|
||||
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
|
||||
// blive til en besked ad bagvejen.
|
||||
if status.Disabled || !status.Available || status.Latest == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, updateScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
|
||||
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
|
||||
// noget, også slået fra.
|
||||
if last, _ := stored["notified_version"].(string); last == status.Latest {
|
||||
return nil
|
||||
}
|
||||
|
||||
users, err := s.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := updateTitle(status.Latest)
|
||||
body := firstLineOf(status.Notes)
|
||||
for _, u := range users {
|
||||
if !u.IsAdmin {
|
||||
continue
|
||||
}
|
||||
s.notifyDirect(ctx, &store.Notification{
|
||||
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
|
||||
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
|
||||
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
|
||||
"notified_version": status.Latest,
|
||||
})
|
||||
}
|
||||
|
||||
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
|
||||
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
|
||||
// `kind` — se KINDS i Notifications.svelte.
|
||||
func updateTitle(version string) string {
|
||||
return "verdande " + version + " er klar"
|
||||
}
|
||||
|
||||
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
|
||||
//
|
||||
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
|
||||
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
|
||||
func firstLineOf(notes string) string {
|
||||
for _, line := range strings.Split(notes, "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
return firstWords(line, 30)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/update"
|
||||
)
|
||||
|
||||
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
|
||||
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
|
||||
// der var noget at se efter.
|
||||
func TestANewVersionTellsTheAdministrator(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
status := update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
|
||||
}
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatalf("besked om ny version: %v", err)
|
||||
}
|
||||
|
||||
list, unread := notifications(t, ts)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "update.available" {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
|
||||
// har ingen ordbog at slå sætningen op i.
|
||||
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
|
||||
t.Errorf("titel = %q", title)
|
||||
}
|
||||
|
||||
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
|
||||
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
|
||||
// noget, også slået fra.
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 1 {
|
||||
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
|
||||
}
|
||||
|
||||
// Og den næste version er en ny ting at få at vide.
|
||||
status.Latest = "v0.46.0"
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 2 {
|
||||
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
|
||||
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
|
||||
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "sofie@example.dk", "Sofie")
|
||||
|
||||
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if list, _ := notifications(t, other); len(list) != 0 {
|
||||
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 1 {
|
||||
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
|
||||
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, status := range []update.Status{
|
||||
{Current: "v0.44.0", Disabled: true},
|
||||
{Current: "v0.44.0", Available: false},
|
||||
{Current: "v0.44.0", Available: true, Latest: ""},
|
||||
} {
|
||||
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
|
||||
t.Fatalf("%v: %v", status, err)
|
||||
}
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
|
||||
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
|
||||
// klikkes.
|
||||
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
|
||||
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := firstOf(t, mustNotifications(t, ts))
|
||||
for _, key := range []string{"note_id", "task_id", "project_id"} {
|
||||
if v, ok := n[key]; ok && v != "" {
|
||||
t.Errorf("%s = %v, want ingenting", key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustNotifications(t *testing.T, ts *testServer) []any {
|
||||
t.Helper()
|
||||
list, _ := notifications(t, ts)
|
||||
if len(list) == 0 {
|
||||
t.Fatal("ingen beskeder")
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -49,6 +49,14 @@ type Runner struct {
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af
|
||||
// serveren af samme grund som SendBeacon: opsætningen, tidszonen og
|
||||
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
|
||||
DailyPlans func(ctx context.Context) error
|
||||
|
||||
// NotifyOfUpdates tells the administrators when a newer version is out.
|
||||
NotifyOfUpdates func(ctx context.Context) error
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
@@ -89,6 +97,13 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
// Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme
|
||||
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
|
||||
// syv er syv forskellige steder.
|
||||
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
|
||||
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
|
||||
// seks timer, og beskeden sendes én gang pr. version.
|
||||
r.every(ctx, "updates", time.Hour, r.updateNotices)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -123,6 +138,20 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *Runner) updateNotices(ctx context.Context) error {
|
||||
if r.NotifyOfUpdates == nil {
|
||||
return nil
|
||||
}
|
||||
return r.NotifyOfUpdates(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) dailyPlans(ctx context.Context) error {
|
||||
if r.DailyPlans == nil {
|
||||
return nil
|
||||
}
|
||||
return r.DailyPlans(ctx)
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
|
||||
@@ -51,6 +51,8 @@ nav:
|
||||
- Signing in: signing-in.md
|
||||
- Quick add: quick-add.md
|
||||
- Notes: notes.md
|
||||
- Notifications: notifications.md
|
||||
- Opening links: links.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- Connecting things:
|
||||
@@ -60,6 +62,8 @@ nav:
|
||||
- Mailboxes: mailboxes.md
|
||||
- Claude (MCP): mcp.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
|
||||
# file, and api.md links to it. A nav entry expects a page to render.
|
||||
- Reference:
|
||||
|
||||
@@ -28,5 +28,20 @@ setup('opret den første konto og log ind', async ({ page }) => {
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det bliver sagt uopfordret her —
|
||||
// det er hele grunden til, at et default-on er til at forsvare. Prøvet her,
|
||||
// fordi det er det eneste sted, en helt frisk installation bliver logget ind
|
||||
// på for første gang, og fordi beskeden ellers ville stå i vejen for hver
|
||||
// eneste prøve nedenfor og skulle klikkes væk uden at nogen havde set efter,
|
||||
// om den overhovedet sagde det rigtige.
|
||||
const notice = page.getByRole('region', { name: /melder, at den findes/ });
|
||||
await expect(notice).toBeVisible();
|
||||
// Værdierne står i beskeden, ikke ordet "anonymt": et løfte om telemetri er
|
||||
// præcis så meget værd som læserens mulighed for at efterprøve det.
|
||||
await expect(notice.getByText('instance_id')).toBeVisible();
|
||||
await expect(notice.getByText('version')).toBeVisible();
|
||||
await notice.getByRole('button', { name: 'Behold den' }).click();
|
||||
await expect(notice).toBeHidden();
|
||||
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
});
|
||||
|
||||
@@ -4272,3 +4272,91 @@ test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', asyn
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
@@ -280,6 +280,11 @@ export const api = {
|
||||
|
||||
beacon: () => get('/beacon/settings'),
|
||||
setBeacon: (body) => put('/beacon/settings', body),
|
||||
// Beskeden om, hvad installationen melder ind, og svaret på den. `keep` sendes
|
||||
// altid — en tom krop måtte ikke kunne betyde "slå det fra", og serveren
|
||||
// afviser den derfor.
|
||||
beaconNotice: () => get('/beacon/notice'),
|
||||
answerBeaconNotice: (keep) => post('/beacon/notice', { keep }),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
@@ -477,6 +482,23 @@ export const api = {
|
||||
setAISettings: (data) => put('/ai/settings', data),
|
||||
aiSummary: () => post('/ai/summary'),
|
||||
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
||||
// Forslag frem for skrivninger: de to første spørger, de to sidste er svaret
|
||||
// på et ja. Linjen sendes med, som den står i feltet — den kan være rettet i
|
||||
// hånden, inden den blev accepteret, og skal læses på samme måde som en, der
|
||||
// ikke blev rørt.
|
||||
aiTidyInbox: () => post('/ai/inbox/tidy'),
|
||||
aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }),
|
||||
aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`),
|
||||
aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }),
|
||||
// Spørg om sine egne noter og opgaver. Basen finder kandidaterne, modellen
|
||||
// læser dem — svaret bærer, hvad det står på.
|
||||
aiAsk: (question) => post('/ai/ask', { question }),
|
||||
// Dagens plan om morgenen.
|
||||
planSettings: () => get('/ai/plan'),
|
||||
setPlanSettings: (body) => put('/ai/plan', body),
|
||||
// `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'),
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script>
|
||||
/**
|
||||
* En liste af forslag, man siger ja eller nej til, ét ad gangen.
|
||||
*
|
||||
* Den samme rude to steder — oprydning i indbakken og opgaver ud af en note —
|
||||
* fordi det er den samme handling: en model har foreslået en linje, og et
|
||||
* menneske bestemmer. To ruder ville betyde to svar på "hvad sker der, når
|
||||
* modellen tager fejl", og det spørgsmål har kun ét rigtigt svar.
|
||||
*
|
||||
* Forslaget står i et felt, man kan skrive i, frem for i en etiket. Det er
|
||||
* hele forskellen mellem at tage imod og at bestemme: et forslag, der er tæt
|
||||
* på, bliver rettet på to tegn i stedet for at blive kasseret — og linjen
|
||||
* læses af den samme parser bagefter, uanset om den blev rørt.
|
||||
*
|
||||
* `why` står under linjen, fordi et forkert gæt skal kunne gennemskues. "Fordi
|
||||
* der står 'inden fredag' i noten" er til at være uenig i; en dato, der bare
|
||||
* dukkede op, er ikke.
|
||||
*/
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
|
||||
let { title, hint, load, apply, onclose } = $props();
|
||||
|
||||
let state = $state('loading'); // loading | ready | failed
|
||||
let error = $state('');
|
||||
let rows = $state([]);
|
||||
let busy = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
let alive = true;
|
||||
state = 'loading';
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
rows = (r?.suggestions ?? []).map((s, i) => ({ ...s, key: i, line: s.line, done: false }));
|
||||
state = 'ready';
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
error = humanMessage(e);
|
||||
state = 'failed';
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
async function accept(row) {
|
||||
if (busy !== null) return;
|
||||
busy = row.key;
|
||||
try {
|
||||
await apply(row, row.line);
|
||||
rows = rows.map((r) => (r.key === row.key ? { ...r, done: true } : r));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function skip(row) {
|
||||
rows = rows.filter((r) => r.key !== row.key);
|
||||
}
|
||||
|
||||
const left = $derived(rows.filter((r) => !r.done).length);
|
||||
</script>
|
||||
|
||||
<section class="suggest">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<button class="close" onclick={onclose} aria-label={t('detail.close')}>×</button>
|
||||
</header>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<p class="hint">{t('ai.thinking')}</p>
|
||||
{:else if state === 'failed'}
|
||||
<p class="hint bad">{error}</p>
|
||||
{:else if !rows.length}
|
||||
<p class="hint">{t('ai.nothingToSuggest')}</p>
|
||||
{:else}
|
||||
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||
<ul>
|
||||
{#each rows as row (row.key)}
|
||||
<li class:done={row.done}>
|
||||
<input
|
||||
value={row.line}
|
||||
oninput={(e) => (row.line = e.currentTarget.value)}
|
||||
disabled={row.done}
|
||||
aria-label={title}
|
||||
/>
|
||||
{#if row.why}<p class="why">{row.why}</p>{/if}
|
||||
{#if !row.done}
|
||||
<div class="row">
|
||||
<button class="button" disabled={busy !== null} onclick={() => accept(row)}>
|
||||
{t('ai.accept')}
|
||||
</button>
|
||||
<button class="secondary" onclick={() => skip(row)}>{t('ai.skip')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="why ok">{t('ai.applied')}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if !left}
|
||||
<p class="hint">{t('ai.allDone')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.suggest {
|
||||
margin: var(--s2) 0;
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised, var(--surface));
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.close {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint.bad {
|
||||
color: var(--danger, var(--ink));
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
li input {
|
||||
width: 100%;
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--s1);
|
||||
}
|
||||
|
||||
li.done input {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Grunden til forslaget, under det. Mindre end linjen selv: det er baggrund
|
||||
for en beslutning, ikke selve beslutningen. */
|
||||
.why {
|
||||
margin: 2px 0 var(--s1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.why.ok {
|
||||
color: var(--ok, var(--ink-muted));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
/**
|
||||
* Beskeden om, at denne installation melder ind — sagt uopfordret, første gang
|
||||
* en administrator er logget ind.
|
||||
*
|
||||
* Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
* bliver sagt af sig selv. Siden under Indstillinger → Data siger det rigtigt
|
||||
* og fyldestgørende, og den bliver åbnet af dem, der i forvejen har en
|
||||
* mistanke om, at der er noget at læse; for alle andre er "du kan slå den fra"
|
||||
* et tilbud til nogen, der ikke ved, der er noget at slå fra.
|
||||
*
|
||||
* De tre værdier står i beskeden — det rigtige id, den rigtige version, den
|
||||
* rigtige adresse — frem for ordene "anonyme data". Et løfte om telemetri er
|
||||
* præcis så meget værd som læserens mulighed for at efterprøve det.
|
||||
*
|
||||
* Kun til administratorer, og hentet på `app.user` frem for i onMount: en
|
||||
* indlæsning, der kører, mens login-siden står, kører aldrig for den, der lige
|
||||
* er logget ind — og så er beskeden aldrig sagt til dem, den er skrevet til.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
|
||||
let notice = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const me = app.user;
|
||||
if (!me?.is_admin) {
|
||||
notice = null;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
api
|
||||
.beaconNotice()
|
||||
.then((r) => {
|
||||
if (alive && r?.pending) notice = r;
|
||||
})
|
||||
.catch(() => {
|
||||
// En besked, der ikke kunne hentes, må ikke stå i vejen for appen.
|
||||
// Den er stadig ubesvaret næste gang, og bliver stillet igen.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
async function answer(keep) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.answerBeaconNotice(keep);
|
||||
notice = null;
|
||||
app.toast(keep ? t('beacon.noticeKept') : t('beacon.noticeStopped'));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if notice}
|
||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||
<h2>{t('beacon.noticeTitle')}</h2>
|
||||
<p>{t('beacon.noticeBody')}</p>
|
||||
|
||||
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||
<pre>{JSON.stringify(
|
||||
{ instance_id: notice.instance_id, version: notice.version },
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
<p class="to">{t('beacon.noticeTo', { url: notice.collector_url })}</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="button" disabled={busy} onclick={() => answer(true)}>
|
||||
{t('beacon.noticeKeep')}
|
||||
</button>
|
||||
<button class="secondary" disabled={busy} onclick={() => answer(false)}>
|
||||
{t('beacon.noticeStop')}
|
||||
</button>
|
||||
<a href="/indstillinger/data">{t('beacon.noticeMore')}</a>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Øverst i indholdet, i flowet — ikke svævende over det. En rude, der ligger
|
||||
oven på arbejdet, dækker knapper, og en besked, der er i vejen, bliver
|
||||
klikket væk uden at blive læst. Den her skubber i stedet, står til den
|
||||
bliver besvaret, og forsvinder så for altid. */
|
||||
.notice {
|
||||
margin: 0 0 var(--s3);
|
||||
max-width: 44rem;
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg, var(--radius));
|
||||
background: var(--surface-raised, var(--surface));
|
||||
box-shadow: var(--shadow-lg, 0 10px 30px rgb(0 0 0 / 0.25));
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.notice h2 {
|
||||
margin: 0 0 var(--s1);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
margin: 0 0 var(--s2);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notice pre {
|
||||
margin: 0 0 var(--s1);
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-sunken, var(--surface));
|
||||
font-size: var(--text-xs);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notice .to {
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row a {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@
|
||||
import { api } from '$lib/api.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
@@ -11,6 +13,11 @@
|
||||
let projects = $state([]);
|
||||
let notes = $state([]);
|
||||
let selected = $state(0);
|
||||
// Svaret på et spørgsmål, når nogen har stillet et. Søgningen finder det, der
|
||||
// indeholder ordene; det her svarer på spørgsmålet — og de to bor i det samme
|
||||
// felt, fordi man ikke skal vide på forhånd, hvilken slags spørgsmål man har.
|
||||
let answer = $state(null);
|
||||
let asking = $state(false);
|
||||
let input;
|
||||
let controller = null;
|
||||
let timer = null;
|
||||
@@ -20,6 +27,7 @@
|
||||
query = '';
|
||||
tasks = [];
|
||||
projects = [];
|
||||
answer = null;
|
||||
selected = 0;
|
||||
queueMicrotask(() => input?.focus());
|
||||
}
|
||||
@@ -34,8 +42,12 @@
|
||||
tasks = [];
|
||||
projects = [];
|
||||
notes = [];
|
||||
answer = null;
|
||||
return;
|
||||
}
|
||||
// Et nyt bogstav gør det forrige svar forældet. Et svar, der bliver
|
||||
// stående, mens spørgsmålet ændrer sig, er et svar på noget andet.
|
||||
answer = null;
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
|
||||
@@ -68,6 +80,32 @@
|
||||
}))
|
||||
]);
|
||||
|
||||
/**
|
||||
* Spørg om sine egne noter og opgaver.
|
||||
*
|
||||
* Bag en tast frem for automatisk: det koster et kald til en model, og de
|
||||
* fleste ⌘K er nogen, der leder efter en note, de kender navnet på.
|
||||
*/
|
||||
async function ask() {
|
||||
const question = query.trim();
|
||||
if (!question || asking) return;
|
||||
asking = true;
|
||||
answer = null;
|
||||
try {
|
||||
answer = await api.aiAsk(question);
|
||||
} catch (e) {
|
||||
answer = { answer: '', error: humanMessage(e), sources: [] };
|
||||
} finally {
|
||||
asking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSource(source) {
|
||||
open = false;
|
||||
if (source.kind === 'note') return goto(`/noter?note=${source.id}`);
|
||||
app.openDetail(source.id);
|
||||
}
|
||||
|
||||
function choose(item) {
|
||||
open = false;
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
@@ -89,6 +127,12 @@
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
// ⌘⏎ spørger, ⏎ åbner det valgte. Den, der leder efter en note, skal
|
||||
// ikke vente på en model for at komme hen til den.
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
ask();
|
||||
break;
|
||||
}
|
||||
if (results[selected]) choose(results[selected]);
|
||||
break;
|
||||
}
|
||||
@@ -110,6 +154,39 @@
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
{#if query.trim()}
|
||||
<div class="askrow">
|
||||
<button class="ask" onclick={ask} disabled={asking}>
|
||||
{asking ? t('ai.thinking') : t('ai.askThis')}
|
||||
</button>
|
||||
<kbd>⌘⏎</kbd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if answer}
|
||||
<div class="answer">
|
||||
{#if answer.error}
|
||||
<p class="bad">{answer.error}</p>
|
||||
{:else if answer.answer}
|
||||
<p>{answer.answer}</p>
|
||||
{#if answer.sources?.length}
|
||||
<!-- Hvad svaret står på. En model, der siger "det gjorde du
|
||||
den 14.", er kun brugbar, hvis man kan slå op i det, den
|
||||
læste det i. -->
|
||||
<p class="sources">
|
||||
{t('ai.askSources')}
|
||||
{#each answer.sources as source, i (source.kind + source.id)}<button
|
||||
class="source"
|
||||
onclick={() => openSource(source)}>{source.title}</button
|
||||
>{i < answer.sources.length - 1 ? ', ' : ''}{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="bad">{t('ai.askNothing')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if results.length}
|
||||
<ul>
|
||||
{#each results as item, i (item.kind + item.id)}
|
||||
@@ -140,6 +217,62 @@
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Spørgsmålsknappen står under feltet og fylder ikke: de fleste ⌘K er nogen,
|
||||
der leder efter en note, de kender navnet på, og skal ikke forbi en model
|
||||
for at komme derhen. */
|
||||
.askrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ask {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ask:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.answer {
|
||||
padding: var(--s2);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.answer p {
|
||||
margin: 0 0 var(--s1);
|
||||
}
|
||||
|
||||
.answer .bad {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.answer .sources {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.source {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Not centred: a palette pinned near the top does not jump as results
|
||||
appear, and lands where the eye already is after ⌘K. */
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
'note.changed': 'notif.noteChanged',
|
||||
'note.shared': 'notif.noteShared',
|
||||
'note.mention': 'notif.mention',
|
||||
'daily.plan': 'notif.dailyPlan',
|
||||
'update.available': 'notif.update',
|
||||
comment: 'notif.comment'
|
||||
};
|
||||
|
||||
@@ -51,6 +53,12 @@
|
||||
function follow(n) {
|
||||
open = false;
|
||||
if (!n.read) app.markRead(n.id);
|
||||
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
||||
// til at gøre noget ved den sidder.
|
||||
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}`);
|
||||
else if (n.task_id) app.openDetail(n.task_id);
|
||||
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||
@@ -106,7 +114,10 @@
|
||||
<li>
|
||||
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
@@ -255,6 +266,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about.full {
|
||||
white-space: pre-line;
|
||||
text-overflow: clip;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ago {
|
||||
font-size: var(--text-xs);
|
||||
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;
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export const da = {
|
||||
'notif.noteChanged': '{name} rettede en note',
|
||||
'notif.noteShared': '{name} delte en note med dig',
|
||||
'notif.mention': '{name} nævnte dig i en note',
|
||||
'notif.dailyPlan': 'Dagens plan',
|
||||
'notif.update': 'Der er kommet en ny version',
|
||||
'notif.comment': '{name} kommenterede',
|
||||
'notif.justNow': 'lige nu',
|
||||
'notif.minutes': '{n} min. siden',
|
||||
@@ -77,6 +79,53 @@ export const da = {
|
||||
'notes.sortUpdated': 'Senest rørt',
|
||||
'notes.sortCreated': 'Oprettet',
|
||||
'notes.sortTitle': 'Navn',
|
||||
// AI'ens forslag. Ordene skal gøre det klart, at der bliver foreslået og ikke
|
||||
// rettet: den, der siger ja, er den, der bestemmer.
|
||||
'ai.tidyInbox': 'Ryd op i indbakken',
|
||||
'ai.tidyTitle': 'Forslag til indbakken',
|
||||
'ai.tidyHint':
|
||||
'Sådan kunne linjerne have set ud, hvis de var skrevet færdig. Ret i dem, du vil rette i — der sker først noget, når du siger ja.',
|
||||
'ai.findActions': 'Find opgaver i noten',
|
||||
'ai.actionsTitle': 'Opgaver i noten',
|
||||
'ai.actionsHint':
|
||||
'Det, nogen har lovet eller er blevet bedt om i teksten. Citatet under hver linje er de ord, den kom fra.',
|
||||
'ai.askThis': 'Spørg om det her',
|
||||
'ai.askSources': 'Fra:',
|
||||
'ai.askNothing': 'Der er ikke noget i dine noter eller opgaver, der svarer på det.',
|
||||
'ai.plan': 'Dagens plan',
|
||||
'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.',
|
||||
'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.planSent': 'Sendt — kig i klokken.',
|
||||
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
||||
'ai.thinking': 'Læser …',
|
||||
'ai.nothingToSuggest': 'Der er ikke noget at foreslå.',
|
||||
'ai.accept': 'Tilføj',
|
||||
'ai.skip': 'Nej tak',
|
||||
'ai.applied': 'Gjort.',
|
||||
'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.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',
|
||||
@@ -90,6 +139,22 @@ export const da = {
|
||||
'beacon.active7': 'Aktive 7 dage',
|
||||
'beacon.active30': 'Aktive 30 dage',
|
||||
'beacon.byVersion': 'Efter version (30 dage):',
|
||||
// Beskeden, der bliver sagt uopfordret første gang. Værdierne står i den, ikke
|
||||
// ordet 'anonymt': et løfte om telemetri er præcis så meget værd som
|
||||
// læserens mulighed for at efterprøve det.
|
||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||
'beacon.noticeBody':
|
||||
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'beacon.noticeTo': 'Sendes til {url}',
|
||||
'beacon.noticeKeep': 'Behold den',
|
||||
'beacon.noticeStop': 'Slå den fra',
|
||||
'beacon.noticeMore': 'Læs mere',
|
||||
'beacon.noticeKept': 'Tak. Du kan altid slå den fra under Indstillinger → Data.',
|
||||
'beacon.noticeStopped': 'Meldingen er slået fra. Der bliver ikke sendt noget.',
|
||||
'beacon.lastError': 'Sidste forsøg nåede ikke frem ({when}): {why}',
|
||||
'beacon.publishMin': 'Offentliggør først fra',
|
||||
'beacon.publishMinHint':
|
||||
'Under det tal svares der ingenting. Et lille tal fortæller mest om, hvor lidt der skal til at flytte det — og et, der falder, ville blive læst som “færre bruger det nu”, også når det var en udrulning, der gik galt.',
|
||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.newHere': 'Ny note',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const en = {
|
||||
'notif.noteChanged': '{name} edited a note',
|
||||
'notif.noteShared': '{name} shared a note with you',
|
||||
'notif.mention': '{name} mentioned you in a note',
|
||||
'notif.dailyPlan': "Today's plan",
|
||||
'notif.update': 'A new version is out',
|
||||
'notif.comment': '{name} commented',
|
||||
'notif.justNow': 'just now',
|
||||
'notif.minutes': '{n} min ago',
|
||||
@@ -74,6 +76,53 @@ export const en = {
|
||||
'notes.sortUpdated': 'Last touched',
|
||||
'notes.sortCreated': 'Created',
|
||||
'notes.sortTitle': 'Name',
|
||||
// The AI's suggestions. The words have to make it plain that this proposes
|
||||
// rather than edits: whoever says yes is the one deciding.
|
||||
'ai.tidyInbox': 'Tidy up the inbox',
|
||||
'ai.tidyTitle': 'Suggestions for the inbox',
|
||||
'ai.tidyHint':
|
||||
'How the lines could have read if they had been finished. Edit the ones you want to edit — nothing happens until you say yes.',
|
||||
'ai.findActions': 'Find tasks in the note',
|
||||
'ai.actionsTitle': 'Tasks in the note',
|
||||
'ai.actionsHint':
|
||||
'What somebody promised or was asked to do in the text. The quote under each line is the words it came from.',
|
||||
'ai.askThis': 'Ask about this',
|
||||
'ai.askSources': 'From:',
|
||||
'ai.askNothing': 'There is nothing in your notes or tasks that answers that.',
|
||||
'ai.plan': "Today's plan",
|
||||
'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.',
|
||||
'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.planSent': 'Sent — look in the bell.',
|
||||
'ai.planNothing': 'There is nothing to plan for today.',
|
||||
'ai.thinking': 'Reading …',
|
||||
'ai.nothingToSuggest': 'There is nothing to suggest.',
|
||||
'ai.accept': 'Add',
|
||||
'ai.skip': 'No thanks',
|
||||
'ai.applied': 'Done.',
|
||||
'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.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',
|
||||
@@ -87,6 +136,22 @@ export const en = {
|
||||
'beacon.active7': 'Active 7 days',
|
||||
'beacon.active30': 'Active 30 days',
|
||||
'beacon.byVersion': 'By version (30 days):',
|
||||
// The notice said unprompted the first time. The values are in it rather than
|
||||
// the word 'anonymous': a promise about telemetry is worth exactly as much as
|
||||
// the reader's ability to check it.
|
||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||
'beacon.noticeBody':
|
||||
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'beacon.noticeTo': 'Sent to {url}',
|
||||
'beacon.noticeKeep': 'Keep it on',
|
||||
'beacon.noticeStop': 'Turn it off',
|
||||
'beacon.noticeMore': 'Read more',
|
||||
'beacon.noticeKept': 'Thank you. You can always turn it off under Settings → Data.',
|
||||
'beacon.noticeStopped': 'Reporting is off. Nothing is being sent.',
|
||||
'beacon.lastError': 'The last attempt did not arrive ({when}): {why}',
|
||||
'beacon.publishMin': 'Publish only from',
|
||||
'beacon.publishMinHint':
|
||||
'Below that number nothing is answered. A small number mostly says how little it takes to move it — and one that falls would read as “fewer people use this now”, even when it was a release that went wrong.',
|
||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.newHere': 'New note',
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import { t } from '$lib/i18n.svelte.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';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -54,6 +56,37 @@
|
||||
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,
|
||||
// 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.
|
||||
@@ -124,7 +157,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
<svelte:window
|
||||
onclick={onLinkClick} {onkeydown} />
|
||||
|
||||
{#if app.loading}
|
||||
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
||||
@@ -192,6 +226,13 @@
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Sagt uopfordret, første gang en administrator er logget ind: hvad
|
||||
denne installation melder ind, og hvordan man siger nej.
|
||||
Øverst i indholdet og ikke svævende over det — en rude, der
|
||||
ligger oven på arbejdet, dækker knapper, og en besked, der er i
|
||||
vejen, bliver klikket væk uden at blive læst. Komponenten tegner
|
||||
ingenting, når der ikke er noget at fortælle. -->
|
||||
<BeaconNotice />
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
|
||||
let loaded = $state(false);
|
||||
|
||||
@@ -17,6 +18,72 @@
|
||||
).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() {
|
||||
const data = await api.today();
|
||||
app.tasks = [...data.overdue, ...data.today];
|
||||
@@ -54,6 +121,33 @@
|
||||
|
||||
<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}
|
||||
<section>
|
||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||
@@ -93,6 +187,77 @@
|
||||
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 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
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 { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||
import { supported, register } from '$lib/passkey.js';
|
||||
@@ -8,6 +14,20 @@
|
||||
|
||||
// --- 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 timezone = $state('');
|
||||
let locale = $state('da');
|
||||
@@ -401,6 +421,49 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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">
|
||||
<header>
|
||||
<h2>{t('account.password')}</h2>
|
||||
|
||||
@@ -534,6 +534,18 @@
|
||||
<pre class="payload">{t('beacon.exactly')}
|
||||
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
|
||||
|
||||
{#if beacon.last_error}
|
||||
<!-- Et beacon, der ikke kan nå sin collector, fejler ellers i fuldstændig
|
||||
tavshed: det prøver igen i morgen, og "sidst sendt" bliver bare ved
|
||||
med at blive ældre. Ikke en fejl, der skal afbryde nogen — en linje,
|
||||
hvor siden ellers påstår, at alt er sendt. -->
|
||||
<p class="hint">
|
||||
{t('beacon.lastError', {
|
||||
when: new Date(beacon.last_error_at).toLocaleString(tag()),
|
||||
why: beacon.last_error
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if beacon.last_ping_at}
|
||||
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
|
||||
{/if}
|
||||
@@ -592,6 +604,20 @@
|
||||
/>
|
||||
<span>{t('beacon.publish')}</span>
|
||||
</label>
|
||||
|
||||
{#if beacon.publish_count}
|
||||
<div class="field">
|
||||
<label for="publishmin">{t('beacon.publishMin')}</label>
|
||||
<input
|
||||
id="publishmin"
|
||||
type="number"
|
||||
min="0"
|
||||
value={beacon.publish_min}
|
||||
onchange={(e) => saveBeacon({ publish_min: Number(e.currentTarget.value) })}
|
||||
/>
|
||||
<p class="hint">{t('beacon.publishMinHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">{t('beacon.publishHint')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -86,6 +86,39 @@
|
||||
|
||||
// --- the list -----------------------------------------------------------------
|
||||
|
||||
// Dagens plan: én besked om morgenen om det, der venter. Slået fra som
|
||||
// udgangspunkt — en besked klokken syv, man ikke har bedt om, er ikke en hjælp.
|
||||
let plan = $state(null);
|
||||
let planBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.planSettings()
|
||||
.then((p) => (plan = p))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function savePlan(change) {
|
||||
try {
|
||||
plan = await api.setPlanSettings(change);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPlanNow() {
|
||||
if (planBusy) return;
|
||||
planBusy = true;
|
||||
try {
|
||||
const r = await api.planNow();
|
||||
app.toast(r.sent ? t('ai.planSent') : t('ai.planNothing'));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
planBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let notifications = $state([]);
|
||||
let unread = $state(0);
|
||||
let loading = $state(true);
|
||||
@@ -180,6 +213,45 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('ai.plan')}</h2>
|
||||
<p class="hint">{t('ai.planHint')}</p>
|
||||
</header>
|
||||
|
||||
{#if plan}
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.enabled}
|
||||
onchange={(e) => savePlan({ enabled: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('ai.plan')}</span>
|
||||
</label>
|
||||
|
||||
{#if plan.enabled}
|
||||
<div class="field">
|
||||
<label for="planhour">{t('ai.planHour')}</label>
|
||||
<select
|
||||
id="planhour"
|
||||
value={plan.hour}
|
||||
onchange={(e) => savePlan({ hour: Number(e.currentTarget.value) })}
|
||||
>
|
||||
{#each Array.from({ length: 24 }, (_, i) => i) as hour (hour)}
|
||||
<option value={hour}>{String(hour).padStart(2, '0')}:00</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- En indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
er en indstilling, ingen tør slå til. -->
|
||||
<button class="secondary" disabled={planBusy} onclick={sendPlanNow}>
|
||||
{t('ai.planNow')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('push.title')}</h2>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||
|
||||
let notes = $state([]);
|
||||
|
||||
@@ -207,6 +208,9 @@
|
||||
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
|
||||
// frem, og det skal kunne kopieres frem for at forsvinde.
|
||||
let shareInviteLink = $state('');
|
||||
// Opgaver, der ligger i notens tekst. Lukket, indtil nogen spørger: det koster
|
||||
// et kald til en model, og de fleste noter er ikke referater.
|
||||
let showActions = $state(false);
|
||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||
|
||||
// Dem, et `@` kan betyde i den åbne note.
|
||||
@@ -246,8 +250,9 @@
|
||||
|
||||
$effect(() => {
|
||||
const id = selected?.id;
|
||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
||||
// under den nye.
|
||||
// Nulstil ved hvert skift, så den forrige notes folk — og den forrige notes
|
||||
// forslag — ikke står et øjeblik under den nye.
|
||||
showActions = false;
|
||||
shares = [];
|
||||
shareCandidates = [];
|
||||
shareFollows = [];
|
||||
@@ -1086,6 +1091,16 @@
|
||||
onsave={save}
|
||||
/>
|
||||
|
||||
{#if showActions}
|
||||
<AISuggestions
|
||||
title={t('ai.actionsTitle')}
|
||||
hint={t('ai.actionsHint')}
|
||||
load={() => api.aiNoteActions(selected.id)}
|
||||
apply={(row, line) => api.aiCreateFromNote(selected.id, line)}
|
||||
onclose={() => (showActions = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||
{#if links.length}
|
||||
@@ -1274,6 +1289,12 @@
|
||||
{/if}
|
||||
|
||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||
<!-- Det, nogen har lovet i et referat, står i teksten og ikke på
|
||||
listen. Knappen finder det frem som forslag; opgaverne bliver
|
||||
til noget, når man siger ja til dem. -->
|
||||
<button class="button" onclick={() => (showActions = !showActions)}>
|
||||
{t('ai.findActions')}
|
||||
</button>
|
||||
{#if ownsSelected}
|
||||
<!-- Laid away, not thrown away: archive is the note leaving the list
|
||||
without leaving the account, and it reads "bring it back" once it
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||
import BoardView from '$lib/components/BoardView.svelte';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
@@ -31,6 +32,10 @@
|
||||
let members = $state([]);
|
||||
let showShare = $state(false);
|
||||
let showLog = $state(false);
|
||||
// Oprydningen i indbakken. Kun dér: det er den ene liste, der bliver fyldt med
|
||||
// linjer, ingen nåede at skrive færdig, og derfor den ene, hvor det er værd at
|
||||
// spørge en model om, hvordan de skulle have set ud.
|
||||
let showTidy = $state(false);
|
||||
let activity = $state([]);
|
||||
let inviteEmail = $state('');
|
||||
let inviteRole = $state('editor');
|
||||
@@ -613,6 +618,18 @@
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
|
||||
{#if project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showTidy = !showTidy;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{t('ai.tidyInbox')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
@@ -665,6 +682,18 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showTidy}
|
||||
<!-- Forslag, ikke rettelser. Hver linje står i et felt, man kan skrive
|
||||
i, og sker først, når nogen siger ja til den. -->
|
||||
<AISuggestions
|
||||
title={t('ai.tidyTitle')}
|
||||
hint={t('ai.tidyHint')}
|
||||
load={() => api.aiTidyInbox()}
|
||||
apply={(row, line) => api.aiApplyToTask(row.task_id, line)}
|
||||
onclose={() => (showTidy = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showLog}
|
||||
<div class="panel">
|
||||
<ul class="log">
|
||||
|
||||
Reference in New Issue
Block a user