Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40102d6038 | ||
|
|
445d9de57a | ||
|
|
d5ad459eba | ||
|
|
c297d256e4 | ||
|
|
d063425cae | ||
|
|
0764fbd566 | ||
|
|
b041b32aa2 | ||
|
|
d8f5f6e236 | ||
|
|
61634a6bb5 | ||
|
|
8ee9fbedfb | ||
|
|
ab77024ec9 |
+316
-2
@@ -2718,6 +2718,25 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200": { $ref: "#/components/responses/MailAddress" }
|
"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:
|
/api/v1/notifications:
|
||||||
get:
|
get:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -3017,6 +3036,12 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Write a note.
|
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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3080,6 +3105,11 @@ paths:
|
|||||||
patch:
|
patch:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Change a note. Every field is optional; what is sent is what changes.
|
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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3126,6 +3156,13 @@ paths:
|
|||||||
candidates:
|
candidates:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/Person" }
|
items: { $ref: "#/components/schemas/Person" }
|
||||||
|
invites:
|
||||||
|
description: >
|
||||||
|
Addresses invited to the note that have not been taken up yet.
|
||||||
|
There is no account behind them, so there is no name or colour
|
||||||
|
to show — the address is all there is until they arrive.
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/NoteInvite" }
|
||||||
follows:
|
follows:
|
||||||
description: >
|
description: >
|
||||||
The notes this one points at with [[links]], which are shared
|
The notes this one points at with [[links]], which are shared
|
||||||
@@ -3137,6 +3174,11 @@ paths:
|
|||||||
tags: [notes]
|
tags: [notes]
|
||||||
summary: Share a note with a person, or change the role they hold.
|
summary: Share a note with a person, or change the role they hold.
|
||||||
description: >
|
description: >
|
||||||
|
The person is named either by `user_id` — somebody picked from the
|
||||||
|
candidates — or by `email`. An address that already has an account is
|
||||||
|
shared with straight away; one that does not gets an invitation, which
|
||||||
|
becomes the share the moment the account is created (201).
|
||||||
|
|
||||||
The notes this one points at with [[links]] are shared with the same person
|
The notes this one points at with [[links]] are shared with the same person
|
||||||
and the same role, so a link in a shared note leads somewhere. Only notes
|
and the same role, so a link in a shared note leads somewhere. Only notes
|
||||||
the sharer owns follow, and the response lists them.
|
the sharer owns follow, and the response lists them.
|
||||||
@@ -3146,9 +3188,10 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [user_id]
|
description: One of user_id or email.
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
role: { type: string, enum: [viewer, editor] }
|
role: { type: string, enum: [viewer, editor] }
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -3158,12 +3201,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
user:
|
||||||
|
description: >
|
||||||
|
Who it was shared with. Sent back because a caller that shared
|
||||||
|
by address does not otherwise know who it reached.
|
||||||
|
$ref: "#/components/schemas/Person"
|
||||||
|
role: { type: string, enum: [viewer, editor] }
|
||||||
follows:
|
follows:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/NoteFollow" }
|
items: { $ref: "#/components/schemas/NoteFollow" }
|
||||||
|
"201":
|
||||||
|
description: >
|
||||||
|
Invited. The address has no account yet, so a link that creates one and
|
||||||
|
the share together has been issued.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
invited: { $ref: "#/components/schemas/NoteInvite" }
|
||||||
|
link:
|
||||||
|
description: The signup link, shown so it can be passed on.
|
||||||
|
type: string
|
||||||
|
emailed:
|
||||||
|
description: >
|
||||||
|
False when the instance has no mail configured, which is when
|
||||||
|
the link is the only way the invitation reaches anybody.
|
||||||
|
type: boolean
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/notes/{noteID}/invites/{inviteID}:
|
||||||
|
delete:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Withdraw an invitation to a note before it is used.
|
||||||
|
description: >
|
||||||
|
The only way back. Only the token's hash is stored, so a link sent to the
|
||||||
|
wrong address cannot be looked up and invalidated — only the row it belongs
|
||||||
|
to can.
|
||||||
|
parameters:
|
||||||
|
- name: noteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: inviteID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Withdrawn }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/api/v1/notes/{noteID}/shares/{userID}:
|
/api/v1/notes/{noteID}/shares/{userID}:
|
||||||
delete:
|
delete:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -3361,6 +3450,124 @@ paths:
|
|||||||
$ref: "#/components/responses/Conflict"
|
$ref: "#/components/responses/Conflict"
|
||||||
"502": { $ref: "#/components/responses/Upstream" }
|
"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:
|
/api/v1/mcp:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -3682,6 +3889,69 @@ paths:
|
|||||||
task_id: { type: string }
|
task_id: { type: string }
|
||||||
"404": { description: "No such address. Identical to a malformed one, deliberately." }
|
"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:
|
/oauth/gmail/callback:
|
||||||
get:
|
get:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -3948,6 +4218,15 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items: { type: string }
|
items: { type: string }
|
||||||
|
|
||||||
|
HookURL:
|
||||||
|
description: The address another program pushes to.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url: { type: string }
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
Calendar:
|
Calendar:
|
||||||
type: object
|
type: object
|
||||||
@@ -4072,6 +4351,31 @@ components:
|
|||||||
note_id: { type: string }
|
note_id: { type: string }
|
||||||
title: { 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: >
|
||||||
|
A share on a note that is waiting for an account. The address is the whole
|
||||||
|
of who the person is until they sign up.
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
|
role: { type: string, enum: [viewer, editor] }
|
||||||
|
|
||||||
Mailbox:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
description: One connected mailbox. The password and any token are never returned.
|
description: One connected mailbox. The password and any token are never returned.
|
||||||
@@ -4560,12 +4864,22 @@ components:
|
|||||||
|
|
||||||
Notification:
|
Notification:
|
||||||
type: object
|
type: object
|
||||||
|
description: >
|
||||||
|
Something somebody else did that you need to know about. `kind` says what
|
||||||
|
happened and the client composes the sentence from it, so the bell follows
|
||||||
|
the reader's language; `title` is the server's own Danish wording, used for
|
||||||
|
Web Push, which has no dictionary to look words up in.
|
||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
actor_name: { type: string }
|
actor_name: { type: string }
|
||||||
project_id: { type: string }
|
project_id: { type: string }
|
||||||
task_id: { type: string }
|
task_id: { type: string }
|
||||||
kind: { type: string }
|
note_id:
|
||||||
|
description: Set on a notification about a note — a shared note somebody else edited.
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [assigned, note.changed, comment]
|
||||||
title: { type: string }
|
title: { type: string }
|
||||||
body: { type: string }
|
body: { type: string }
|
||||||
read: { type: boolean }
|
read: { type: boolean }
|
||||||
|
|||||||
@@ -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}})
|
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
|
// 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
|
// fence or preceded by a sentence, which models do however firmly they are asked
|
||||||
// not to.
|
// not to.
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/ai"
|
||||||
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AI'ens forslag, og hvad der sker med dem.
|
||||||
|
//
|
||||||
|
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
|
||||||
|
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
|
||||||
|
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
|
||||||
|
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
|
||||||
|
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
|
||||||
|
// skal have ret tit nok til at spare et par tastetryk.
|
||||||
|
//
|
||||||
|
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
|
||||||
|
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
|
||||||
|
// gennem den samme som alt andet.
|
||||||
|
|
||||||
|
// aiSuggestionJSON er ét forslag på vej til fladen.
|
||||||
|
type aiSuggestionJSON struct {
|
||||||
|
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
|
||||||
|
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
|
||||||
|
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
|
||||||
|
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
|
||||||
|
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "ai settings", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !cfg.Configured() {
|
||||||
|
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
|
||||||
|
// lægger sin egen nøgle ind.
|
||||||
|
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ai.New(cfg), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectNames er de projekter, modellen må nævne.
|
||||||
|
//
|
||||||
|
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
|
||||||
|
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
|
||||||
|
// nogen sagde ja til forslaget.
|
||||||
|
func (s *Server) projectNames(r *http.Request) []string {
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("projects for ai", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.IsInbox {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
|
||||||
|
// skrives færdig.
|
||||||
|
//
|
||||||
|
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
|
||||||
|
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
|
||||||
|
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
|
||||||
|
// spørgsmål og et ja pr. linje.
|
||||||
|
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
client, ok := s.aiClientFor(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, err := s.db.InboxID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
ProjectIDs: []string{inbox}, Limit: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
|
||||||
|
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
|
||||||
|
// må ikke kunne flytte alle de andres forslag én plads — derfor står
|
||||||
|
// nummeret i teksten og bliver læst tilbage.
|
||||||
|
lines := make([]string, 0, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
line := strconv.Itoa(i+1) + ". " + t.Content
|
||||||
|
if t.Description != "" {
|
||||||
|
line += " — " + firstWords(t.Description, 30)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("ai tidy inbox", "err", err)
|
||||||
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||||
|
for i, sug := range suggestions {
|
||||||
|
n, line := numberedLine(sug.Line)
|
||||||
|
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
|
||||||
|
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
|
||||||
|
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
|
||||||
|
if n == 0 {
|
||||||
|
n = i + 1
|
||||||
|
}
|
||||||
|
if n < 1 || n > len(tasks) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type applyLineRequest struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
|
||||||
|
// om.
|
||||||
|
//
|
||||||
|
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
|
||||||
|
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
|
||||||
|
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
|
||||||
|
// der ikke blev rørt.
|
||||||
|
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req applyLineRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if strings.TrimSpace(req.Line) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||||
|
if strings.TrimSpace(parsed.Content) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
|
||||||
|
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
|
||||||
|
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
|
||||||
|
// slette noget.
|
||||||
|
update := store.TaskUpdate{Content: &parsed.Content}
|
||||||
|
if parsed.Priority != 0 {
|
||||||
|
update.Priority = &parsed.Priority
|
||||||
|
}
|
||||||
|
if parsed.DueDate != "" {
|
||||||
|
update.SetDue, update.DueDate = true, parsed.DueDate
|
||||||
|
}
|
||||||
|
if parsed.Recurrence != "" {
|
||||||
|
update.RecurrenceRule = &parsed.Recurrence
|
||||||
|
}
|
||||||
|
if len(parsed.Labels) > 0 {
|
||||||
|
update.Labels, update.SetLabels = parsed.Labels, true
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
|
||||||
|
s.storeError(w, r, "update task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
|
||||||
|
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
|
||||||
|
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
|
||||||
|
if parsed.Project != "" {
|
||||||
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||||
|
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
|
||||||
|
s.internal(w, r, "move task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
|
||||||
|
//
|
||||||
|
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
|
||||||
|
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
|
||||||
|
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
|
||||||
|
// bare at være forkert.
|
||||||
|
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
client, ok := s.aiClientFor(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.Body) == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("ai note actions", "err", err)
|
||||||
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||||
|
for _, sug := range suggestions {
|
||||||
|
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAICreateFromNote laver én accepteret opgave fra en note.
|
||||||
|
//
|
||||||
|
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
|
||||||
|
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
|
||||||
|
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
|
||||||
|
// den kom fra — og noten kan findes på sin titel.
|
||||||
|
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req applyLineRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||||
|
if strings.TrimSpace(parsed.Content) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
|
||||||
|
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
|
||||||
|
// linjen vinder, fordi det er valgt frem for arvet.
|
||||||
|
projectID := n.ProjectID
|
||||||
|
if parsed.Project != "" {
|
||||||
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||||
|
projectID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
|
s.internal(w, r, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task := &store.Task{
|
||||||
|
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||||
|
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||||
|
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||||
|
s.internal(w, r, "create task from note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
|
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
|
||||||
|
//
|
||||||
|
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
|
||||||
|
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
|
||||||
|
// model, der springer en opgave over, ville så flytte alle de følgende forslag
|
||||||
|
// én plads, og så bliver "sæt dato på den her" sat på en anden.
|
||||||
|
func numberedLine(s string) (int, string) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
dot := strings.IndexByte(s, '.')
|
||||||
|
if dot <= 0 || dot > 3 {
|
||||||
|
return 0, s
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, c := range s[:dot] {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0, s
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
return n, strings.TrimSpace(s[dot+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
|
||||||
|
// fylde den med en hel note.
|
||||||
|
func firstWords(s string, n int) string {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) <= n {
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
return strings.Join(fields[:n], " ") + " …"
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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})
|
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>".
|
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
||||||
func mailToken(address string) string {
|
func mailToken(address string) string {
|
||||||
if i := strings.LastIndex(address, "<"); i >= 0 {
|
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: " +
|
"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 " +
|
"`#Projekt` files the note against that project, so it appears on the " +
|
||||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
"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.",
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
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."),
|
"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.
|
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||||
if strings.TrimSpace(n.Body) != "" {
|
if strings.TrimSpace(n.Body) != "" {
|
||||||
s.followLinks(r, n)
|
s.followLinks(r, n)
|
||||||
|
s.shareWithMentioned(r, n, "")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, 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) {
|
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||||
var body noteBody
|
var body noteBody
|
||||||
if err := decodeJSON(w, r, &body); err != nil {
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
@@ -306,6 +409,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
||||||
s.followLinks(r, n)
|
s.followLinks(r, n)
|
||||||
}
|
}
|
||||||
|
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
|
||||||
|
// 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 {
|
||||||
|
// 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)
|
writeJSON(w, http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
|
||||||
|
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
|
||||||
|
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
|
||||||
|
|
||||||
|
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "sofie@example.dk", "role": "editor"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
invited, _ := body["invited"].(map[string]any)
|
||||||
|
if invited == nil || invited["email"] != "sofie@example.dk" {
|
||||||
|
t.Fatalf("the response does not say who was invited: %v", body)
|
||||||
|
}
|
||||||
|
if invited["role"] != "editor" {
|
||||||
|
t.Errorf("role = %v, want the one the owner chose", invited["role"])
|
||||||
|
}
|
||||||
|
// Uden en postserver er linket det eneste, der findes, og det skal stå i
|
||||||
|
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
|
||||||
|
if body["emailed"] != false {
|
||||||
|
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
||||||
|
}
|
||||||
|
link, _ := body["link"].(string)
|
||||||
|
if !strings.Contains(link, "token=") {
|
||||||
|
t.Fatalf("link = %q, want one carrying a token", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
|
||||||
|
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||||
|
pending, _ := panel["invites"].([]any)
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("want one pending invite under the note, got %v", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og linket laver kontoen og delingen i ét.
|
||||||
|
token := link[strings.Index(link, "token=")+len("token="):]
|
||||||
|
fresh := newSignedOut(t, owner)
|
||||||
|
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||||
|
"token": token, "name": "Sofie", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noten er hendes at læse — og som medredaktør også at rette.
|
||||||
|
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if note["title"] != "Aftale om levering" {
|
||||||
|
t.Errorf("the invited person got something else: %v", note)
|
||||||
|
}
|
||||||
|
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og invitationen er brugt op: den står ikke længere og venter.
|
||||||
|
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||||
|
if left, _ := after["invites"].([]any); len(left) != 0 {
|
||||||
|
t.Errorf("the invite is still pending after it was accepted: %v", left)
|
||||||
|
}
|
||||||
|
shares, _ := after["shares"].([]any)
|
||||||
|
if len(shares) != 1 {
|
||||||
|
t.Fatalf("want the accepted invite to be a share, got %v", shares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
|
||||||
|
// invitation til at oprette en, som personen aldrig kan komme igennem.
|
||||||
|
func TestSharingByEmailFindsAnExistingAccount(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, "# Prisliste\n2026")
|
||||||
|
|
||||||
|
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "Sofie@Example.dk"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
person, _ := body["user"].(map[string]any)
|
||||||
|
if person == nil || person["id"] != sofie {
|
||||||
|
t.Fatalf("the response does not say who it reached: %v", body)
|
||||||
|
}
|
||||||
|
if _, invited := body["invited"]; invited {
|
||||||
|
t.Error("an address with an account was invited rather than shared with")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||||
|
if err != nil || role != "viewer" {
|
||||||
|
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
|
||||||
|
// det samme, og kun det ene kan trækkes tilbage ad gangen.
|
||||||
|
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first invite: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "ny@example.dk"})
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
|
||||||
|
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||||
|
pending, _ := panel["invites"].([]any)
|
||||||
|
id := pending[0].(map[string]any)["id"].(string)
|
||||||
|
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
|
||||||
|
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
|
||||||
|
func TestOnlyTheOwnerCanInviteToANote(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, "# Ferieplan\nuge 29")
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("share: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||||
|
map[string]string{"email": "fremmed@example.dk"})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
|
||||||
|
t.Fatalf("invites = %v (%v), want none", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En invitation til én note kan ikke bruges til at rydde en anden notes
|
||||||
|
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
|
||||||
|
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
mine := owner.createNote(t, "# Min note\n1")
|
||||||
|
other := owner.createNote(t, "# Min anden note\n2")
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
|
||||||
|
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatal("invite failed")
|
||||||
|
}
|
||||||
|
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
|
||||||
|
if err != nil || len(invites) != 1 {
|
||||||
|
t.Fatalf("invites = %v (%v)", invites, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
|
||||||
|
t.Error("the invite was withdrawn through a note it does not belong to")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -128,9 +129,23 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
|
||||||
|
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
|
||||||
|
// den samme indbakke, og ingen af dem til at trække tilbage.
|
||||||
|
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list note invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending := make([]noteInviteJSON, 0, len(invites))
|
||||||
|
for _, i := range invites {
|
||||||
|
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"shares": out,
|
"shares": out,
|
||||||
"candidates": cand,
|
"candidates": cand,
|
||||||
|
"invites": pending,
|
||||||
"follows": followsOf(following, n.ID),
|
"follows": followsOf(following, n.ID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -167,10 +182,33 @@ type noteShareJSON struct {
|
|||||||
|
|
||||||
type shareNoteRequest struct {
|
type shareNoteRequest struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Role string `json:"role"`
|
// Email deles der med, når personen ikke er at finde i listen — enten fordi
|
||||||
|
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
|
||||||
|
// finde endnu.
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
|
||||||
|
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
|
||||||
|
// dukker op.
|
||||||
|
type noteInviteJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||||
|
//
|
||||||
|
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
|
||||||
|
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
|
||||||
|
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
|
||||||
|
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
|
||||||
|
//
|
||||||
|
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
|
||||||
|
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
|
||||||
|
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
|
||||||
|
// vent på at de opretter sig, find noten frem, del den.
|
||||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||||
var req shareNoteRequest
|
var req shareNoteRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
@@ -194,34 +232,134 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.UserID == "" {
|
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
email := store.NormalizeEmail(req.Email)
|
||||||
|
|
||||||
|
if req.UserID == "" && email == "" {
|
||||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
|
||||||
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
|
||||||
|
// — samme regel som en projektinvitation følger.
|
||||||
|
if req.UserID == "" {
|
||||||
|
if !strings.Contains(email, "@") {
|
||||||
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email == store.NormalizeEmail(me.Email) {
|
||||||
|
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := s.db.UserByEmail(r.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
s.inviteToNote(w, r, n, email, role)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.UserID = existing.ID
|
||||||
|
}
|
||||||
|
|
||||||
// The recipient must be a real account and not the sharer themselves; ShareNote
|
// The recipient must be a real account and not the sharer themselves; ShareNote
|
||||||
// refuses the owner, and a made-up id should not reach it.
|
// refuses the owner, and a made-up id should not reach it.
|
||||||
if req.UserID == userFrom(r.Context()).ID {
|
if req.UserID == me.ID {
|
||||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||||
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
|
||||||
s.storeError(w, r, "share note", err)
|
s.storeError(w, r, "share note", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.notifyNoteShared(r, n, req.UserID)
|
||||||
|
|
||||||
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
||||||
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
||||||
followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "sync linked shares", err)
|
s.internal(w, r, "sync linked shares", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"follows": followsOf(followed, n.ID),
|
||||||
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
|
||||||
|
// derfor ikke selv ved, hvem den ramte.
|
||||||
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||||
|
"role": string(role),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// inviteToNote sends a link that creates an account and the share at once.
|
||||||
|
//
|
||||||
|
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
|
||||||
|
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
|
||||||
|
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
|
||||||
|
// på den.
|
||||||
|
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
|
||||||
|
me := userFrom(r.Context())
|
||||||
|
|
||||||
|
// En anden invitation til den samme note i den samme indbakke er ikke en
|
||||||
|
// invitation mere: det er to links, der gør det samme, og kun det ene kan
|
||||||
|
// trækkes tilbage ad gangen.
|
||||||
|
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "pending note invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "create note invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||||
|
|
||||||
|
emailed := false
|
||||||
|
if s.mail.Configured() {
|
||||||
|
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
|
||||||
|
s.log.Error("send note invite", "err", err, "to", email)
|
||||||
|
} else {
|
||||||
|
emailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
|
||||||
|
"link": link,
|
||||||
|
"emailed": emailed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteNoteInvite withdraws an invitation before it is used.
|
||||||
|
//
|
||||||
|
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
|
||||||
|
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
|
||||||
|
// kan kun rækken, det hører til.
|
||||||
|
func (s *Server) handleDeleteNoteInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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.ownsNote(r, n) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
|
||||||
|
s.storeError(w, r, "delete note invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUnshareNote takes a person's access away again.
|
// handleUnshareNote takes a person's access away again.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type notificationJSON struct {
|
|||||||
ActorName string `json:"actor_name,omitempty"`
|
ActorName string `json:"actor_name,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
NoteID string `json:"note_id,omitempty"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
for _, n := range list {
|
for _, n := range list {
|
||||||
out = append(out, notificationJSON{
|
out = append(out, notificationJSON{
|
||||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||||
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
||||||
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||||
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Detached from the request: a push round trip to Google or Mozilla has no
|
// Detached from the request: a push round trip to Google or Mozilla has no
|
||||||
@@ -102,6 +104,89 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyAssigned tells somebody a task is now theirs.
|
||||||
|
//
|
||||||
|
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
|
||||||
|
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
|
||||||
|
// hvad de lige har gjort.
|
||||||
|
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
|
||||||
|
if t == nil || t.AssigneeID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
|
||||||
|
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyNoteShared tells somebody that a note is now theirs to read.
|
||||||
|
//
|
||||||
|
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
|
||||||
|
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
|
||||||
|
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
|
||||||
|
//
|
||||||
|
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
|
||||||
|
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
|
||||||
|
// ikke en ny note at læse.
|
||||||
|
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
|
||||||
|
if n == nil || userID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||||
|
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, except map[string]bool) {
|
||||||
|
if n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := userFrom(r.Context())
|
||||||
|
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := actor.Name + " rettede en note"
|
||||||
|
body := n.Title
|
||||||
|
for _, userID := range audience {
|
||||||
|
if userID == actor.ID || except[userID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Fold first, and only write a row when there was nothing to fold into.
|
||||||
|
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
|
||||||
|
// hvor klokken siger to.
|
||||||
|
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("fold note notification", "err", err, "note", n.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if folded {
|
||||||
|
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
|
||||||
|
// op og siger nu et andet navn. Uden det her opdager en åben fane først
|
||||||
|
// den sammenlagte rettelse ved næste genindlæsning.
|
||||||
|
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.notify(r, &store.Notification{
|
||||||
|
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||||
|
Kind: "note.changed", Title: title, Body: body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||||
// which has the schedule but not the keys.
|
// which has the schedule but not the keys.
|
||||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||||
@@ -152,7 +237,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
target = "/"
|
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()
|
subject := s.vapidSubject()
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
err := push.Send(ctx, push.Subscription{
|
err := push.Send(ctx, push.Subscription{
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifications reads somebody's bell.
|
||||||
|
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
|
||||||
|
t.Helper()
|
||||||
|
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list notifications: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
list, _ := out["notifications"].([]any)
|
||||||
|
unread, _ := out["unread"].(float64)
|
||||||
|
return list, unread
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(t *testing.T, list []any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("ingen beskeder")
|
||||||
|
}
|
||||||
|
n, _ := list[0].(map[string]any)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
|
||||||
|
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||||
|
projectID, _ := project["id"].(string)
|
||||||
|
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
|
||||||
|
// medlem med det samme.
|
||||||
|
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||||
|
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("invitér: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, task := owner.do(t, "POST", "/api/v1/tasks",
|
||||||
|
map[string]any{"project_id": projectID, "content": "ring til revisor"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("opret opgave: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
taskID, _ := task["id"].(string)
|
||||||
|
|
||||||
|
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
|
||||||
|
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
|
||||||
|
before, _ := notifications(t, other)
|
||||||
|
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("tildel: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, _ := notifications(t, other)
|
||||||
|
if len(list) != len(before)+1 {
|
||||||
|
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
|
||||||
|
}
|
||||||
|
n := firstOf(t, list)
|
||||||
|
if n["kind"] != "assigned" {
|
||||||
|
t.Errorf("kind = %v, want assigned", n["kind"])
|
||||||
|
}
|
||||||
|
if n["task_id"] != taskID {
|
||||||
|
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
|
||||||
|
}
|
||||||
|
if n["actor_name"] != "Kristian" {
|
||||||
|
t.Errorf("actor_name = %v", n["actor_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
|
||||||
|
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
|
||||||
|
// ændring, gør — og så bliver klokken ubrugelig på en dag.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ret: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if now, _ := notifications(t, other); len(now) != len(before)+1 {
|
||||||
|
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den, der tildeler sig selv, får ingenting.
|
||||||
|
me := userID(t, owner, "kristian@example.dk")
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
|
||||||
|
// så længe den ikke er læst.
|
||||||
|
func TestEditingASharedNoteNotifiesTheOthers(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\nfirst")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
|
||||||
|
// nogen retter i den. Den tælles med i alt, hvad der følger.
|
||||||
|
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
|
||||||
|
firstOf(t, list)["kind"] != "note.shared" {
|
||||||
|
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren skriver. Sofie skal høre det.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
list, unread := notifications(t, other)
|
||||||
|
if unread != 2 || len(list) != 2 {
|
||||||
|
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
|
||||||
|
len(list), unread)
|
||||||
|
}
|
||||||
|
n := firstOf(t, list)
|
||||||
|
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
||||||
|
t.Errorf("besked = %v", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
|
||||||
|
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
|
||||||
|
// ens linjer i klokken.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten igen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
|
||||||
|
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
|
||||||
|
len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
|
||||||
|
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatal("markér som læst")
|
||||||
|
}
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten efter læsningen")
|
||||||
|
}
|
||||||
|
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
|
||||||
|
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den anden vej: Sofie skriver, ejeren hører det.
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("Sofie retter")
|
||||||
|
}
|
||||||
|
list, _ = notifications(t, owner)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
|
||||||
|
}
|
||||||
|
if firstOf(t, list)["actor_name"] != "Sofie" {
|
||||||
|
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("stjernemarkér")
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, other); len(list) != 3 {
|
||||||
|
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En note, ingen deler, er ingen at give besked.
|
||||||
|
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
|
||||||
|
owner := newTestServer(t)
|
||||||
|
owner.bootstrap(t)
|
||||||
|
other := owner.newUser(t, "sofie@example.dk", "Sofie")
|
||||||
|
|
||||||
|
noteID := owner.createNote(t, "# Min egen\nintet")
|
||||||
|
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||||
|
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatal("ret noten")
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, other); len(list) != 0 {
|
||||||
|
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||||
|
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ type Server struct {
|
|||||||
// Sat kun af prøver; se icsClient().
|
// Sat kun af prøver; se icsClient().
|
||||||
icsFetch *http.Client
|
icsFetch *http.Client
|
||||||
mailLimiter *limiter
|
mailLimiter *limiter
|
||||||
|
hookLimiter *limiter
|
||||||
resetLimiter *limiter
|
resetLimiter *limiter
|
||||||
// Second factors are counted against the *account*, not the address. An
|
// Second factors are counted against the *account*, not the address. An
|
||||||
// address bucket is defeated by anybody with more than one address, which is
|
// 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
|
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||||
// on the one public write path that has none.
|
// on the one public write path that has none.
|
||||||
mailLimiter: newLimiter(60, time.Minute),
|
mailLimiter: newLimiter(60, time.Minute),
|
||||||
|
hookLimiter: newLimiter(60, time.Minute),
|
||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
// 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
|
// 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",
|
r.Method(http.MethodPost, "/inbound/mail",
|
||||||
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
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
|
// 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
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
// /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.Get("/mail-address", s.handleGetMailAddress)
|
||||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
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
|
// Personal API tokens. Behind requireSession rather than the ambient
|
||||||
// auth: a token must not be able to mint or revoke another.
|
// auth: a token must not be able to mint or revoke another.
|
||||||
r.Route("/tokens", func(r chi.Router) {
|
r.Route("/tokens", func(r chi.Router) {
|
||||||
@@ -333,6 +352,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Put("/settings", s.handleSetAISettings)
|
r.Put("/settings", s.handleSetAISettings)
|
||||||
r.Post("/summary", s.handleAISummary)
|
r.Post("/summary", s.handleAISummary)
|
||||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
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)
|
r.Get("/version", s.handleVersion)
|
||||||
@@ -353,6 +379,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||||
|
// En invitation til en note er en deling, der venter på en konto.
|
||||||
|
// Den kan tages tilbage indtil den bruges; se handleShareNote.
|
||||||
|
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
|
||||||
r.Get("/{noteID}", s.handleGetNote)
|
r.Get("/{noteID}", s.handleGetNote)
|
||||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.Labels = req.Labels
|
t.Labels = req.Labels
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||||
|
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
|
||||||
|
// ind i "det her er dit nu" giver besked og den anden ikke.
|
||||||
|
s.notifyAssigned(r, t)
|
||||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
|
||||||
|
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
|
||||||
|
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
|
||||||
|
// det hver gang nogen retter en overskrift.
|
||||||
|
wasAssigned := ""
|
||||||
|
if req.AssigneeID != nil {
|
||||||
|
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
||||||
|
wasAssigned = before.AssigneeID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u := store.TaskUpdate{
|
u := store.TaskUpdate{
|
||||||
Description: req.Description, Priority: req.Priority,
|
Description: req.Description, Priority: req.Priority,
|
||||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||||
@@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||||
|
s.notifyAssigned(r, t)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
|||||||
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
||||||
|
|
||||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||||
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
|
||||||
what := "verdande"
|
what := "verdande"
|
||||||
if projectName != "" {
|
if projectName != "" {
|
||||||
what = "projektet “" + projectName + "”"
|
what = "projektet “" + projectName + "”"
|
||||||
}
|
}
|
||||||
|
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNoteInvite is the same letter about a single note.
|
||||||
|
//
|
||||||
|
// The title is in the mail because it is what makes the invitation mean anything —
|
||||||
|
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
|
||||||
|
// somebody can decide about, where "til en note" is one they have to click a link
|
||||||
|
// to understand. It travels no further than the address the owner typed, which is
|
||||||
|
// the address they chose to share the note with.
|
||||||
|
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
|
||||||
|
what := "en note"
|
||||||
|
if noteTitle != "" {
|
||||||
|
what = "noten “" + noteTitle + "”"
|
||||||
|
}
|
||||||
|
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
|
||||||
|
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
||||||
body := fmt.Sprintf(`Hej
|
body := fmt.Sprintf(`Hej
|
||||||
|
|
||||||
%s har inviteret dig til %s.
|
%s har inviteret dig til %s.
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type Payload struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Tag string `json:"tag,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
|
var b64 = base64.RawURLEncoding
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/ecdh"
|
"crypto/ecdh"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
|
|||||||
}
|
}
|
||||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+161
-36
@@ -16,6 +16,7 @@ type Invite struct {
|
|||||||
ID string
|
ID string
|
||||||
Email string
|
Email string
|
||||||
ProjectID string // empty for an invite to the instance rather than to a project
|
ProjectID string // empty for an invite to the instance rather than to a project
|
||||||
|
NoteID string // set when the invite is to a single note
|
||||||
Role Role
|
Role Role
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInvite issues an invite and returns the token for the emailed link.
|
// CreateInvite issues an invite to a project and returns the token for the emailed
|
||||||
// projectID may be empty, which invites somebody to the instance rather than to a
|
// link. projectID may be empty, which invites somebody to the instance rather than
|
||||||
// particular project.
|
// to a particular project.
|
||||||
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
||||||
if !role.Valid() {
|
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
|
||||||
return "", nil, fmt.Errorf("store: %q is not a role", role)
|
}
|
||||||
|
|
||||||
|
// CreateNoteInvite issues an invite to a single note.
|
||||||
|
//
|
||||||
|
// Same row, same token, same link as a project invite — the difference is only
|
||||||
|
// what the person gets when they arrive, and that is settled in AcceptInvite. A
|
||||||
|
// second table for "invited to a note" would have meant a second signup path to
|
||||||
|
// keep in step with this one, and the two would drift the first time either was
|
||||||
|
// touched.
|
||||||
|
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
|
||||||
|
if noteID == "" {
|
||||||
|
return "", nil, errors.New("store: a note invite needs a note")
|
||||||
|
}
|
||||||
|
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
|
||||||
|
if !inv.Role.Valid() {
|
||||||
|
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
|
||||||
}
|
}
|
||||||
token, err := auth.NewToken()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
inv := &Invite{
|
inv.ID = NewID()
|
||||||
ID: NewID(),
|
inv.Email = NormalizeEmail(inv.Email)
|
||||||
Email: NormalizeEmail(email),
|
inv.CreatedAt = now
|
||||||
ProjectID: projectID,
|
inv.ExpiresAt = now.Add(ttl)
|
||||||
Role: role,
|
|
||||||
CreatedBy: createdBy,
|
|
||||||
CreatedAt: now,
|
|
||||||
ExpiresAt: now.Add(ttl),
|
|
||||||
}
|
|
||||||
|
|
||||||
var project any
|
|
||||||
if projectID != "" {
|
|
||||||
project = projectID
|
|
||||||
}
|
|
||||||
_, err = db.ExecContext(ctx,
|
_, err = db.ExecContext(ctx,
|
||||||
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
|
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
|
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
|
||||||
|
string(inv.Role), auth.HashToken(token),
|
||||||
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
|
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
|||||||
return nil, ErrInviteInvalid
|
return nil, ErrInviteInvalid
|
||||||
}
|
}
|
||||||
var inv Invite
|
var inv Invite
|
||||||
var projectID sql.NullString
|
var projectID, noteID sql.NullString
|
||||||
var created, expires int64
|
var created, expires int64
|
||||||
var accepted sql.NullInt64
|
var accepted sql.NullInt64
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
|
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
|
||||||
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
|
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
|
||||||
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
|
Scan(&inv.ID, &inv.Email, &projectID, ¬eID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrInviteInvalid
|
return nil, ErrInviteInvalid
|
||||||
}
|
}
|
||||||
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
inv.ProjectID = projectID.String
|
inv.ProjectID = projectID.String
|
||||||
|
inv.NoteID = noteID.String
|
||||||
inv.CreatedAt = time.Unix(created, 0).UTC()
|
inv.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
inv.ExpiresAt = time.Unix(expires, 0).UTC()
|
inv.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
return &inv, nil
|
return &inv, nil
|
||||||
@@ -144,10 +156,16 @@ type PendingInvite struct {
|
|||||||
Email string
|
Email string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ProjectName string
|
ProjectName string
|
||||||
Role Role
|
// NoteID is set for an invitation to a single note. The note is named by its
|
||||||
InvitedBy string
|
// id and never by its title: titles are sealed in the database, and a private
|
||||||
CreatedAt time.Time
|
// note's first line is not something an administrator's user list should
|
||||||
ExpiresAt time.Time
|
// spell out. That there is an account on its way is the administrator's
|
||||||
|
// business; what it was invited to read is not.
|
||||||
|
NoteID string
|
||||||
|
Role Role
|
||||||
|
InvitedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPendingInvites returns invites that are neither accepted nor expired.
|
// ListPendingInvites returns invites that are neither accepted nor expired.
|
||||||
@@ -159,7 +177,8 @@ type PendingInvite struct {
|
|||||||
// able to say which it is.
|
// able to say which it is.
|
||||||
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
|
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
|
||||||
|
COALESCE(i.note_id, ''), i.role,
|
||||||
COALESCE(u.name, ''), i.created_at, i.expires_at
|
COALESCE(u.name, ''), i.created_at, i.expires_at
|
||||||
FROM invites i
|
FROM invites i
|
||||||
LEFT JOIN projects p ON p.id = i.project_id
|
LEFT JOIN projects p ON p.id = i.project_id
|
||||||
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p PendingInvite
|
var p PendingInvite
|
||||||
var created, expires int64
|
var created, expires int64
|
||||||
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
|
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
|
||||||
&p.InvitedBy, &created, &expires); err != nil {
|
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptInvite marks the invite used and grants the membership it promised, in one
|
// AcceptInvite marks the invite used and grants what it promised, in one
|
||||||
// transaction. Splitting the two would allow a signup to fail partway and leave a
|
// transaction. Splitting the two would allow a signup to fail partway and leave a
|
||||||
// live invite behind, which is a link that still works after it has been used.
|
// live invite behind, which is a link that still works after it has been used.
|
||||||
//
|
//
|
||||||
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
|
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
|
||||||
// same link produce one membership and one failure rather than two memberships.
|
// same link produce one grant and one failure rather than two.
|
||||||
|
//
|
||||||
|
// What is granted depends on what the invite was to: a project membership, a share
|
||||||
|
// on a single note, or — when it carries neither — nothing beyond the account the
|
||||||
|
// caller has just created.
|
||||||
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
|||||||
return ErrInviteInvalid
|
return ErrInviteInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
var projectID sql.NullString
|
var projectID, noteID sql.NullString
|
||||||
var role Role
|
var role Role
|
||||||
|
var invitedBy string
|
||||||
if err := tx.QueryRowContext(ctx,
|
if err := tx.QueryRowContext(ctx,
|
||||||
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
|
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
|
||||||
Scan(&projectID, &role); err != nil {
|
Scan(&projectID, ¬eID, &role, &invitedBy); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if noteID.Valid && noteID.String != "" {
|
||||||
|
// created_by is the person who invited, not the person arriving: the row
|
||||||
|
// records who did the sharing, the same as a share made from the panel.
|
||||||
|
//
|
||||||
|
// The note may have been deleted between the invite and the signup. The
|
||||||
|
// foreign key would refuse the row and take the whole signup with it, so
|
||||||
|
// the note is looked up first and a share on a note that is gone is
|
||||||
|
// simply not made — the account is still worth creating.
|
||||||
|
var alive int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
|
||||||
|
noteID.String, userID).Scan(&alive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alive == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if !projectID.Valid || projectID.String == "" {
|
if !projectID.Valid || projectID.String == "" {
|
||||||
return nil // an invite to the instance, with no project attached
|
return nil // an invite to the instance, with no project attached
|
||||||
}
|
}
|
||||||
@@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- invitationer til én note ----------------------------------------------------
|
||||||
|
|
||||||
|
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
|
||||||
|
// The email address is the whole of who they are — there is no account to name
|
||||||
|
// them by, which is the entire reason the row exists.
|
||||||
|
type NoteInvite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
ExpiresAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNoteInvites is who has been invited to a note and not yet taken it up.
|
||||||
|
//
|
||||||
|
// The panel needs them beside the people who *are* on the note: without this, an
|
||||||
|
// invitation sent yesterday is invisible, and the owner sends it again — a second
|
||||||
|
// link to the same inbox, and no way to take either back.
|
||||||
|
//
|
||||||
|
// Expired ones are left out, the same rule the administrator's list follows: an
|
||||||
|
// invite past its date is not something anybody can act on.
|
||||||
|
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, email, role, created_at, expires_at
|
||||||
|
FROM invites
|
||||||
|
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY created_at`, noteID, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []NoteInvite{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i NoteInvite
|
||||||
|
var created, expires int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
|
||||||
|
// anything else.
|
||||||
|
//
|
||||||
|
// The note id is part of the WHERE rather than checked beforehand: the owner has
|
||||||
|
// been established for *this* note, and one statement that can only touch this
|
||||||
|
// note's rows cannot be talked into deleting a project invite by id.
|
||||||
|
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingNoteInvite reports whether this address already has a live invitation to
|
||||||
|
// this note, so a second attempt can say so instead of sending a second link.
|
||||||
|
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*) FROM invites
|
||||||
|
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
|
||||||
|
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// --- password resets ----------------------------------------------------------
|
// --- password resets ----------------------------------------------------------
|
||||||
|
|
||||||
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
|
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
|
||||||
|
|||||||
@@ -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,18 @@
|
|||||||
|
-- En besked kan handle om en note.
|
||||||
|
--
|
||||||
|
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
|
||||||
|
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
|
||||||
|
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
|
||||||
|
-- for at opdage det.
|
||||||
|
--
|
||||||
|
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
|
||||||
|
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
|
||||||
|
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
|
||||||
|
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
|
||||||
|
-- — kan et generisk par slet ikke have.
|
||||||
|
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
|
||||||
|
-- er nøjagtig det: min, ulæst, om denne note.
|
||||||
|
CREATE INDEX notifications_unread_by_note
|
||||||
|
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- En invitation kan handle om en note.
|
||||||
|
--
|
||||||
|
-- En note kunne deles med nogen, der allerede havde en konto — og kun med dem.
|
||||||
|
-- Skulle en, der ikke havde en, læse med, skulle man først invitere dem til
|
||||||
|
-- instansen, vente på at de oprettede sig, og så finde noten frem og dele den. Tre
|
||||||
|
-- skridt og en ventetid for det, der er én sætning: "lad Sofie se den her".
|
||||||
|
--
|
||||||
|
-- Rækken er den, der allerede findes. En invitation bærer i forvejen en e-mail, en
|
||||||
|
-- rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad
|
||||||
|
-- den er en invitation *til*. project_id sagde det for projekter, og note_id siger
|
||||||
|
-- det nu for noter.
|
||||||
|
--
|
||||||
|
-- To søjler frem for én kolonne med et id og en art ved siden af — samme valg som
|
||||||
|
-- notifications traf i 0033, og af samme grund: fremmednøglen. ON DELETE CASCADE
|
||||||
|
-- er dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage
|
||||||
|
-- imod en invitation til noget, der ikke er der længere. Et generisk par kan ikke
|
||||||
|
-- have den nøgle, og oprydningen skulle så skrives i kode, der huskede at køre.
|
||||||
|
--
|
||||||
|
-- De to udelukker hinanden i praksis, men det står ikke som en CHECK: en
|
||||||
|
-- invitation uden nogen af delene er den gyldige "kom med på instansen", og en
|
||||||
|
-- regel, der skulle tillade nul eller én af to, ville skulle skrives om hver gang
|
||||||
|
-- der kom en tredje slags ting at invitere til.
|
||||||
|
ALTER TABLE invites ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Panelet under en note spørger "hvem er inviteret til den her, og har ikke taget
|
||||||
|
-- imod endnu" hver gang, det åbnes. Det er opslaget, og det er derfor, det er
|
||||||
|
-- indekset. Kun de uafklarede: en invitation, der er taget imod, er en deling nu
|
||||||
|
-- og står i note_shares.
|
||||||
|
CREATE INDEX invites_pending_by_note
|
||||||
|
ON invites (note_id) WHERE accepted_at IS NULL AND note_id IS NOT NULL;
|
||||||
@@ -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;
|
||||||
@@ -372,3 +372,41 @@ func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedSha
|
|||||||
})
|
})
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NoteAudience is everybody who can see a note: its owner, the people it is shared
|
||||||
|
// with — chosen or followed along a link — and the members of the project it is
|
||||||
|
// filed in.
|
||||||
|
//
|
||||||
|
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
|
||||||
|
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
|
||||||
|
// delt direkte, og to beskeder om den samme rettelse er én for mange.
|
||||||
|
//
|
||||||
|
// The note's own row is joined rather than passed in, so a caller cannot ask about
|
||||||
|
// a note that has been deleted and get an audience for it anyway.
|
||||||
|
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT user_id FROM note_shares WHERE note_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
|
||||||
|
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
|
||||||
|
WHERE n.id = ? AND n.deleted_at IS NULL`,
|
||||||
|
noteID, noteID, noteID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Notification struct {
|
|||||||
ActorName string
|
ActorName string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
TaskID string
|
TaskID string
|
||||||
|
NoteID string
|
||||||
Kind string
|
Kind string
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
@@ -32,25 +33,54 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
|||||||
n.CreatedAt = time.Now().UTC()
|
n.CreatedAt = time.Now().UTC()
|
||||||
|
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
|
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
|
||||||
title, body, created_at)
|
title, body, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
||||||
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BumpUnreadNoteNotification folds a second change to the same note into the first,
|
||||||
|
// and says whether it found one to fold into.
|
||||||
|
//
|
||||||
|
// A note is saved on a typing pause, so writing a row per save would turn one
|
||||||
|
// person editing one note for five minutes into a column of twenty identical
|
||||||
|
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
|
||||||
|
// der rørte den sidst.
|
||||||
|
//
|
||||||
|
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
|
||||||
|
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
|
||||||
|
// gentagelse af noget, man allerede har set.
|
||||||
|
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE notifications
|
||||||
|
SET created_at = ?, actor_id = ?, title = ?, body = ?
|
||||||
|
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
|
||||||
|
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
||||||
n.kind, n.title, n.body, n.read_at, n.created_at
|
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
WHERE n.user_id = ?
|
WHERE n.user_id = ?
|
||||||
ORDER BY n.created_at DESC
|
-- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver
|
||||||
|
-- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på
|
||||||
|
-- afgør basen selv, hvilken der står øverst, og klokken viser dem i den
|
||||||
|
-- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er
|
||||||
|
-- den rigtige afgørelse at give den — og den er den samme hver gang.
|
||||||
|
ORDER BY n.created_at DESC, n.id DESC
|
||||||
LIMIT ?`, userID, limit)
|
LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -60,14 +90,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
|||||||
out := []Notification{}
|
out := []Notification{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Notification
|
var n Notification
|
||||||
var actorID, projectID, taskID sql.NullString
|
var actorID, projectID, taskID, noteID sql.NullString
|
||||||
var readAt sql.NullInt64
|
var readAt sql.NullInt64
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
||||||
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
&taskID, ¬eID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
|
n.ActorID, n.ProjectID = actorID.String, projectID.String
|
||||||
|
n.TaskID, n.NoteID = taskID.String, noteID.String
|
||||||
if readAt.Valid {
|
if readAt.Valid {
|
||||||
v := time.Unix(readAt.Int64, 0).UTC()
|
v := time.Unix(readAt.Int64, 0).UTC()
|
||||||
n.ReadAt = &v
|
n.ReadAt = &v
|
||||||
@@ -299,6 +330,52 @@ func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
|
|||||||
return err
|
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) {
|
func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
|
|||||||
@@ -418,6 +418,11 @@ export const api = {
|
|||||||
getMailAddress: () => get('/mail-address'),
|
getMailAddress: () => get('/mail-address'),
|
||||||
rotateMailAddress: () => post('/mail-address/rotate'),
|
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'),
|
getGmail: () => get('/gmail'),
|
||||||
setGmail: (data) => put('/gmail', data),
|
setGmail: (data) => put('/gmail', data),
|
||||||
disconnectGmail: () => del('/gmail'),
|
disconnectGmail: () => del('/gmail'),
|
||||||
@@ -454,7 +459,12 @@ export const api = {
|
|||||||
// path. Owner only; the list call also returns who it can still be shared with.
|
// path. Owner only; the list call also returns who it can still be shared with.
|
||||||
noteShares: (id) => get(`/notes/${id}/shares`),
|
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||||
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
||||||
|
// Deling ved adresse. Har adressen en konto, bliver det en almindelig deling —
|
||||||
|
// svaret siger hvem — og ellers en invitation, der venter på, at kontoen bliver
|
||||||
|
// oprettet.
|
||||||
|
shareNoteByEmail: (id, email, role) => post(`/notes/${id}/shares`, { email, role }),
|
||||||
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
||||||
|
revokeNoteInvite: (id, inviteId) => del(`/notes/${id}/invites/${inviteId}`),
|
||||||
|
|
||||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||||
// there is no instance-wide registration behind these the way Gmail has one.
|
// there is no instance-wide registration behind these the way Gmail has one.
|
||||||
@@ -467,6 +477,14 @@ export const api = {
|
|||||||
setAISettings: (data) => put('/ai/settings', data),
|
setAISettings: (data) => put('/ai/settings', data),
|
||||||
aiSummary: () => post('/ai/summary'),
|
aiSummary: () => post('/ai/summary'),
|
||||||
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
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'),
|
version: () => get('/version'),
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.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;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -87,6 +90,7 @@
|
|||||||
const SYNTAX = [
|
const SYNTAX = [
|
||||||
{ mark: '#', what: 'notes.syntaxProject' },
|
{ mark: '#', what: 'notes.syntaxProject' },
|
||||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||||
|
{ mark: '@', what: 'notes.syntaxPerson' },
|
||||||
{ mark: '⌘B', what: 'notes.bold' },
|
{ mark: '⌘B', what: 'notes.bold' },
|
||||||
{ mark: '⌘U', what: 'notes.underline' }
|
{ mark: '⌘U', what: 'notes.underline' }
|
||||||
];
|
];
|
||||||
@@ -509,6 +513,19 @@
|
|||||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
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);
|
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
if (project) {
|
if (project) {
|
||||||
return {
|
return {
|
||||||
@@ -562,7 +579,12 @@
|
|||||||
// pixels for hvert bogstav, mens listen snævres ind.
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
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;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +608,16 @@
|
|||||||
.sort((a, b) => rank(a.label, b.label, term));
|
.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) {
|
function rank(a, b, term) {
|
||||||
const al = a.toLowerCase();
|
const al = a.toLowerCase();
|
||||||
const bl = b.toLowerCase();
|
const bl = b.toLowerCase();
|
||||||
@@ -1011,6 +1043,10 @@
|
|||||||
{#if item.kind === 'note'}
|
{#if item.kind === 'note'}
|
||||||
<span class="dot note" aria-hidden="true">[[</span>
|
<span class="dot note" aria-hidden="true">[[</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{:else if item.kind === 'person'}
|
||||||
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
@{item.label}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Klokken.
|
||||||
|
*
|
||||||
|
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
|
||||||
|
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
|
||||||
|
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
|
||||||
|
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
|
||||||
|
* delt projekt, er en klokke, folk holder op med at se på.
|
||||||
|
*
|
||||||
|
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
|
||||||
|
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
|
||||||
|
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
|
||||||
|
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
|
||||||
|
*/
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let wrap = $state(null);
|
||||||
|
|
||||||
|
const KINDS = {
|
||||||
|
assigned: 'notif.assigned',
|
||||||
|
'note.changed': 'notif.noteChanged',
|
||||||
|
'note.shared': 'notif.noteShared',
|
||||||
|
'note.mention': 'notif.mention',
|
||||||
|
comment: 'notif.comment'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
|
||||||
|
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
|
||||||
|
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
|
||||||
|
if (minutes < 1) return t('notif.justNow');
|
||||||
|
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
|
||||||
|
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor en besked fører hen.
|
||||||
|
*
|
||||||
|
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
|
||||||
|
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
|
||||||
|
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
|
||||||
|
* man går hen.
|
||||||
|
*/
|
||||||
|
function follow(n) {
|
||||||
|
open = false;
|
||||||
|
if (!n.read) app.markRead(n.id);
|
||||||
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outside(event) {
|
||||||
|
if (open && wrap && !wrap.contains(event.target)) open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onclick={outside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open) open = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap" bind:this={wrap}>
|
||||||
|
<button
|
||||||
|
class="bell"
|
||||||
|
class:on={open}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={app.unread
|
||||||
|
? t('notif.titleWithCount', { n: String(app.unread) })
|
||||||
|
: t('notif.title')}
|
||||||
|
title={t('notif.title')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10.3 21a2 2 0 003.4 0" />
|
||||||
|
</svg>
|
||||||
|
{#if app.unread}
|
||||||
|
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
|
||||||
|
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
|
||||||
|
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
||||||
|
<header>
|
||||||
|
<span>{t('notif.title')}</span>
|
||||||
|
{#if app.unread}
|
||||||
|
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if app.notifications.length}
|
||||||
|
<ul>
|
||||||
|
{#each app.notifications as n (n.id)}
|
||||||
|
<li>
|
||||||
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
|
<span class="what">{headline(n)}</span>
|
||||||
|
{#if n.body}<span class="about">{n.body}</span>{/if}
|
||||||
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="none">{t('notif.empty')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bell:hover,
|
||||||
|
.bell.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.bell svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 15px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent, #fff);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
width: min(340px, calc(100vw - var(--s3) * 2));
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.allread {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.allread:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
|
||||||
|
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
|
||||||
|
kunne se hvad der var én besked. */
|
||||||
|
.row.unread .what {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.row.unread .what::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ago {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3) var(--s2);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.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 { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
@@ -31,6 +32,9 @@
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await api.logout();
|
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
|
// 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
|
// 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.
|
// the next person to sign in on this browser must not find them.
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ export const da = {
|
|||||||
'notes.unshared': 'Noten er din igen.',
|
'notes.unshared': 'Noten er din igen.',
|
||||||
// Deling med enkeltpersoner, ved siden af projektet.
|
// Deling med enkeltpersoner, ved siden af projektet.
|
||||||
'notes.groupSharedWithMe': 'Delt med mig',
|
'notes.groupSharedWithMe': 'Delt med mig',
|
||||||
|
// Klokken: hvad andre har gjort, som man skal vide. Sætningen skrives i fladen
|
||||||
|
// og ikke på serveren, så en engelsk flade også får en engelsk klokke.
|
||||||
|
'notif.title': 'Beskeder',
|
||||||
|
'notif.titleWithCount': 'Beskeder ({n} ulæste)',
|
||||||
|
'notif.empty': 'Ingen beskeder.',
|
||||||
|
'notif.markAllRead': 'Markér alle som læst',
|
||||||
|
'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',
|
||||||
|
'notif.hours': '{n} t. siden',
|
||||||
'notes.linkOpen': 'Åbn',
|
'notes.linkOpen': 'Åbn',
|
||||||
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
||||||
'notes.sharedBy': 'Delt af {name}',
|
'notes.sharedBy': 'Delt af {name}',
|
||||||
@@ -38,6 +52,21 @@ export const da = {
|
|||||||
'notes.shareFollows': 'Følger med:',
|
'notes.shareFollows': 'Følger med:',
|
||||||
'notes.shareVia': 'følger med “{note}”',
|
'notes.shareVia': 'følger med “{note}”',
|
||||||
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
||||||
|
// Deling med en adresse. Ordene skal kunne bære begge udfald — en deling nu
|
||||||
|
// eller en invitation, der venter — uden at love det ene.
|
||||||
|
'notes.shareByEmail': 'Del med en e-mailadresse',
|
||||||
|
'notes.shareEmailPlaceholder': 'navn@eksempel.dk',
|
||||||
|
'notes.shareRole': 'Rolle',
|
||||||
|
'notes.shareInvite': 'Inviter',
|
||||||
|
'notes.invited': 'Invitationen er sendt til {email}.',
|
||||||
|
'notes.invitePending': 'inviteret',
|
||||||
|
'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.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||||
'notes.save': 'Gem',
|
'notes.save': 'Gem',
|
||||||
'notes.format': 'Formatér',
|
'notes.format': 'Formatér',
|
||||||
@@ -48,6 +77,22 @@ export const da = {
|
|||||||
'notes.sortUpdated': 'Senest rørt',
|
'notes.sortUpdated': 'Senest rørt',
|
||||||
'notes.sortCreated': 'Oprettet',
|
'notes.sortCreated': 'Oprettet',
|
||||||
'notes.sortTitle': 'Navn',
|
'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.title': 'Tælling af installationer',
|
||||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||||
'beacon.send': 'Send en anonym daglig melding',
|
'beacon.send': 'Send en anonym daglig melding',
|
||||||
@@ -722,6 +767,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.',
|
'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.yourAddress': 'Din adresse',
|
||||||
'int.newAddress': 'Ny 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.caldav': 'CalDAV',
|
||||||
'int.caldavHint':
|
'int.caldavHint':
|
||||||
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
|
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ export const en = {
|
|||||||
'notes.unshared': 'The note is yours again.',
|
'notes.unshared': 'The note is yours again.',
|
||||||
// Sharing with individual people, alongside the project.
|
// Sharing with individual people, alongside the project.
|
||||||
'notes.groupSharedWithMe': 'Shared with me',
|
'notes.groupSharedWithMe': 'Shared with me',
|
||||||
|
// The bell: what other people did that you need to know about. The sentence is
|
||||||
|
// composed here rather than on the server, so an English interface gets an
|
||||||
|
// English bell.
|
||||||
|
'notif.title': 'Notifications',
|
||||||
|
'notif.titleWithCount': 'Notifications ({n} unread)',
|
||||||
|
'notif.empty': 'Nothing yet.',
|
||||||
|
'notif.markAllRead': 'Mark all as read',
|
||||||
|
'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',
|
||||||
|
'notif.hours': '{n} h ago',
|
||||||
'notes.linkOpen': 'Open',
|
'notes.linkOpen': 'Open',
|
||||||
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
||||||
'notes.sharedBy': 'Shared by {name}',
|
'notes.sharedBy': 'Shared by {name}',
|
||||||
@@ -34,6 +49,21 @@ export const en = {
|
|||||||
'notes.shareFollows': 'Comes along:',
|
'notes.shareFollows': 'Comes along:',
|
||||||
'notes.shareVia': 'came along with “{note}”',
|
'notes.shareVia': 'came along with “{note}”',
|
||||||
'notes.unsharedPerson': '{name} can no longer see the note.',
|
'notes.unsharedPerson': '{name} can no longer see the note.',
|
||||||
|
// Sharing with an address. The words have to carry both outcomes — a share now,
|
||||||
|
// or an invitation that waits — without promising either one.
|
||||||
|
'notes.shareByEmail': 'Share with an email address',
|
||||||
|
'notes.shareEmailPlaceholder': 'name@example.com',
|
||||||
|
'notes.shareRole': 'Role',
|
||||||
|
'notes.shareInvite': 'Invite',
|
||||||
|
'notes.invited': 'The invitation has been sent to {email}.',
|
||||||
|
'notes.invitePending': 'invited',
|
||||||
|
'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.shareOwnerOnly': 'Only the owner can share the note.',
|
||||||
'notes.save': 'Save',
|
'notes.save': 'Save',
|
||||||
'notes.format': 'Format',
|
'notes.format': 'Format',
|
||||||
@@ -44,6 +74,22 @@ export const en = {
|
|||||||
'notes.sortUpdated': 'Last touched',
|
'notes.sortUpdated': 'Last touched',
|
||||||
'notes.sortCreated': 'Created',
|
'notes.sortCreated': 'Created',
|
||||||
'notes.sortTitle': 'Name',
|
'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.title': 'Counting installations',
|
||||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||||
'beacon.send': 'Send an anonymous daily report',
|
'beacon.send': 'Send an anonymous daily report',
|
||||||
@@ -718,6 +764,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.',
|
'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.yourAddress': 'Your address',
|
||||||
'int.newAddress': 'New 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.caldav': 'CalDAV',
|
||||||
'int.caldavHint':
|
'int.caldavHint':
|
||||||
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
|
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class AppState {
|
|||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
this.people = people;
|
this.people = people;
|
||||||
this.connect();
|
this.connect();
|
||||||
|
this.loadNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,10 +146,64 @@ class AppState {
|
|||||||
socket.onerror = () => socket.close();
|
socket.onerror = () => socket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- klokken -----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje:
|
||||||
|
// hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En
|
||||||
|
// klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste —
|
||||||
|
// og en besked, man skal genindlæse for at se, er ikke en besked.
|
||||||
|
|
||||||
|
notifications = $state([]);
|
||||||
|
unread = $state(0);
|
||||||
|
|
||||||
|
async loadNotifications() {
|
||||||
|
try {
|
||||||
|
const r = await api.listNotifications();
|
||||||
|
this.notifications = r.notifications ?? [];
|
||||||
|
this.unread = r.unread ?? 0;
|
||||||
|
} catch {
|
||||||
|
// En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks one read, or all of them when no id is given. */
|
||||||
|
async markRead(id) {
|
||||||
|
const previous = this.notifications;
|
||||||
|
const wasUnread = this.unread;
|
||||||
|
this.notifications = this.notifications.map((n) =>
|
||||||
|
!id || n.id === id ? { ...n, read: true } : n
|
||||||
|
);
|
||||||
|
this.unread = this.notifications.filter((n) => !n.read).length;
|
||||||
|
try {
|
||||||
|
await api.markNotificationsRead(id);
|
||||||
|
} catch {
|
||||||
|
this.notifications = previous;
|
||||||
|
this.unread = wasUnread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Applies a change made by somebody else. */
|
/** Applies a change made by somebody else. */
|
||||||
#applyRemote(event) {
|
#applyRemote(event) {
|
||||||
const task = event.payload;
|
const task = event.payload;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
// Beskeder kommer på personens egen kanal, ikke projektets: en note delt
|
||||||
|
// med én person hører ikke til noget projekt, og der er intet rum at
|
||||||
|
// udsende den i.
|
||||||
|
case 'notification':
|
||||||
|
this.notifications = [
|
||||||
|
{ ...event.payload, read: false },
|
||||||
|
...this.notifications.filter((n) => n.id !== event.payload?.id)
|
||||||
|
].slice(0, 30);
|
||||||
|
this.unread = this.notifications.filter((n) => !n.read).length;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// En rettelse, der er lagt oven i en besked, man allerede har. Tallet er
|
||||||
|
// uændret; linjen er en anden. Hentet frem for flettet, fordi serveren
|
||||||
|
// har det rigtige svar og listen er tredive rækker.
|
||||||
|
case 'notification.folded':
|
||||||
|
this.loadNotifications();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'task.created':
|
case 'task.created':
|
||||||
case 'task.updated':
|
case 'task.updated':
|
||||||
case 'task.completed':
|
case 'task.completed':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||||
|
import Notifications from '$lib/components/Notifications.svelte';
|
||||||
import { connectivity } from '$lib/connectivity.svelte.js';
|
import { connectivity } from '$lib/connectivity.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { setBadge } from '$lib/badge.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -52,6 +54,13 @@
|
|||||||
app.load();
|
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.
|
* Global keys.
|
||||||
*
|
*
|
||||||
@@ -169,6 +178,8 @@
|
|||||||
<kbd>⌘K</kbd>
|
<kbd>⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Notifications />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="theme"
|
class="theme"
|
||||||
onclick={() => theme.toggle()}
|
onclick={() => theme.toggle()}
|
||||||
|
|||||||
@@ -289,6 +289,9 @@
|
|||||||
|
|
||||||
let feedURL = $state('');
|
let feedURL = $state('');
|
||||||
let mailAddress = $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. */
|
/** Null until loaded, so the warning does not flash before the answer is in. */
|
||||||
let mailConfigured = $state(null);
|
let mailConfigured = $state(null);
|
||||||
|
|
||||||
@@ -301,6 +304,10 @@
|
|||||||
mailConfigured = r.configured;
|
mailConfigured = r.configured;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
api
|
||||||
|
.getHookURL()
|
||||||
|
.then((r) => (hookURL = r.url))
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function rotateFeed() {
|
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) {
|
async function copy(text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -674,6 +690,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.caldav')}</h2>
|
<h2>{t('int.caldav')}</h2>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
|
|
||||||
@@ -199,8 +200,34 @@
|
|||||||
let sharePick = $state('');
|
let sharePick = $state('');
|
||||||
let shareRole = $state('viewer');
|
let shareRole = $state('viewer');
|
||||||
let shareBusy = $state(false);
|
let shareBusy = $state(false);
|
||||||
|
// De inviterede: delinger, der venter på, at der bliver oprettet en konto.
|
||||||
|
// De står i panelet ved siden af dem, der er kommet — ellers er en invitation
|
||||||
|
// sendt i går usynlig, og så sender man den igen.
|
||||||
|
let shareInvites = $state([]);
|
||||||
|
let shareEmail = $state('');
|
||||||
|
// 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);
|
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
|
// 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
|
// one button, so the note's footer stays a status and three buttons rather than
|
||||||
// a row that runs off its own width.
|
// a row that runs off its own width.
|
||||||
@@ -223,11 +250,15 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = selected?.id;
|
const id = selected?.id;
|
||||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
// Nulstil ved hvert skift, så den forrige notes folk — og den forrige notes
|
||||||
// under den nye.
|
// forslag — ikke står et øjeblik under den nye.
|
||||||
|
showActions = false;
|
||||||
shares = [];
|
shares = [];
|
||||||
shareCandidates = [];
|
shareCandidates = [];
|
||||||
shareFollows = [];
|
shareFollows = [];
|
||||||
|
shareInvites = [];
|
||||||
|
shareEmail = '';
|
||||||
|
shareInviteLink = '';
|
||||||
sharePick = '';
|
sharePick = '';
|
||||||
if (!id || selected.created_by !== app.user?.id) return;
|
if (!id || selected.created_by !== app.user?.id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -238,6 +269,7 @@
|
|||||||
shares = r.shares ?? [];
|
shares = r.shares ?? [];
|
||||||
shareCandidates = r.candidates ?? [];
|
shareCandidates = r.candidates ?? [];
|
||||||
shareFollows = r.follows ?? [];
|
shareFollows = r.follows ?? [];
|
||||||
|
shareInvites = r.invites ?? [];
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -274,6 +306,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deling med en adresse frem for et navn i listen.
|
||||||
|
*
|
||||||
|
* Ét felt til begge udfald. Man skriver adressen på den, der skal læse med, og
|
||||||
|
* det er serveren, der ved, om der er en konto bag: er der det, bliver det en
|
||||||
|
* deling med det samme, og ellers en invitation, der bliver til en deling, når
|
||||||
|
* kontoen oprettes. At skulle vide det på forhånd — og vælge det rigtige felt —
|
||||||
|
* er at skulle vide noget om instansens brugerliste for at dele en note.
|
||||||
|
*/
|
||||||
|
async function inviteByEmail() {
|
||||||
|
if (!selected || !shareEmail.trim() || shareBusy) return;
|
||||||
|
shareBusy = true;
|
||||||
|
shareInviteLink = '';
|
||||||
|
try {
|
||||||
|
const r = await api.shareNoteByEmail(selected.id, shareEmail.trim(), shareRole);
|
||||||
|
if (r?.invited) {
|
||||||
|
shareInvites = [...shareInvites, r.invited];
|
||||||
|
// Kan instansen ikke sende post, er linket det eneste, der findes:
|
||||||
|
// vist frem for slugt, så det kan gives videre ad anden vej.
|
||||||
|
if (!r.emailed && r.link) shareInviteLink = r.link;
|
||||||
|
app.toast(t('notes.invited', { email: r.invited.email }));
|
||||||
|
} else if (r?.user) {
|
||||||
|
shares = [...shares, { user: r.user, role: r.role ?? shareRole }].sort((a, b) =>
|
||||||
|
byName(a.user, b.user)
|
||||||
|
);
|
||||||
|
shareCandidates = shareCandidates.filter((p) => p.id !== r.user.id);
|
||||||
|
shareFollows = r?.follows ?? shareFollows;
|
||||||
|
app.toast(t('notes.sharedToast', { name: r.user.name ?? '' }));
|
||||||
|
}
|
||||||
|
shareEmail = '';
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
shareBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInvite(inviteId, email) {
|
||||||
|
try {
|
||||||
|
await api.revokeNoteInvite(selected.id, inviteId);
|
||||||
|
shareInvites = shareInvites.filter((i) => i.id !== inviteId);
|
||||||
|
shareInviteLink = '';
|
||||||
|
app.toast(t('notes.inviteRevoked', { email }));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function changeShareRole(userId, role) {
|
async function changeShareRole(userId, role) {
|
||||||
try {
|
try {
|
||||||
await api.shareNote(selected.id, userId, role);
|
await api.shareNote(selected.id, userId, role);
|
||||||
@@ -599,6 +679,7 @@
|
|||||||
// Only if it is still the one on screen: switching notes mid-save must not
|
// 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.
|
// drag the previous one's title back onto this one.
|
||||||
if (selected?.id === id) selected = saved;
|
if (selected?.id === id) selected = saved;
|
||||||
|
await mentionsShared(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -606,6 +687,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
|
* 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
|
* one is the same act as saying "keep this where I can see it" — one idea, not
|
||||||
@@ -968,6 +1082,7 @@
|
|||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
|
people={mentionable}
|
||||||
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
@@ -976,6 +1091,16 @@
|
|||||||
onsave={save}
|
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>
|
<footer>
|
||||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||||
{#if links.length}
|
{#if links.length}
|
||||||
@@ -1083,6 +1208,25 @@
|
|||||||
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if shareInvites.length}
|
||||||
|
<!-- Sendt, ikke taget imod endnu. Adressen er alt, hvad der findes
|
||||||
|
om personen indtil de dukker op — der er hverken navn eller
|
||||||
|
farve at vise, og det er selve pointen med linjen. -->
|
||||||
|
<ul class="invited">
|
||||||
|
{#each shareInvites as inv (inv.id)}
|
||||||
|
<li>
|
||||||
|
<span class="who">{inv.email}</span>
|
||||||
|
<span class="pending">{t('notes.invitePending')}</span>
|
||||||
|
<button
|
||||||
|
class="unshare"
|
||||||
|
onclick={() => revokeInvite(inv.id, inv.email)}
|
||||||
|
title={t('notes.inviteRevoke')}
|
||||||
|
aria-label={t('notes.inviteRevoke')}>×</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{#if shareCandidates.length}
|
{#if shareCandidates.length}
|
||||||
<div class="addshare">
|
<div class="addshare">
|
||||||
<select bind:value={sharePick}>
|
<select bind:value={sharePick}>
|
||||||
@@ -1104,6 +1248,40 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Og den, der ikke står i listen. Ét felt til begge udfald: har
|
||||||
|
adressen en konto, bliver det en deling med det samme, og ellers
|
||||||
|
en invitation. Hvem der har en konto, er ikke noget, man skal
|
||||||
|
vide for at dele en note. -->
|
||||||
|
<form
|
||||||
|
class="addshare byemail"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
inviteByEmail();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:value={shareEmail}
|
||||||
|
type="email"
|
||||||
|
placeholder={t('notes.shareEmailPlaceholder')}
|
||||||
|
aria-label={t('notes.shareByEmail')}
|
||||||
|
/>
|
||||||
|
<select bind:value={shareRole} aria-label={t('notes.shareRole')}>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button class="button" type="submit" disabled={!shareEmail || shareBusy}>
|
||||||
|
{t('notes.shareInvite')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if shareInviteLink}
|
||||||
|
<!-- Ingen post at sende det med. Så er linket det eneste, der
|
||||||
|
findes, og det skal kunne læses og kopieres frem for at blive
|
||||||
|
lovet i en besked, der ikke kom af sted. -->
|
||||||
|
<p class="invitelink">
|
||||||
|
{t('notes.inviteNoMail')}
|
||||||
|
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1111,6 +1289,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<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}
|
{#if ownsSelected}
|
||||||
<!-- Laid away, not thrown away: archive is the note leaving the list
|
<!-- Laid away, not thrown away: archive is the note leaving the list
|
||||||
without leaving the account, and it reads "bring it back" once it
|
without leaving the account, and it reads "bring it back" once it
|
||||||
@@ -1982,6 +2166,63 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* De inviterede. Samme form som listen ovenover, fordi det er den samme slags
|
||||||
|
linje — en person, noten er delt med — og forskellen er, at de ikke er kommet
|
||||||
|
endnu. Den forskel siges med ordet til højre, ikke med en anden form. */
|
||||||
|
.invited {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invited li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invited .who {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invited .pending {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adressefeltet får sin egen række af samme grund som personvælgeren: en
|
||||||
|
e-mailadresse klemt ned ved siden af en rolle og en knap bliver et felt, man
|
||||||
|
ikke kan læse det, man har skrevet i. */
|
||||||
|
.byemail input {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 2px var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitelink {
|
||||||
|
margin: var(--s1) 0 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitelink input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 2px var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
.addshare {
|
.addshare {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
|
import AISuggestions from '$lib/components/AISuggestions.svelte';
|
||||||
import BoardView from '$lib/components/BoardView.svelte';
|
import BoardView from '$lib/components/BoardView.svelte';
|
||||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
@@ -31,6 +32,10 @@
|
|||||||
let members = $state([]);
|
let members = $state([]);
|
||||||
let showShare = $state(false);
|
let showShare = $state(false);
|
||||||
let showLog = $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 activity = $state([]);
|
||||||
let inviteEmail = $state('');
|
let inviteEmail = $state('');
|
||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
@@ -613,6 +618,18 @@
|
|||||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if project.is_inbox}
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => {
|
||||||
|
showTidy = !showTidy;
|
||||||
|
showMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('ai.tidyInbox')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !project.is_inbox}
|
{#if !project.is_inbox}
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -665,6 +682,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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}
|
{#if showLog}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<ul class="log">
|
<ul class="log">
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ self.addEventListener('push', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = payload.title || 'verdande';
|
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(
|
event.waitUntil(
|
||||||
self.registration.showNotification(title, {
|
self.registration.showNotification(title, {
|
||||||
body: payload.body || '',
|
body: payload.body || '',
|
||||||
|
|||||||
Reference in New Issue
Block a user