Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 40102d6038 feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt.

Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive
dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne
hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu
er det ét spørgsmål og et ja pr. linje.

Og 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.

Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks,
og forslaget bliver til noget, når nogen siger ja. 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.

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 — også når linjen er blevet rettet i
hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man
selv skriver i, kan læses og rettes af den, det bliver stillet til.

Modellen får listen over de projekter, der findes, og må ikke nævne
andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et
uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en
opgave hen, hvor ejeren ikke leder efter den. Testet.

Forslagene til indbakken bærer et nummer tilbage til den opgave, de
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æt en dato på den her" ville lande på
en anden.

Noten bliver ikke rørt, når der laves opgaver ud af den. 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å. Opgaven bærer i stedet, hvor den kom fra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:48:56 +02:00
17 changed files with 1120 additions and 642 deletions
+134 -83
View File
@@ -1564,15 +1564,7 @@ paths:
schema:
type: object
properties:
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.
installs: { type: integer }
window_days: { type: integer }
"404":
description: Not a collector, or not published.
@@ -1598,19 +1590,7 @@ 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.
@@ -1630,74 +1610,12 @@ 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]
@@ -3532,6 +3450,124 @@ 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/mcp:
post:
tags: [integrations]
@@ -4315,6 +4351,21 @@ components:
note_id: { type: string }
title: { type: string }
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: >
+126
View File
@@ -395,6 +395,132 @@ 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)
}
// 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"
}
// 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.
+370
View File
@@ -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], " ") + " …"
}
+189
View File
@@ -0,0 +1,189 @@
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
}
+13 -191
View File
@@ -20,11 +20,6 @@ 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".
@@ -35,33 +30,6 @@ 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
@@ -72,9 +40,7 @@ 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, PublishMin: defaultPublishMin,
}
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
@@ -99,21 +65,6 @@ 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()
@@ -132,31 +83,20 @@ 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"`
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"`
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"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
@@ -169,15 +109,10 @@ 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 {
@@ -194,7 +129,6 @@ 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) {
@@ -217,13 +151,6 @@ 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 == "" {
@@ -310,16 +237,6 @@ 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
@@ -352,15 +269,11 @@ func (s *Server) SendBeacon(ctx context.Context) error {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
return s.beaconSent(ctx, cfg)
cfg.LastPingAt = time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
// 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 {
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
return nil
}
@@ -376,100 +289,9 @@ 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)
// 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 nil
}
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})
}
-132
View File
@@ -1,132 +0,0 @@
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
}
+7 -6
View File
@@ -352,6 +352,13 @@ 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)
})
r.Get("/version", s.handleVersion)
@@ -512,12 +519,6 @@ 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.
-15
View File
@@ -28,20 +28,5 @@ 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 });
});
+8 -5
View File
@@ -280,11 +280,6 @@ 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'),
@@ -482,6 +477,14 @@ 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 }),
version: () => get('/version'),
+189
View File
@@ -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>
-142
View File
@@ -1,142 +0,0 @@
<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>
+16 -16
View File
@@ -77,6 +77,22 @@ 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.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.',
'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,22 +106,6 @@ 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',
+16 -16
View File
@@ -74,6 +74,22 @@ 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.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.',
'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,22 +103,6 @@ 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',
-8
View File
@@ -11,7 +11,6 @@
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
let { children } = $props();
@@ -193,13 +192,6 @@
</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>
@@ -534,18 +534,6 @@
<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}
@@ -604,20 +592,6 @@
/>
<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}
+23 -2
View File
@@ -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
+29
View File
@@ -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">