Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
445d9de57a | ||
|
|
d5ad459eba | ||
|
|
c297d256e4 | ||
|
|
d063425cae | ||
|
|
0764fbd566 | ||
|
|
b041b32aa2 | ||
|
|
d8f5f6e236 | ||
|
|
61634a6bb5 |
@@ -2718,6 +2718,25 @@ paths:
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/MailAddress" }
|
||||
|
||||
/api/v1/hook-url:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Your personal address for pushing a task in from somewhere else.
|
||||
description: >
|
||||
Minted on first ask. Its own token rather than the mail one: the two are
|
||||
the same kind of secret, but they leak separately — 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.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/HookURL" }
|
||||
|
||||
/api/v1/hook-url/rotate:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Issue a new push address, retiring the old one at once.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/HookURL" }
|
||||
|
||||
/api/v1/notifications:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -3017,6 +3036,12 @@ paths:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Write a note.
|
||||
description: >
|
||||
`#Projekt` files the note against a project and `[[Another note]]` links to
|
||||
one. A third, `@Name`, shares the note with that person as a viewer and
|
||||
tells them — only the owner's mentions share, and only a name that has just
|
||||
appeared in the text counts, so a note saved on every typing pause does not
|
||||
share itself twice.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3080,6 +3105,11 @@ paths:
|
||||
patch:
|
||||
tags: [notes]
|
||||
summary: Change a note. Every field is optional; what is sent is what changes.
|
||||
description: >
|
||||
A name written with an `@` that was not in the text before shares the note
|
||||
with that person as a viewer and tells them. Only the owner's mentions
|
||||
share — an editor may write the name, but giving somebody else's note away
|
||||
is the owner's alone — and a role already granted is never lowered by one.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3741,6 +3771,69 @@ paths:
|
||||
task_id: { type: string }
|
||||
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
||||
|
||||
/inbound/hook/{token}:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Push a task into somebody's inbox from another program.
|
||||
description: |
|
||||
The token in the path is the whole credential — the caller is a shortcut on
|
||||
a phone or a script, and neither can sign in. A token here can do one
|
||||
thing: create a task in that person's inbox. An API token can do everything,
|
||||
which is why this is not one.
|
||||
|
||||
The body is read three ways, because those are the three you meet: JSON from
|
||||
a service, a form field from a webhook form, and plain text from a shortcut
|
||||
that just sends what was selected. The first line becomes the task and the
|
||||
rest sits underneath it, and the line goes through the quick-add parser — so
|
||||
"Ring til Anders i morgen p1 #Firma" means here what it means in the box at
|
||||
the top of the app.
|
||||
|
||||
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.
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
description: First line the task, the rest its description.
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
text: { type: string }
|
||||
title: { description: Same as text. }
|
||||
content: { description: Same as text. }
|
||||
note: { type: string }
|
||||
body: { description: Same as note. }
|
||||
description: { description: Same as note. }
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
text: { type: string }
|
||||
note: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
content: { type: string }
|
||||
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { description: Too many pushes; the address is rate limited. }
|
||||
|
||||
/oauth/gmail/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -4007,6 +4100,15 @@ components:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
HookURL:
|
||||
description: The address another program pushes to.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
|
||||
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
|
||||
// prøves her.
|
||||
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
// hookURL henter personens egen krog-adresse, som fladen viser den.
|
||||
func (ts *testServer) hookURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
|
||||
}
|
||||
url, _ := out["url"].(string)
|
||||
if !strings.Contains(url, "/inbound/hook/") {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
|
||||
// skal kunne kopieres ind i en genvej på en telefon.
|
||||
func hookPath(url string) string {
|
||||
return url[strings.Index(url, "/inbound/hook/"):]
|
||||
}
|
||||
|
||||
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
|
||||
// betyder det samme, som den ville betyde i feltet øverst i fladen.
|
||||
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
resp, out := anon.doRaw(t, "POST", path, "text/plain",
|
||||
"Ring til Anders p1\nhan ringede i går")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["content"] == "Ring til Anders" {
|
||||
found = task
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
|
||||
}
|
||||
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
|
||||
// teksten, og resten af kroppen står under opgaven.
|
||||
if found["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", found["priority"])
|
||||
}
|
||||
if found["description"] != "han ringede i går" {
|
||||
t.Errorf("beskrivelse = %v", found["description"])
|
||||
}
|
||||
}
|
||||
|
||||
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
|
||||
func TestTheHookReadsJSON(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"text": "Køb mælk"}`,
|
||||
`{"title": "Køb mælk"}`,
|
||||
`{"content": "Køb mælk"}`,
|
||||
} {
|
||||
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Køb mælk" {
|
||||
t.Errorf("%s gav %v", body, out["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
|
||||
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
|
||||
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
|
||||
// Alt andet er en maskine til at gætte tokens med.
|
||||
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
|
||||
// tilbage: adressen står i en genvej på en telefon eller i et script.
|
||||
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
old := hookPath(ts.hookURL(t))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skift: %d", resp.StatusCode)
|
||||
}
|
||||
fresh := hookPath(out["url"].(string))
|
||||
if fresh == old {
|
||||
t.Fatal("den nye URL er den gamle")
|
||||
}
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
|
||||
// fordi den ser ud som om integrationen virker.
|
||||
func TestAnEmptyPushIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
|
||||
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
|
||||
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
url := ts.hookURL(t)
|
||||
token := url[strings.LastIndex(url, "/")+1:]
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := anon.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("krog-tokenet gav adgang til API'et")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -155,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
|
||||
}
|
||||
|
||||
// --- en krog ind i indbakken --------------------------------------------------------
|
||||
|
||||
type hookURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleGetHookURL returns the personal address another program can push a task
|
||||
// to, minting the token on first ask.
|
||||
//
|
||||
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
|
||||
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
|
||||
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
|
||||
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
|
||||
// — fra en genvej på telefonen, et script, en anden tjeneste.
|
||||
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
// handleRotateHookURL mints a new one and retires the old.
|
||||
//
|
||||
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
|
||||
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
|
||||
// tilbage end tokenet selv.
|
||||
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, r, "generate hook token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||
s.internal(w, r, "set hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
func (s *Server) hookURL(token string) string {
|
||||
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
|
||||
}
|
||||
|
||||
type hookRequest struct {
|
||||
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
|
||||
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
|
||||
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
|
||||
// op.
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
// Note er det, der står under opgaven. `body` og `description` ligeså.
|
||||
Note string `json:"note"`
|
||||
Body string `json:"body"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (h hookRequest) line() string {
|
||||
for _, v := range []string{h.Text, h.Title, h.Content} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h hookRequest) note() string {
|
||||
for _, v := range []string{h.Note, h.Body, h.Description} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
|
||||
//
|
||||
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
|
||||
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
|
||||
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
|
||||
// den ene persons indbakke — hvor et API-token kan alt.
|
||||
//
|
||||
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
|
||||
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
|
||||
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
|
||||
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
|
||||
//
|
||||
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
|
||||
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
|
||||
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
|
||||
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
|
||||
if err != nil {
|
||||
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
|
||||
// kender forskel, er en maskine til at gætte tokens med.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
||||
return
|
||||
}
|
||||
|
||||
line, note := s.readHookBody(r)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"text": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
content := parsed.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = line
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: content, Priority: parsed.Priority,
|
||||
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from hook", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
s.log.Info("task created from hook", "user", user.ID)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
|
||||
}
|
||||
|
||||
// readHookBody er de tre måder, en linje tekst kommer ind ad.
|
||||
//
|
||||
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
|
||||
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
|
||||
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
|
||||
//
|
||||
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
|
||||
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
|
||||
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
|
||||
func (s *Server) readHookBody(r *http.Request) (line, note string) {
|
||||
const maxBody = 64 << 10
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
kind := r.Header.Get("Content-Type")
|
||||
|
||||
switch {
|
||||
case strings.Contains(kind, "application/json"):
|
||||
var req hookRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return req.line(), req.note()
|
||||
|
||||
case strings.Contains(kind, "application/x-www-form-urlencoded"):
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
req := hookRequest{
|
||||
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
|
||||
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
|
||||
}
|
||||
return req.line(), req.note()
|
||||
}
|
||||
|
||||
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
|
||||
// man har delt fra en anden app, ud — en overskrift og noget under.
|
||||
text := strings.TrimSpace(string(body))
|
||||
first, rest, _ := strings.Cut(text, "\n")
|
||||
return first, strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
||||
func mailToken(address string) string {
|
||||
if i := strings.LastIndex(address, "<"); i >= 0 {
|
||||
|
||||
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"links to a note by its title. A third, `@Navn`, shares the note with that " +
|
||||
"person and tells them — so only write one when the note is meant for them. " +
|
||||
"Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
|
||||
@@ -215,6 +215,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||
if strings.TrimSpace(n.Body) != "" {
|
||||
s.followLinks(r, n)
|
||||
s.shareWithMentioned(r, n, "")
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
@@ -247,6 +248,108 @@ func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
||||
}
|
||||
}
|
||||
|
||||
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
|
||||
//
|
||||
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
|
||||
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
|
||||
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
|
||||
//
|
||||
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
|
||||
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
|
||||
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
|
||||
//
|
||||
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
|
||||
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
|
||||
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
|
||||
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
|
||||
// kun besked til en, der kan se noten i forvejen.
|
||||
//
|
||||
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
|
||||
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
|
||||
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
|
||||
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
|
||||
// fortæller dem, at noten er blevet rettet.
|
||||
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
|
||||
told := map[string]bool{}
|
||||
if n == nil || n.CreatedBy == "" {
|
||||
return told
|
||||
}
|
||||
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
|
||||
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
|
||||
// deles med den, der har den.
|
||||
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
|
||||
if err != nil {
|
||||
s.log.Warn("people for mentions", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
|
||||
was := map[string]bool{}
|
||||
for _, id := range store.MentionsIn(before, people) {
|
||||
was[id] = true
|
||||
}
|
||||
fresh := []string{}
|
||||
for _, id := range store.MentionsIn(n.Body, people) {
|
||||
if !was[id] {
|
||||
fresh = append(fresh, id)
|
||||
}
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return told
|
||||
}
|
||||
|
||||
actor := userFrom(r.Context())
|
||||
owner := actor.ID == n.CreatedBy
|
||||
shared := false
|
||||
for _, id := range fresh {
|
||||
if !owner {
|
||||
continue
|
||||
}
|
||||
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
|
||||
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
|
||||
// skrev deres navn i teksten.
|
||||
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
|
||||
s.log.Warn("mention share role", "err", err, "note", n.ID)
|
||||
continue
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
|
||||
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
}
|
||||
if shared {
|
||||
// Og det, noten peger på, følger med delingen — samme vej som en deling
|
||||
// sat i panelet.
|
||||
s.followLinks(r, n)
|
||||
}
|
||||
|
||||
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
|
||||
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
|
||||
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
|
||||
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
canSee := map[string]bool{}
|
||||
for _, id := range audience {
|
||||
canSee[id] = true
|
||||
}
|
||||
for _, id := range fresh {
|
||||
if !canSee[id] {
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
|
||||
})
|
||||
told[id] = true
|
||||
}
|
||||
return told
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
@@ -310,7 +413,11 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
||||
// det ville lære folk at overse dem alle sammen.
|
||||
if before != n.Body {
|
||||
s.notifyNoteChanged(r, n)
|
||||
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
|
||||
// ikke også "rettede en note" om den samme gemning — det er én handling, og
|
||||
// den ene sætning siger mere end den anden.
|
||||
mentioned := s.shareWithMentioned(r, n, before)
|
||||
s.notifyNoteChanged(r, n, mentioned)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
|
||||
// noten, og den, der bliver nævnt, får besked om det.
|
||||
func TestMentioningSomebodySharesTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ret noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Delt, som læser.
|
||||
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || !has || role != "viewer" {
|
||||
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
|
||||
}
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
|
||||
// handling, og den ene sætning siger mere end den anden.
|
||||
list, unread := notifications(t, other)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
|
||||
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
|
||||
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
|
||||
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
|
||||
if list, _ := notifications(t, other); len(list) != 1 {
|
||||
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
}
|
||||
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
|
||||
// omtaler mere.
|
||||
list, _ := notifications(t, other)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
|
||||
}
|
||||
if firstOf(t, list)["kind"] != "note.changed" {
|
||||
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
|
||||
}
|
||||
}
|
||||
|
||||
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
|
||||
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
|
||||
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
editor := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
peterClient := owner.newUser(t, "peter@example.dk", "Peter")
|
||||
peter := userID(t, owner, "peter@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
|
||||
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("medredaktøren retter")
|
||||
}
|
||||
|
||||
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
|
||||
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
|
||||
}
|
||||
// Og Peter får ikke besked om en note, han ikke kan åbne.
|
||||
if list, _ := notifications(t, peterClient); len(list) != 0 {
|
||||
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
|
||||
}
|
||||
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
|
||||
if list, _ := notifications(t, owner); len(list) == 0 {
|
||||
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
|
||||
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
|
||||
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||
sofie := userID(t, owner, "sofie@example.dk")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
|
||||
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || role != "editor" {
|
||||
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
|
||||
}
|
||||
}
|
||||
@@ -143,10 +143,13 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||
// it — folded into one line per note while it is unread.
|
||||
//
|
||||
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
|
||||
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
|
||||
//
|
||||
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
||||
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
||||
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
||||
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
|
||||
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +162,7 @@ func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
|
||||
title := actor.Name + " rettede en note"
|
||||
body := n.Title
|
||||
for _, userID := range audience {
|
||||
if userID == actor.ID {
|
||||
if userID == actor.ID || except[userID] {
|
||||
continue
|
||||
}
|
||||
// Fold first, and only write a row when there was nothing to fold into.
|
||||
@@ -234,7 +237,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
|
||||
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
|
||||
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
|
||||
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
|
||||
// stadig en besked.
|
||||
unread, err := s.db.UnreadNotificationCount(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Warn("unread count for push", "err", err)
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
|
||||
@@ -66,6 +66,7 @@ type Server struct {
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
hookLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
@@ -93,6 +94,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||
// on the one public write path that has none.
|
||||
mailLimiter: newLimiter(60, time.Minute),
|
||||
hookLimiter: newLimiter(60, time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
@@ -143,6 +145,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/inbound/mail",
|
||||
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||
|
||||
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
|
||||
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
|
||||
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
|
||||
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
|
||||
//
|
||||
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
|
||||
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
|
||||
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||
@@ -319,6 +335,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
|
||||
r.Get("/hook-url", s.handleGetHookURL)
|
||||
r.Post("/hook-url/rotate", s.handleRotateHookURL)
|
||||
|
||||
// Personal API tokens. Behind requireSession rather than the ambient
|
||||
// auth: a token must not be able to mint or revoke another.
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
|
||||
@@ -56,6 +56,13 @@ type Payload struct {
|
||||
Body string `json:"body,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
|
||||
//
|
||||
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
|
||||
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
|
||||
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
|
||||
// er serverens, og serveren ved det i forvejen.
|
||||
Unread int `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
|
||||
}
|
||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
||||
}
|
||||
|
||||
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
|
||||
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
|
||||
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
|
||||
// ikke lykkedes.
|
||||
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
|
||||
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(with), `"unread":3`) {
|
||||
t.Errorf("payload = %s, want the unread count in it", with)
|
||||
}
|
||||
|
||||
without, err := json.Marshal(Payload{Title: "hej"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(without), "unread") {
|
||||
t.Errorf("payload = %s, want no count when there is none to send", without)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MentionsIn reads the people named with an @ out of a body, and returns their
|
||||
// ids in the order they are written.
|
||||
//
|
||||
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
|
||||
// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville
|
||||
// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der
|
||||
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
|
||||
// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke.
|
||||
//
|
||||
// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang,
|
||||
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
|
||||
//
|
||||
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
|
||||
// efternavn hængende bagefter — også når begge findes som konti.
|
||||
func MentionsIn(body string, people []Person) []string {
|
||||
if body == "" || len(people) == 0 {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(body)
|
||||
|
||||
byLength := make([]Person, len(people))
|
||||
copy(byLength, people)
|
||||
sort.SliceStable(byLength, func(i, j int) bool {
|
||||
return len(byLength[i].Name) > len(byLength[j].Name)
|
||||
})
|
||||
|
||||
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
|
||||
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
|
||||
// noten ville blive delt med en, der ikke står i den.
|
||||
claimed := make([]bool, len(body))
|
||||
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
|
||||
|
||||
for _, p := range byLength {
|
||||
name := strings.ToLower(strings.TrimSpace(p.Name))
|
||||
if name == "" || p.ID == "" {
|
||||
continue
|
||||
}
|
||||
needle := "@" + name
|
||||
for at := 0; ; {
|
||||
i := strings.Index(lower[at:], needle)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += at
|
||||
at = i + len(needle)
|
||||
|
||||
end := i + len(needle)
|
||||
if overlaps(claimed, i, end) || !isMention(body, i, end) {
|
||||
continue
|
||||
}
|
||||
for j := i; j < end; j++ {
|
||||
claimed[j] = true
|
||||
}
|
||||
if _, seen := found[p.ID]; !seen {
|
||||
found[p.ID] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(found))
|
||||
for id := range found {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
|
||||
return out
|
||||
}
|
||||
|
||||
func overlaps(claimed []bool, from, to int) bool {
|
||||
for i := from; i < to && i < len(claimed); i++ {
|
||||
if claimed[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isMention er de to kanter om navnet.
|
||||
//
|
||||
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
|
||||
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
|
||||
// efter snabel-a'et.
|
||||
//
|
||||
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
|
||||
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
|
||||
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
|
||||
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
|
||||
func isMention(body string, from, to int) bool {
|
||||
runes := []rune(body[:from])
|
||||
if len(runes) > 0 {
|
||||
prev := runes[len(runes)-1]
|
||||
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
rest := body[to:]
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
next := []rune(rest)
|
||||
if next[0] == 's' || next[0] == 'S' {
|
||||
next = next[1:]
|
||||
if len(next) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMentionsIn(t *testing.T) {
|
||||
people := []Person{
|
||||
{ID: "sofie", Name: "Sofie"},
|
||||
{ID: "sofie-jensen", Name: "Sofie Jensen"},
|
||||
{ID: "kristian", Name: "Kristian"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{"ingen omtale", "En helt almindelig note om noget", nil},
|
||||
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
|
||||
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
|
||||
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
|
||||
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
|
||||
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
|
||||
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
|
||||
// med den, der står i den, og ikke også med en, der ikke gør.
|
||||
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
|
||||
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
|
||||
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
|
||||
{"e-mailadresse", "skriv til hej@Sofie.dk", nil},
|
||||
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
|
||||
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
|
||||
{"ukendt navn", "@Ingen her", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := MentionsIn(tc.body, people)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
|
||||
// tomt svar frem for et gæt på, hvad et @ mon betød.
|
||||
func TestMentionsInWithoutPeople(t *testing.T) {
|
||||
if got := MentionsIn("@Sofie hej", nil); got != nil {
|
||||
t.Errorf("got %v, want nothing to resolve against", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- En personlig adresse, andre programmer kan skubbe en opgave ind ad.
|
||||
--
|
||||
-- Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
|
||||
-- jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
|
||||
-- en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
|
||||
-- mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der
|
||||
-- manglede, var en URL, man kan smide en linje tekst efter.
|
||||
--
|
||||
-- Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed,
|
||||
-- men de bliver kompromitteret hver for sig: en URL, der har ligget i en genvej på
|
||||
-- en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i
|
||||
-- andres adressebøger — skifter med. Og omvendt.
|
||||
--
|
||||
-- Ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons
|
||||
-- indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på
|
||||
-- en anden maskine, og en, man ikke kan.
|
||||
ALTER TABLE users ADD COLUMN hook_token TEXT;
|
||||
|
||||
-- Opslaget er "hvem er det her token", og det er også dét, der skal være entydigt:
|
||||
-- to konti med det samme token er to konti, en opgave kan lande i.
|
||||
CREATE UNIQUE INDEX idx_users_hook_token ON users (hook_token) WHERE hook_token IS NOT NULL;
|
||||
@@ -330,6 +330,52 @@ func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// --- krogen ind i indbakken ------------------------------------------------------
|
||||
|
||||
// EnsureHookToken returns the token in the person's webhook URL, minting one the
|
||||
// first time it is asked for.
|
||||
//
|
||||
// Sit eget token frem for mailens. De to er den samme slags hemmelighed, men de
|
||||
// slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der
|
||||
// blev væk, skal kunne skiftes uden at mailadressen — som står i andres
|
||||
// adressebøger — skifter med.
|
||||
func (db *DB) EnsureHookToken(ctx context.Context, userID string) (string, error) {
|
||||
var token sql.NullString
|
||||
err := db.QueryRowContext(ctx, `SELECT hook_token FROM users WHERE id = ?`, userID).Scan(&token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token.Valid && token.String != "" {
|
||||
return token.String, nil
|
||||
}
|
||||
|
||||
fresh, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := db.SetHookToken(ctx, userID, fresh); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetHookToken(ctx context.Context, userID, token string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET hook_token = ?, updated_at = ? WHERE id = ?`,
|
||||
token, time.Now().Unix(), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserByHookToken is who a webhook call is on behalf of. Tokenet er hele
|
||||
// legitimationen, så et tomt et er et nej med det samme frem for et opslag, der
|
||||
// kunne ramme en række, hvor søjlen aldrig er sat.
|
||||
func (db *DB) UserByHookToken(ctx context.Context, token string) (*User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE hook_token = ?`, token)
|
||||
}
|
||||
|
||||
func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrNotFound
|
||||
|
||||
@@ -418,6 +418,11 @@ export const api = {
|
||||
getMailAddress: () => get('/mail-address'),
|
||||
rotateMailAddress: () => post('/mail-address/rotate'),
|
||||
|
||||
// Krogen ind i indbakken. Selve skubbet sker ikke herfra — det er et andet
|
||||
// program, der gør det — så der er kun adressen at hente og at skifte.
|
||||
getHookURL: () => get('/hook-url'),
|
||||
rotateHookURL: () => post('/hook-url/rotate'),
|
||||
|
||||
getGmail: () => get('/gmail'),
|
||||
setGmail: (data) => put('/gmail', data),
|
||||
disconnectGmail: () => del('/gmail'),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc.
|
||||
*
|
||||
* Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det
|
||||
* halve af pointen med at få besked er at få den, mens man laver noget andet, og
|
||||
* det eneste sted, appen kan sige noget dér, er på sit eget ikon.
|
||||
*
|
||||
* Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved
|
||||
* hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`-
|
||||
* lytteren i service-worker.js, som får tallet med i beskeden.
|
||||
*
|
||||
* Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der
|
||||
* lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en
|
||||
* eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør
|
||||
* ingenting. Firefox kan ikke endnu.
|
||||
*/
|
||||
export function supported() {
|
||||
return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sætter mærket til `n`, eller fjerner det ved nul.
|
||||
*
|
||||
* Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken,
|
||||
* der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren
|
||||
* siger nej.
|
||||
*/
|
||||
export function setBadge(n) {
|
||||
if (!supported()) return;
|
||||
const count = Number(n) || 0;
|
||||
try {
|
||||
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
|
||||
done?.catch?.(() => {});
|
||||
} catch {
|
||||
// Nogle browsere kaster i stedet for at afvise. Det er det samme svar.
|
||||
}
|
||||
}
|
||||
|
||||
/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */
|
||||
export function clearBadge() {
|
||||
setBadge(0);
|
||||
}
|
||||
@@ -20,7 +20,10 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
|
||||
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
|
||||
// to hentninger af den samme liste er to, der kan blive uenige.
|
||||
let { note, notes = [], people = [], onchange, onsave, onopennote } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
@@ -87,6 +90,7 @@
|
||||
const SYNTAX = [
|
||||
{ mark: '#', what: 'notes.syntaxProject' },
|
||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||
{ mark: '@', what: 'notes.syntaxPerson' },
|
||||
{ mark: '⌘B', what: 'notes.bold' },
|
||||
{ mark: '⌘U', what: 'notes.underline' }
|
||||
];
|
||||
@@ -509,6 +513,19 @@
|
||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||
}
|
||||
|
||||
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
|
||||
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
|
||||
// læse sætningen efter navnet med.
|
||||
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
|
||||
if (person) {
|
||||
return {
|
||||
kind: 'person',
|
||||
node,
|
||||
start: selection.anchorOffset - person[1].length,
|
||||
term: person[1]
|
||||
};
|
||||
}
|
||||
|
||||
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (project) {
|
||||
return {
|
||||
@@ -562,7 +579,12 @@
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||
suggestions =
|
||||
partial.kind === 'note'
|
||||
? noteMatches(term)
|
||||
: partial.kind === 'person'
|
||||
? personMatches(term)
|
||||
: projectMatches(term);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
@@ -586,6 +608,16 @@
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
|
||||
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
|
||||
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
|
||||
function personMatches(term) {
|
||||
return people
|
||||
.filter((p) => p.name && p.name.toLowerCase().includes(term))
|
||||
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
function rank(a, b, term) {
|
||||
const al = a.toLowerCase();
|
||||
const bl = b.toLowerCase();
|
||||
@@ -1011,6 +1043,10 @@
|
||||
{#if item.kind === 'note'}
|
||||
<span class="dot note" aria-hidden="true">[[</span>
|
||||
{item.label}
|
||||
{:else if item.kind === 'person'}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
@{item.label}
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
assigned: 'notif.assigned',
|
||||
'note.changed': 'notif.noteChanged',
|
||||
'note.shared': 'notif.noteShared',
|
||||
'note.mention': 'notif.mention',
|
||||
comment: 'notif.comment'
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { clearBadge } from '$lib/badge.js';
|
||||
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
@@ -31,6 +32,9 @@
|
||||
|
||||
async function signOut() {
|
||||
await api.logout();
|
||||
// Mærket på ikonet er den forriges ulæste. Det står i Dock'en, også når
|
||||
// ingen er logget ind, så det skal væk sammen med resten af dem.
|
||||
clearBadge();
|
||||
// Empty the offline data cache before leaving: the service worker keeps the
|
||||
// last-seen task and note lists so a network drop does not blank the app, and
|
||||
// the next person to sign in on this browser must not find them.
|
||||
|
||||
@@ -31,6 +31,7 @@ export const da = {
|
||||
'notif.assigned': '{name} gav dig en opgave',
|
||||
'notif.noteChanged': '{name} rettede en note',
|
||||
'notif.noteShared': '{name} delte en note med dig',
|
||||
'notif.mention': '{name} nævnte dig i en note',
|
||||
'notif.comment': '{name} kommenterede',
|
||||
'notif.justNow': 'lige nu',
|
||||
'notif.minutes': '{n} min. siden',
|
||||
@@ -62,6 +63,10 @@ export const da = {
|
||||
'notes.inviteRevoke': 'Træk invitationen tilbage',
|
||||
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
|
||||
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
|
||||
// Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her,
|
||||
// fordi det sker uden at nogen åbner delepanelet.
|
||||
'notes.syntaxPerson': 'en person',
|
||||
'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.',
|
||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||
'notes.save': 'Gem',
|
||||
'notes.format': 'Formatér',
|
||||
@@ -746,6 +751,17 @@ export const da = {
|
||||
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
|
||||
'int.yourAddress': 'Din adresse',
|
||||
'int.newAddress': 'Ny adresse',
|
||||
// Krogen ind i indbakken: en URL, et andet program kan skubbe en opgave til.
|
||||
'int.hookToTask': 'Skub fra andre programmer',
|
||||
'int.hookHint':
|
||||
'En adresse, andre programmer kan sende en opgave til — en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Linjen læses som i feltet øverst i fladen, så “i morgen”, “p1” og “#Projekt” betyder det samme her.',
|
||||
'int.yourHook': 'Din adresse',
|
||||
'int.hookHow':
|
||||
'Send en POST med teksten som krop — ren tekst, JSON med et {text}-felt, eller et formularfelt. Første linje bliver opgaven, resten står under den. Adressen er en nøgle: den, der har den, kan lægge opgaver i din indbakke og ikke andet.',
|
||||
'int.hookExample': 'Sådan ser det ud',
|
||||
'int.newHook': 'Ny adresse',
|
||||
'int.newHookQuestion':
|
||||
'Vil du have en ny adresse? Den gamle holder op med at virke med det samme, og alt, der bruger den, skal rettes.',
|
||||
'int.caldav': 'CalDAV',
|
||||
'int.caldavHint':
|
||||
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const en = {
|
||||
'notif.assigned': '{name} gave you a task',
|
||||
'notif.noteChanged': '{name} edited a note',
|
||||
'notif.noteShared': '{name} shared a note with you',
|
||||
'notif.mention': '{name} mentioned you in a note',
|
||||
'notif.comment': '{name} commented',
|
||||
'notif.justNow': 'just now',
|
||||
'notif.minutes': '{n} min ago',
|
||||
@@ -59,6 +60,10 @@ export const en = {
|
||||
'notes.inviteRevoke': 'Withdraw the invitation',
|
||||
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
|
||||
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
|
||||
// A mention. `@Name` in a note shares it with the person named — said here,
|
||||
// because it happens without anybody opening the share panel.
|
||||
'notes.syntaxPerson': 'a person',
|
||||
'notes.mentionShared': 'The note has been shared with {names}, because you named them.',
|
||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||
'notes.save': 'Save',
|
||||
'notes.format': 'Format',
|
||||
@@ -743,6 +748,17 @@ export const en = {
|
||||
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
|
||||
'int.yourAddress': 'Your address',
|
||||
'int.newAddress': 'New address',
|
||||
// The hook into the inbox: a URL another program can push a task to.
|
||||
'int.hookToTask': 'Push from other programs',
|
||||
'int.hookHint':
|
||||
'An address other programs can send a task to — a shortcut on your phone, a script, any service that can call a URL. The line is read the way the box at the top of the app reads it, so “tomorrow”, “p1” and “#Project” mean the same here.',
|
||||
'int.yourHook': 'Your address',
|
||||
'int.hookHow':
|
||||
'Send a POST with the text as the body — plain text, JSON with a {text} field, or a form field. The first line becomes the task and the rest sits underneath it. The address is a key: whoever has it can put tasks in your inbox, and nothing else.',
|
||||
'int.hookExample': 'What it looks like',
|
||||
'int.newHook': 'New address',
|
||||
'int.newHookQuestion':
|
||||
'Do you want a new address? The old one stops working immediately, and anything using it has to be changed.',
|
||||
'int.caldav': 'CalDAV',
|
||||
'int.caldavHint':
|
||||
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -53,6 +54,13 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
// 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.
|
||||
$effect(() => {
|
||||
setBadge(app.unread);
|
||||
});
|
||||
|
||||
/**
|
||||
* Global keys.
|
||||
*
|
||||
|
||||
@@ -289,6 +289,9 @@
|
||||
|
||||
let feedURL = $state('');
|
||||
let mailAddress = $state('');
|
||||
// Krogen ind i indbakken: den URL, en genvej på telefonen eller et script kan
|
||||
// skubbe en linje tekst til.
|
||||
let hookURL = $state('');
|
||||
/** Null until loaded, so the warning does not flash before the answer is in. */
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
@@ -301,6 +304,10 @@
|
||||
mailConfigured = r.configured;
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.getHookURL()
|
||||
.then((r) => (hookURL = r.url))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function rotateFeed() {
|
||||
@@ -323,6 +330,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateHook() {
|
||||
if (!confirm(t('int.newHookQuestion'))) return;
|
||||
try {
|
||||
hookURL = (await api.rotateHookURL()).url;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -674,6 +690,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.hookToTask')}</h2>
|
||||
<p class="hint">
|
||||
{t('int.hookHint')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="hook">{t('int.yourHook')}</label>
|
||||
<input id="hook" class="mono" value={hookURL} readonly />
|
||||
<p class="hint">
|
||||
{t('int.hookHow')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Vist som den kommando, den er. Den, der skal sætte det op, skal kunne
|
||||
prøve det af ét sted, før de bygger genvejen på telefonen — og se, at
|
||||
kroppen bare er den linje, opgaven skal hedde. -->
|
||||
<div class="field">
|
||||
<label for="hookcurl">{t('int.hookExample')}</label>
|
||||
<textarea id="hookcurl" class="mono" rows="3" readonly
|
||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={() => copy(hookURL)}>{t('int.copy')}</button>
|
||||
<button class="danger" onclick={rotateHook}>{t('int.newHook')}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.caldav')}</h2>
|
||||
|
||||
@@ -209,6 +209,21 @@
|
||||
let shareInviteLink = $state('');
|
||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||
|
||||
// Dem, et `@` kan betyde i den åbne note.
|
||||
//
|
||||
// I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt
|
||||
// med: en omtale af en, der læser med i forvejen, er en almindelig ting at
|
||||
// skrive, og den giver dem besked selv om den ikke giver dem adgang.
|
||||
//
|
||||
// I en andens note er det dem, man deler projekter med. En medredaktørs omtale
|
||||
// deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens,
|
||||
// og skal heller ikke være det.
|
||||
const mentionable = $derived(
|
||||
ownsSelected
|
||||
? [...shareCandidates, ...shares.map((sh) => sh.user)]
|
||||
: app.people.filter((p) => p.id !== app.user?.id)
|
||||
);
|
||||
|
||||
// The whole of sharing — a project and named people — lives in a popover behind
|
||||
// one button, so the note's footer stays a status and three buttons rather than
|
||||
// a row that runs off its own width.
|
||||
@@ -659,6 +674,7 @@
|
||||
// Only if it is still the one on screen: switching notes mid-save must not
|
||||
// drag the previous one's title back onto this one.
|
||||
if (selected?.id === id) selected = saved;
|
||||
await mentionsShared(id);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
@@ -666,6 +682,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvem gemningen delte noten med, fordi de blev nævnt i den.
|
||||
*
|
||||
* Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting
|
||||
* blandt mange gemninger, og en tastepause skal ikke koste et opslag i
|
||||
* delingerne, hver gang den falder. Panelet spørges i stedet — og kun når
|
||||
* teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv
|
||||
* bruger for at kigge efter.
|
||||
*
|
||||
* Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man
|
||||
* skrev navnet på, skal sige det — også når panelet er lukket.
|
||||
*/
|
||||
async function mentionsShared(id) {
|
||||
if (!ownsSelected || !draft.includes('@')) return;
|
||||
const known = new Set(shares.map((sh) => sh.user.id));
|
||||
try {
|
||||
const r = await api.noteShares(id);
|
||||
if (selected?.id !== id) return;
|
||||
shares = r.shares ?? [];
|
||||
shareCandidates = r.candidates ?? [];
|
||||
shareFollows = r.follows ?? [];
|
||||
shareInvites = r.invites ?? [];
|
||||
const added = shares.filter((sh) => !known.has(sh.user.id));
|
||||
if (added.length) {
|
||||
app.toast(
|
||||
t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') })
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// En liste, der ikke kunne hentes, er ikke en gemning, der gik galt.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites are the pinned ones. The list already puts them first, so marking
|
||||
* one is the same act as saying "keep this where I can see it" — one idea, not
|
||||
@@ -1028,6 +1077,7 @@
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
notes={notes}
|
||||
people={mentionable}
|
||||
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||
onchange={(body) => {
|
||||
draft = body;
|
||||
|
||||
@@ -145,6 +145,19 @@ self.addEventListener('push', (event) => {
|
||||
}
|
||||
|
||||
const title = payload.title || 'verdande';
|
||||
// Mærket på appens ikon — i Dock'en på en Mac, på proceslinjen på en pc.
|
||||
//
|
||||
// Sat her, fordi det her er det eneste, der kører, når appen ikke er åben: det
|
||||
// er netop dér, et tal på ikonet er det, der siger, at der er noget nyt. Tallet
|
||||
// kommer med beskeden frem for at blive talt op her; en service-worker husker
|
||||
// ingenting mellem to opvågninger, så et mærke, den selv talte, ville kun kunne
|
||||
// gå opad.
|
||||
//
|
||||
// Kan browseren ikke sætte mærker, sker der ingenting. Det er ikke en fejl at
|
||||
// melde om: beskeden selv kom frem, og den er hovedsagen.
|
||||
if (typeof payload.unread === 'number' && 'setAppBadge' in self.navigator) {
|
||||
self.navigator.setAppBadge(payload.unread).catch(() => {});
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.body || '',
|
||||
|
||||
Reference in New Issue
Block a user