Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c | ||
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 | ||
|
|
7f8d925299 | ||
|
|
cdab817141 | ||
|
|
2e76ced5fa | ||
|
|
643b30555e | ||
|
|
91671a4f97 |
@@ -805,6 +805,28 @@ paths:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/tasks/{taskID}/snooze:
|
||||
parameters:
|
||||
- { name: taskID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [tasks]
|
||||
summary: Snooze a task until a time, or wake it.
|
||||
description: |
|
||||
Parks the task at the bottom of its list, greyed, until `until` passes —
|
||||
nothing else about it moves. An empty `until` wakes it now.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add:
|
||||
post:
|
||||
tags: [tasks]
|
||||
@@ -3072,6 +3094,69 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares:
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Who a note is shared with, and who it can be shared with.
|
||||
description: >
|
||||
Owner only. Returns the people the note is already shared with, each with
|
||||
their role, and the candidates the owner may add — everyone on the instance
|
||||
who is not already on the note.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shares:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteShare" }
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Share a note with a person, or change the role they hold.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"204": { description: Shared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares/{userID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Take a person's access to a note away again.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: userID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Unshared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/linking/{kind}/{targetID}:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3907,6 +3992,14 @@ components:
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
shared_with_me:
|
||||
type: boolean
|
||||
description: >
|
||||
True when the reader reached this note through a direct share rather
|
||||
than by owning it or its project. What puts it in the Delt med mig group.
|
||||
owner:
|
||||
allOf: [{ $ref: "#/components/schemas/Person" }]
|
||||
description: Who the note belongs to, present on a note shared with the reader.
|
||||
links:
|
||||
type: array
|
||||
description: What the body points at, read out of the text on the way in.
|
||||
@@ -3923,6 +4016,21 @@ components:
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Person:
|
||||
type: object
|
||||
description: A user, as much of one as a name and a colour.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
avatar_color: { type: string }
|
||||
|
||||
NoteShare:
|
||||
type: object
|
||||
description: One person's direct grant on a note.
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
|
||||
Mailbox:
|
||||
type: object
|
||||
description: One connected mailbox. The password and any token are never returned.
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 216 KiB |
@@ -19,21 +19,38 @@ import (
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
// Access has three ways in, and a note may have any of them at once. A note with
|
||||
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||
// note in a project is readable by everyone who can read the project and writable
|
||||
// by an editor. And a note can be shared with a person directly, which grants that
|
||||
// person a role of its own. The effective role is the most any of the three allow.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||
// on a shared project or a shared note, may do the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
// The owner and the project path, exactly as before.
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
if n.CreatedBy == user.ID {
|
||||
return true
|
||||
}
|
||||
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
// A direct share grants its role on top of whatever the above allow.
|
||||
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||
return shareRole.AtLeast(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||
// change its text; giving away access is the owner's alone, the same shape a
|
||||
// project takes where members edit but only the owner invites.
|
||||
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
@@ -206,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||
// change the text; letting them also move the note would let them file
|
||||
// somebody else's note into a project the owner is not in — and the owner,
|
||||
// who is not a member there, would lose their own note. So the move is
|
||||
// owner-only, and then only into a project the owner may write to.
|
||||
if !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||
return
|
||||
}
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||
// — may change its text, but throwing away the owner's note is the owner's to
|
||||
// do. A project editor keeps the reach they have always had over a project's
|
||||
// notes; a direct share does not extend that far.
|
||||
mayDelete := s.ownsNote(r, n)
|
||||
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||
mayDelete = perr == nil
|
||||
}
|
||||
if n == nil || !mayDelete {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
@@ -280,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||
// the second, so two notes made in the same second have no order between them —
|
||||
// and then which of two same-titled notes gets the plain name and which gets
|
||||
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||
return notes[i].ID < notes[j].ID
|
||||
}
|
||||
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// markShared fills in the two fields the list needs to show a note somebody else
|
||||
// shared with the reader: that it is such a note, and whose it is.
|
||||
//
|
||||
// Done in one pass over the page rather than per note: one query asks which of
|
||||
// these ids are shared with the reader, and one asks for the owners' names and
|
||||
// colours. A note the reader owns is never "shared with me", however it was
|
||||
// reached — the group is other people's notes, not one's own.
|
||||
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
|
||||
me := userFrom(r.Context()).ID
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.CreatedBy != me {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
|
||||
if err != nil {
|
||||
// The list is worth more than the chip: a note without its "shared" mark
|
||||
// still reads and still opens. Log nothing here — the store already did.
|
||||
return notes
|
||||
}
|
||||
|
||||
owners := map[string]store.Person{}
|
||||
if len(shared) > 0 {
|
||||
ownerIDs := make([]string, 0, len(shared))
|
||||
for _, n := range notes {
|
||||
if shared[n.ID] && n.CreatedBy != "" {
|
||||
ownerIDs = append(ownerIDs, n.CreatedBy)
|
||||
}
|
||||
}
|
||||
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
|
||||
}
|
||||
|
||||
for i := range notes {
|
||||
if !shared[notes[i].ID] {
|
||||
continue
|
||||
}
|
||||
notes[i].SharedWithMe = true
|
||||
if p, ok := owners[notes[i].CreatedBy]; ok {
|
||||
owner := p
|
||||
notes[i].Owner = &owner
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// handleListNoteShares is who a note is shared with. Owner only: the list of people
|
||||
// who can see your note is itself something only you should see.
|
||||
func (s *Server) handleListNoteShares(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
|
||||
}
|
||||
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note shares", err)
|
||||
return
|
||||
}
|
||||
out := make([]noteShareJSON, 0, len(shares))
|
||||
already := map[string]bool{}
|
||||
for _, sh := range shares {
|
||||
out = append(out, noteShareJSON{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
})
|
||||
already[sh.User.ID] = true
|
||||
}
|
||||
|
||||
// The picker is filled from the same response, so the panel opens complete in
|
||||
// one round-trip. People already on the note are left out — they are in the list
|
||||
// above it, not the "add someone" menu.
|
||||
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
cand := make([]personJSON, 0, len(candidates))
|
||||
for _, p := range candidates {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
User personJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type shareNoteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareNoteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
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.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if role == "" {
|
||||
role = store.RoleViewer
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleEditor {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
// 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.
|
||||
if req.UserID == userFrom(r.Context()).ID {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUnshareNote takes a person's access away again.
|
||||
func (s *Server) handleUnshareNote(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.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
|
||||
s.internal(w, r, "unshare note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createNote makes a note as the current client and returns its id.
|
||||
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create note returned no id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func userID(t *testing.T, ts *testServer, email string) string {
|
||||
t.Helper()
|
||||
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("look up %s: %v", email, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||
// somebody, and a viewer can read it but not change it.
|
||||
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
// Before sharing, Sofie cannot see it at all.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||
notes, _ := list["notes"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range notes {
|
||||
n := raw.(map[string]any)
|
||||
if n["id"] == noteID {
|
||||
found = n
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the shared note is not in the recipient's list")
|
||||
}
|
||||
if found["shared_with_me"] != true {
|
||||
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||
}
|
||||
ownerObj, _ := found["owner"].(map[string]any)
|
||||
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||
}
|
||||
|
||||
// A viewer reads it.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||
}
|
||||
// But cannot change it.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An editor may change the text, but only the owner may delete the note or hand it
|
||||
// to somebody else.
|
||||
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
// The editor writes.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The editor cannot re-share it: giving access is the owner's alone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||
}
|
||||
// The editor cannot delete it.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("an editor deleted the owner's note")
|
||||
}
|
||||
// The note still exists for the owner.
|
||||
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharing takes access away again, and only the owner can do it.
|
||||
func TestUnshareRemovesAccess(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Hemmelig")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
// A non-owner cannot unshare.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The owner can.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||
}
|
||||
// And access is gone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The share list is owner-only and offers the instance's other people as
|
||||
// candidates, minus those already on the note.
|
||||
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
owner.newUser(t, "[email protected]", "Anders")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Plan")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
shares, _ := out["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("shares = %d, want 1", len(shares))
|
||||
}
|
||||
cands, _ := out["candidates"].([]any)
|
||||
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||
names := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
names[c.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||
}
|
||||
if names["Sofie"] {
|
||||
t.Error("somebody already on the note is offered as a candidate")
|
||||
}
|
||||
if names["Kristian"] {
|
||||
t.Error("the owner is offered as a candidate to share with themselves")
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing refuses a made-up person and the owner themselves.
|
||||
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
me := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Note")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
return out
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
target := "/projekt/" + projectID
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
Public: public, Private: private, Subject: subject,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||
s.log.Warn("mark push delivered", "err", delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Warn, not Debug.
|
||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||
}
|
||||
|
||||
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||
//
|
||||
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||
// what the old fallback, mailto:[email protected], was on an instance with
|
||||
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||
// the person waiting for the notification could tell.
|
||||
//
|
||||
// So a real address is used when there is one, and otherwise the instance's own
|
||||
// https address — which is a valid VAPID subject, points at something that exists,
|
||||
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||
// the https URL a 201.
|
||||
func (s *Server) vapidSubject() string {
|
||||
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||
return "mailto:" + from
|
||||
}
|
||||
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||
return s.cfg.BaseURL
|
||||
}
|
||||
return "mailto:[email protected]"
|
||||
}
|
||||
|
||||
// --- subscription endpoints -----------------------------------------------------------
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||
// every push to an Apple device was refused. With no real address, the instance's
|
||||
// own https URL is used instead — valid, real, and accepted.
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
from string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"a real address is used as-is", "[email protected]", "https://verdande.example.dk", "mailto:[email protected]"},
|
||||
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||
if got := s.vapidSubject(); got != tc.want {
|
||||
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
// Sharing a note with a person directly — its own access list, beside
|
||||
// the project path. Owner only; see notes_share_handlers.go.
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
@@ -546,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,23 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type taskJSON struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
if t.CompletedAt != nil {
|
||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.SnoozedUntil != nil {
|
||||
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type snoozeRequest struct {
|
||||
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||
// but when the task wants to be seen — its due date and everything else stay put.
|
||||
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req snoozeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
var until *time.Time
|
||||
if strings.TrimSpace(req.Until) != "" {
|
||||
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||
if perr != nil {
|
||||
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||
return
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||
s.storeError(w, r, "snooze task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
verb := "task.snoozed"
|
||||
if until == nil {
|
||||
verb = "task.unsnoozed"
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||
// it puts it back. The list order is what the row's dimming rides on.
|
||||
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||
aID, _ := a["id"].(string)
|
||||
|
||||
// Snooze Alfa two days out.
|
||||
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||
t.Error("the response does not carry snoozed_until")
|
||||
}
|
||||
|
||||
// Alfa now sorts after Bravo, though it was created first.
|
||||
order := func() []string {
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
var out []string
|
||||
for _, raw := range list["tasks"].([]any) {
|
||||
out = append(out, raw.(map[string]any)["content"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := order()
|
||||
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||
}
|
||||
|
||||
// Wake it, and it comes back above Bravo.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||
t.Errorf("waking left snoozed_until set: %q", s)
|
||||
}
|
||||
if got := order(); got[0] != "Alfa" {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
||||
|
||||
for _, t := range cal.Tasks {
|
||||
writeTodo(&b, t, cal.Domain)
|
||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
||||
if t.DueDatetime != nil && !t.Completed {
|
||||
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||
// VTODO — which is all of them: Apple Calendar and Google both drop a
|
||||
// subscribed to-do on the floor and show only events. A task with a clock
|
||||
// time becomes a timed event; one with only a date, an all-day event. Once
|
||||
// this was limited to timed tasks, so a date-only task — most of them —
|
||||
// simply never appeared in the calendar it was subscribed into.
|
||||
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
|
||||
writeEvent(&b, t, cal.Domain)
|
||||
}
|
||||
}
|
||||
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
|
||||
}
|
||||
|
||||
func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
||||
// sharing a UID would make a client treat them as two versions of one item.
|
||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(end))
|
||||
|
||||
if t.DueDatetime != nil {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
|
||||
} else {
|
||||
// A date with no time is an all-day event. DTEND is exclusive — a one-day
|
||||
// event ends on the following date — which is what keeps it a single day in
|
||||
// the grid rather than bleeding into the next.
|
||||
day := strings.ReplaceAll(t.DueDate, "-", "")
|
||||
writeLine(b, "DTSTART;VALUE=DATE:"+day)
|
||||
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
|
||||
}
|
||||
|
||||
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||
if t.Description != "" {
|
||||
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
||||
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
|
||||
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
|
||||
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
|
||||
// DTEND. A malformed date is returned as its own compacted form rather than
|
||||
// dropped, so a task never loses its event over a bad string.
|
||||
func nextDay(date string) string {
|
||||
d, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(date, "-", "")
|
||||
}
|
||||
return d.AddDate(0, 0, 1).Format("20060102")
|
||||
}
|
||||
|
||||
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||
// summary at that character in every client.
|
||||
|
||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
||||
|
||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||
// or a feed is useless in the calendar most people actually keep.
|
||||
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
||||
func TestDatedTasksBecomeEvents(t *testing.T) {
|
||||
out := Render(sample())
|
||||
|
||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
||||
strings.Count(out, "BEGIN:VEVENT"))
|
||||
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||
// date and no event.
|
||||
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
|
||||
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
|
||||
}
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
||||
t.Error("the event has no start")
|
||||
|
||||
// The timed one keeps its clock and its 45-minute duration.
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the timed event's start or duration is wrong")
|
||||
}
|
||||
// 45 minutes of stated duration.
|
||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the event's end does not reflect the task's duration")
|
||||
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||
// they sit on their own day rather than bleeding into the next.
|
||||
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
|
||||
t.Error("the date-only event is not a single all-day event")
|
||||
}
|
||||
// A recurring date-only task carries its rule onto the event.
|
||||
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||
t.Error("the recurring task's event has no RRULE")
|
||||
}
|
||||
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||
if !strings.Contains(out, "UID:[email protected]") {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Sharing a note with a person, rather than only by filing it in a shared project.
|
||||
--
|
||||
-- Until now a note was shared exactly one way: put it in a project, and everyone
|
||||
-- who could read the project could read the note. That is the right tool when the
|
||||
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
|
||||
-- see this" — it makes you stand up a project to hand over a single page.
|
||||
--
|
||||
-- A note_shares row is that direct grant: this note, this person, this role. It
|
||||
-- sits alongside the project path, not instead of it — a note can be loose, filed,
|
||||
-- shared with people, or any combination, and access is the most that any of those
|
||||
-- allow. Only the note's owner (its created_by) writes these rows.
|
||||
--
|
||||
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
|
||||
-- is one idea of what a role means in this program and not two.
|
||||
CREATE TABLE note_shares (
|
||||
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
||||
-- One share per person per note: sharing again changes the role, it does not
|
||||
-- add a second row that the first would then quietly disagree with.
|
||||
PRIMARY KEY (note_id, user_id)
|
||||
);
|
||||
|
||||
-- "Which notes are shared with me" is asked on every visit to the notes page, to
|
||||
-- build the Delt med mig group and to widen what the list may show. It is a lookup
|
||||
-- by user_id, so that is what carries the index.
|
||||
CREATE INDEX note_shares_by_user ON note_shares (user_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
|
||||
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
|
||||
-- then comes back on its own — no job wakes it, because "snoozed" is simply
|
||||
-- snoozed_until being still in the future, read against the clock each time.
|
||||
--
|
||||
-- Null means not snoozed, which is every task until somebody snoozes one.
|
||||
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||
// may do. The person is carried whole rather than by id, because every caller of
|
||||
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||
type NoteShare struct {
|
||||
User Person `json:"user"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||
// has already checked that they may.
|
||||
//
|
||||
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||
// confuse the access check.
|
||||
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||
if !role.Valid() || role == RoleOwner {
|
||||
return errors.New("store: a note can be shared as viewer or editor")
|
||||
}
|
||||
var createdBy sql.NullString
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if createdBy.String == userID {
|
||||
return errors.New("store: a note is already the owner's")
|
||||
}
|
||||
_, err := db.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, userID, role, byUserID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||
// error: the end state the caller asked for — this person cannot see this note —
|
||||
// is the same either way.
|
||||
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||
// name so the list under a note does not reshuffle every time it is opened.
|
||||
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.name, u.avatar_color, s.role
|
||||
FROM note_shares s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.note_id = ?
|
||||
ORDER BY lower(u.name)`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []NoteShare
|
||||
for rows.Next() {
|
||||
var s NoteShare
|
||||
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||
// role could not be read — so a caller does not have to treat "no share" as an
|
||||
// error.
|
||||
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||
var role Role
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !role.Valid() {
|
||||
// A role the database holds but this build does not understand is no access,
|
||||
// never a guess — the same rule project roles follow.
|
||||
return "", false, nil
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
|
||||
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||
// the user. It answers the Delt med mig question in one query rather than one per
|
||||
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||
// world to mark a single page.
|
||||
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
if len(noteIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
args := make([]any, 0, len(noteIDs)+1)
|
||||
args = append(args, userID)
|
||||
for _, id := range noteIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -37,6 +37,17 @@ type Note struct {
|
||||
// set by a caller: it is derived, and a caller that set it would be describing
|
||||
// a note whose text says something else.
|
||||
Links []NoteLink `json:"links,omitempty"`
|
||||
|
||||
// SharedWithMe is true when the person reading the list reached this note
|
||||
// through a direct share rather than by owning it or being in its project. It
|
||||
// is what puts the note in the Delt med mig group and shows whose note it is.
|
||||
// Derived per request, so it is never stored and never set by a caller.
|
||||
SharedWithMe bool `json:"shared_with_me,omitempty"`
|
||||
|
||||
// Owner is who the note belongs to — its created_by — as a name and a colour,
|
||||
// for the chip on a note shared with the reader. Set only when it helps the
|
||||
// list say something; nil the rest of the time.
|
||||
Owner *Person `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// NoteLink is one reference from a note to something else.
|
||||
@@ -263,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
|
||||
UNION
|
||||
SELECT project_id FROM project_members WHERE user_id = ?
|
||||
)
|
||||
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||
)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
|
||||
}
|
||||
|
||||
// NotesLinking answers the backwards question: what points at this thing.
|
||||
|
||||
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
||||
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||
// silently unsubscribing somebody's phone.
|
||||
// MarkPushDelivered records that a push went through: the failure count goes back
|
||||
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
||||
// few times and then recovered would keep those failures on its record and be one
|
||||
// flaky night away from the ten that delete it — even though it is working.
|
||||
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
||||
time.Now().Unix(), endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||
if permanent {
|
||||
// Not scoped by user, and that is right here: this is the push service
|
||||
|
||||
@@ -29,6 +29,9 @@ type Task struct {
|
||||
AssigneeID string
|
||||
CompletedAt *time.Time
|
||||
CompletedBy string
|
||||
// SnoozedUntil, when set and still in the future, parks the task at the bottom
|
||||
// of its list, greyed, until it passes. Nil means the task is not snoozed.
|
||||
SnoozedUntil *time.Time
|
||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||
CreatedBy string
|
||||
@@ -219,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
@@ -313,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
|
||||
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
return "t.completed_at DESC, t.id DESC"
|
||||
}
|
||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
// A snoozed task sinks below everything else — the CASE is 1 while it is still
|
||||
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
|
||||
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
|
||||
// strftime returns would sort under every number.
|
||||
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
|
||||
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
}
|
||||
|
||||
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
|
||||
// snooze changes; the due date, the time, everything else is left alone — a snooze
|
||||
// is about when you want to see it, not when it is due.
|
||||
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
|
||||
var v any
|
||||
if until != nil {
|
||||
v = until.Unix()
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
v, time.Now().Unix(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (Task, error) {
|
||||
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
// stays; it loses its author.
|
||||
var createdBy sql.NullString
|
||||
var dueDatetime, completedAt sql.NullInt64
|
||||
var duration sql.NullInt64
|
||||
var duration, snoozedUntil sql.NullInt64
|
||||
var created, updated int64
|
||||
|
||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||
&recurrence, &assignee, &completedAt, &completedBy,
|
||||
&createdBy, &t.SortOrder, &created, &updated,
|
||||
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||
if err != nil {
|
||||
return t, err
|
||||
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
v := int(duration.Int64)
|
||||
t.DurationMin = &v
|
||||
}
|
||||
if snoozedUntil.Valid {
|
||||
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||
t.SnoozedUntil = &v
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return t, nil
|
||||
|
||||
@@ -205,9 +205,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
|
||||
// Person is the public half of a user: what somebody else is allowed to see when
|
||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||
type Person struct {
|
||||
ID string
|
||||
Name string
|
||||
AvatarColor string
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
|
||||
// that somebody a note is about to be shared with actually exists, without loading
|
||||
// the whole account.
|
||||
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
|
||||
var p Person
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Person{}, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// UsersForSharing is everyone on the instance except the caller — the people a note
|
||||
// can be handed to.
|
||||
//
|
||||
// Deliberately wider than ListPeople, which is the project collaborators. The whole
|
||||
// point of sharing a note directly is to do it without first standing up a shared
|
||||
// project, so the picker cannot be limited to people you already share a project
|
||||
// with. An instance's accounts are a closed, invited set to begin with — there is
|
||||
// no open signup — so listing them to each other is not disclosure, it is the
|
||||
// address book the feature needs.
|
||||
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
people := []Person{}
|
||||
for rows.Next() {
|
||||
var p Person
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people = append(people, p)
|
||||
}
|
||||
return people, rows.Err()
|
||||
}
|
||||
|
||||
// PeopleByIDs looks up several users at once.
|
||||
|
||||
@@ -926,6 +926,26 @@
|
||||
</figure>
|
||||
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/calendar-light.webp"
|
||||
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/notes-shared-light.webp"
|
||||
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/board.webp"
|
||||
@@ -1063,6 +1083,23 @@
|
||||
written about it. Archive what is done with it; the bin is for mistakes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Share a note, or a page</h3>
|
||||
<p>
|
||||
Hand a single note to a person — to read or to edit — without standing up
|
||||
a project for it. Notes other people share with you gather under
|
||||
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||
instead and everyone who can read the project reads the note.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>How long it takes</h3>
|
||||
<p>
|
||||
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||
foot of a task to make it longer or shorter, or drag its body to another
|
||||
day or time — the length comes along.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Waiting on others</h3>
|
||||
<p>
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
@@ -127,6 +127,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('Alfa i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
// Snooze it to tomorrow from its drawer.
|
||||
await page.getByText('Alfa', { exact: true }).click();
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByRole('button', { name: 'I morgen' }).click();
|
||||
|
||||
// The row greys — it carries the snoozed class and a "slumret til" mark.
|
||||
const alfa = page.locator('.row', { hasText: 'Alfa' });
|
||||
await expect(alfa).toHaveClass(/snoozed/);
|
||||
await expect(alfa.locator('.snooze-mark')).toBeVisible();
|
||||
|
||||
// The drawer now offers to wake it, and waking takes the grey off.
|
||||
await drawer.getByRole('button', { name: 'Væk nu' }).click();
|
||||
await expect(alfa).not.toHaveClass(/snoozed/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -1225,6 +1251,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
||||
* thing this view was before anything was laid over it — and a Google account that
|
||||
* is not there must not take it down with it.
|
||||
*/
|
||||
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// A timed task today at ten, half an hour long. Today, so the week opens on it
|
||||
// without paging, and ten is inside the hours the grid draws by default.
|
||||
const today = await page.evaluate(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
const id = await page.evaluate(async (day) => {
|
||||
const r = await fetch('/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
content: 'Møde med Anders',
|
||||
due_date: day,
|
||||
due_time: '10:00',
|
||||
duration_min: 30
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}, today);
|
||||
|
||||
await page.goto('/kalender');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
const chip = page
|
||||
.locator(`[data-date="${today}"] .tevent.task`)
|
||||
.filter({ hasText: 'Møde med Anders' });
|
||||
await expect(chip).toBeVisible();
|
||||
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
|
||||
|
||||
// Drag the foot of the task down a good stretch. It should grow, both on screen
|
||||
// and in what the server stores — a resize is a change of length, not of time.
|
||||
const box = await chip.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async (tid) => {
|
||||
const r = await fetch('/api/v1/tasks/' + tid, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).duration_min;
|
||||
}, id)
|
||||
)
|
||||
.toBeGreaterThan(30);
|
||||
|
||||
// And the chip is taller than it was, so the length is something you can see.
|
||||
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
|
||||
|
||||
// The time did not move: it is still a ten o'clock task.
|
||||
await expect(chip.locator('.at')).toHaveText('10:00');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/kalender');
|
||||
@@ -2440,8 +2529,9 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
await page.keyboard.type('Fælles aftale om leveringen');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Shared by being filed in a project. Not an access list of its own.
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
|
||||
// Filing it in a project — the controls live in the Del popover in the footer.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||
|
||||
// And it is really there, not merely labelled: the project's own page shows it.
|
||||
@@ -2451,7 +2541,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
// Taken back out, it is the author's alone again.
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
||||
await expect(page.getByText('din igen')).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||
@@ -2460,6 +2551,68 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Somebody to share with: a second account on the instance, made the ordinary
|
||||
// way. The candidate list is drawn when the note opens, so they must exist
|
||||
// before it does.
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill('[email protected]');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
|
||||
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const sofie = await sofieCtx.newPage();
|
||||
await sofie.goto(link);
|
||||
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
|
||||
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await sofie.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// The owner writes a note and hands it to Sofie, as a reader.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Ferieplan for hele holdet');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Sharing lives in a popover behind the Del button in the footer.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
const share = page.locator('.sharepanel');
|
||||
await expect(share).toBeVisible();
|
||||
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||
|
||||
// She is on the note now, with the role she was given.
|
||||
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('select')).toHaveValue('viewer');
|
||||
|
||||
// And it reaches her: her own notes list grows a Delt med mig group with it in.
|
||||
//
|
||||
// Navigated on the origin the invite signed her in on. VERDANDE_BASE_URL is
|
||||
// localhost while the suite drives 127.0.0.1, and a session cookie set on one
|
||||
// host is not sent to the other — a bare "/noter" would land her logged out.
|
||||
const noterURL = new URL('/noter', link).href;
|
||||
await sofie.goto(noterURL);
|
||||
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
|
||||
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
|
||||
// A viewer's note carries no star or archive control — it is not hers to put away.
|
||||
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
|
||||
await expect(sofieRow.locator('.star')).toHaveCount(0);
|
||||
|
||||
// Taken back, it leaves her list.
|
||||
await row.getByRole('button', { name: 'Fjern adgang' }).click();
|
||||
await expect(row).toBeHidden();
|
||||
await sofie.goto(noterURL);
|
||||
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
|
||||
|
||||
await sofieCtx.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
@@ -2497,6 +2650,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// One note to point at, then another to write the link in.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Rejseplan til Berlin');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Mine planer');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Se ');
|
||||
await page.keyboard.type('[[Rejse');
|
||||
|
||||
// The picker offers the note by title, and choosing it turns the text into a
|
||||
// real inline link — the title, not the brackets.
|
||||
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
|
||||
await expect(inline).toBeVisible();
|
||||
|
||||
// It survives the round-trip through Markdown and a reload.
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(900);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||
|
||||
// And it is a link you can follow: clicking it opens the note it names.
|
||||
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
|
||||
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
@@ -2920,10 +3114,11 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
||||
await page.keyboard.type('Tagsten og lægter');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
|
||||
// nogen har valgt det bagefter.
|
||||
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
||||
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
|
||||
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||
await page.locator('.sharewrap > button').click();
|
||||
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||
expect(shared).toContain('Tagprojekt');
|
||||
|
||||
// Og den står på projektets egen side.
|
||||
@@ -2989,6 +3184,96 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Kvartalsplan');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// The Arkivér button in the note's footer puts it away: it leaves the list.
|
||||
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||
|
||||
// It is in the archive, where the same button now offers to bring it back.
|
||||
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
|
||||
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
|
||||
|
||||
// And back in the ordinary list.
|
||||
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Titel');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
|
||||
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
|
||||
// med at være kode.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('kode linje');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('almindelig tekst');
|
||||
|
||||
// Koden står i sin blok; teksten efter står uden for den.
|
||||
await expect(ed.locator('pre')).toHaveText('kode linje');
|
||||
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
|
||||
|
||||
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('linje et');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('linje to');
|
||||
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
|
||||
|
||||
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
|
||||
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
|
||||
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
|
||||
|
||||
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Rundtur');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('docker pull');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('og så videre i almindelig skrift');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(1200);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
|
||||
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
@@ -3062,7 +3347,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
// The selection bar's Arkivér, not the open note's own in the footer.
|
||||
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||
|
||||
@@ -73,6 +73,11 @@ const SHOTS = [
|
||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||
// The new work, and in the light theme, which is what most people want to look
|
||||
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||
// somebody shared into the "Shared with me" group.
|
||||
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
|
||||
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
// Scrolled, because the themes are the second panel on the page and a picture
|
||||
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function openSharedNote(page) {
|
||||
// The note Mette shared in, so the "Shared with me" group and its owner chip are
|
||||
// what the picture is of rather than something the group happens to sit above.
|
||||
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showThemes(page) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||
@@ -340,6 +352,23 @@ async function handOver(ids) {
|
||||
await api(`/tasks/${ids.detail}/comments`, {
|
||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||
});
|
||||
|
||||
// A note of Mette's own, handed to the owner — so their Notes page has a
|
||||
// "Shared with me" group with a real note in it, marked as hers. This is the
|
||||
// other half of sharing: not the project path, but a page passed to a person.
|
||||
const note = await api('/notes', {
|
||||
body: `# Packaging quotes
|
||||
|
||||
Three back, one still to come:
|
||||
|
||||
- **Nordfelt** — 79 øre a bag at 2,000, plates included
|
||||
- **Kolding Karton** — 84 øre, faster, kinder about small runs
|
||||
- Bruun still owes me a number
|
||||
|
||||
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
|
||||
});
|
||||
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
|
||||
|
||||
return { colleague: me.id };
|
||||
}
|
||||
|
||||
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
const home = await api('/project-groups', { name: 'Home' });
|
||||
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
|
||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||
|
||||
// A handful given a time and a length, so the calendar's week draws them to
|
||||
// scale rather than as a row of identical blocks — a stand-up, a long
|
||||
// planning block, a call, a review. Spread across the day and the days, so
|
||||
// the picture is a week that has a shape.
|
||||
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
|
||||
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
|
||||
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
|
||||
|
||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||
@@ -560,6 +602,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
||||
await api('/users', { email: '[email protected]' });
|
||||
|
||||
return {
|
||||
owner: me.id,
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
|
||||
@@ -310,6 +310,7 @@ export const api = {
|
||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
@@ -405,6 +406,12 @@ export const api = {
|
||||
deleteNote: (id) => del(`/notes/${id}`),
|
||||
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||
|
||||
// Sharing a note with people directly — its own access list, beside the project
|
||||
// path. Owner only; the list call also returns who it can still be shared with.
|
||||
noteShares: (id) => get(`/notes/${id}/shares`),
|
||||
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
||||
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
||||
|
||||
// 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.
|
||||
mailboxes: () => get('/mailboxes'),
|
||||
|
||||
@@ -286,6 +286,48 @@
|
||||
/** The cell lit up under the pointer. */
|
||||
let over = $state(null);
|
||||
|
||||
// A resize in progress: which task, and where its foot is being dragged to, in
|
||||
// minutes since midnight. Kept apart from the stored length so the task can
|
||||
// follow the handle live and only be written when the drag ends.
|
||||
let resizing = $state(null);
|
||||
|
||||
/**
|
||||
* Dragging the foot of a timed task changes how long it is.
|
||||
*
|
||||
* Pointer events rather than the HTML5 drag the body uses: the two must not be
|
||||
* the same gesture, or every attempt to make a task longer would instead pick it
|
||||
* up and move it. stopPropagation keeps the handle's press from starting the
|
||||
* move, and the column it lives in is what the minutes are measured against —
|
||||
* the same box onDropAt reads a drop time from.
|
||||
*/
|
||||
function startResize(event, task, date) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const col = event.currentTarget.closest('.daycol');
|
||||
if (!col || !task.due_datetime) return;
|
||||
const at = new Date(task.due_datetime);
|
||||
const from = at.getHours() * 60 + at.getMinutes();
|
||||
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
|
||||
|
||||
const onMove = (e) => {
|
||||
const box = col.getBoundingClientRect();
|
||||
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
|
||||
const minutes = hours.from * 60 + share * hours.minutes;
|
||||
// Quarter-hour steps, at least a quarter long, never past midnight.
|
||||
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
|
||||
resizing = { ...resizing, to: snapped };
|
||||
};
|
||||
const onUp = async () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
const current = resizing;
|
||||
resizing = null;
|
||||
if (current) await app.resize(current.id, current.to - current.from);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
function onDragOver(event, date) {
|
||||
if (!carries(event, TASK)) return;
|
||||
accept(event);
|
||||
@@ -394,7 +436,16 @@
|
||||
if (!task.due_datetime) continue;
|
||||
const at = new Date(task.due_datetime);
|
||||
const from = at.getHours() * 60 + at.getMinutes();
|
||||
out.push({ kind: 'task', ref: task, from, to: from + 30 });
|
||||
// The task's own length, not a fixed half hour. A quarter-hour floor so a
|
||||
// zero-minute task is still something to see and grab, and the end of the
|
||||
// day is the ceiling. While a resize is in flight this task follows the
|
||||
// handle instead of its stored length.
|
||||
let to =
|
||||
resizing && resizing.id === task.id
|
||||
? resizing.to
|
||||
: from + (task.duration_min || 30);
|
||||
to = Math.min(Math.max(to, from + 15), 24 * 60);
|
||||
out.push({ kind: 'task', ref: task, from, to });
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||
@@ -547,12 +598,13 @@
|
||||
</svelte:element>
|
||||
{/snippet}
|
||||
|
||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
||||
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
|
||||
<!-- Draggable as well as clickable: moving something to another day is the
|
||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||
of movement before it calls a press a drag, so the click survives. -->
|
||||
<button
|
||||
class={kind}
|
||||
class:resizing={resizing?.id === task.id}
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
@@ -562,6 +614,16 @@
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{/if}
|
||||
{task.content}
|
||||
<!-- Only a task standing at a time can be made longer, and only where the
|
||||
grid has room to show it — the timed column, which passes the date. -->
|
||||
{#if date && task.due_datetime}
|
||||
<span
|
||||
class="resize"
|
||||
title={t('cal.resize')}
|
||||
aria-hidden="true"
|
||||
onpointerdown={(e) => startResize(e, task, date)}
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -632,7 +694,7 @@
|
||||
{#if item.kind === 'event'}
|
||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||
{:else}
|
||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -937,6 +999,29 @@
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* The grip along the foot of a task, for dragging its length. Out of the way
|
||||
until the task is hovered or being resized, so it never competes with the
|
||||
words above it. */
|
||||
.tevent.task .resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 7px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
.tevent.task:hover .resize,
|
||||
.tevent.task.resizing .resize {
|
||||
opacity: 1;
|
||||
background: linear-gradient(to bottom, transparent, var(--line-strong));
|
||||
}
|
||||
.tevent.task.resizing {
|
||||
cursor: ns-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tevent.task[data-priority='1'] {
|
||||
border-left-color: var(--p1);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
@@ -120,11 +120,99 @@
|
||||
|
||||
function setStyle(style) {
|
||||
stylesOpen = false;
|
||||
editor?.focus();
|
||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||
|
||||
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
|
||||
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
|
||||
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||
// hand, replacing the element rather than reformatting inside it.
|
||||
const block = currentBlock();
|
||||
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
||||
replaceBlock(block, style.tag);
|
||||
return;
|
||||
}
|
||||
apply('formatBlock', style.tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a block element with one of another tag, keeping its contents and the
|
||||
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||
* <pre>, which it either nests or ignores.
|
||||
*/
|
||||
function replaceBlock(block, tag) {
|
||||
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||
// on the next render for any <pre> that still needs one.
|
||||
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
|
||||
|
||||
const el = document.createElement(tag);
|
||||
while (block.firstChild) el.appendChild(block.firstChild);
|
||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||
block.replaceWith(el);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps out of a code block on Enter.
|
||||
*
|
||||
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
|
||||
* staying inside it — which means a monospace last line makes every line after
|
||||
* it monospace too, with no way back to body by typing. Plain Enter now leaves
|
||||
* the block: the tail after the caret becomes a paragraph below the <pre>, and
|
||||
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
|
||||
* break still writes multi-line code inside the block.
|
||||
*/
|
||||
function exitPre(pre) {
|
||||
pre.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Everything from the caret to the end of the block leaves with the new line.
|
||||
const tail = document.createRange();
|
||||
tail.setStart(range.endContainer, range.endOffset);
|
||||
tail.setEnd(pre, pre.childNodes.length);
|
||||
const frag = tail.extractContents();
|
||||
|
||||
// A break at the head of what we carried is the empty line return was pressed
|
||||
// on; it should not open the paragraph with a blank row.
|
||||
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
|
||||
|
||||
const p = document.createElement('p');
|
||||
if (frag.childNodes.length) p.appendChild(frag);
|
||||
// The split can leave an empty text node behind, and a block with no element
|
||||
// and no text is not a caret host in Chromium — the next keystroke falls back
|
||||
// into the block above. An empty paragraph needs a <br> to be stood in.
|
||||
if (!p.textContent && !p.querySelector('br, img')) {
|
||||
p.replaceChildren(document.createElement('br'));
|
||||
}
|
||||
|
||||
// And the same break seen from the other side, left dangling in the <pre>.
|
||||
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
|
||||
|
||||
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
|
||||
if (!pre.textContent.trim()) pre.replaceWith(p);
|
||||
else pre.after(p);
|
||||
|
||||
const caret = document.createRange();
|
||||
caret.setStart(p, 0);
|
||||
caret.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(caret);
|
||||
}
|
||||
|
||||
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||
function readState() {
|
||||
if (!editor) return;
|
||||
@@ -302,9 +390,26 @@
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
|
||||
// A note link, [[, is the same idea as a project tag and gets the same
|
||||
// completion — checked first because it is the longer opener. The term runs
|
||||
// to the caret and is anything up to the closing bracket, which has not been
|
||||
// typed yet.
|
||||
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||
if (note) {
|
||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||
}
|
||||
|
||||
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (project) {
|
||||
return {
|
||||
kind: 'project',
|
||||
node,
|
||||
start: selection.anchorOffset - project[1].length,
|
||||
term: project[1]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,46 +453,87 @@
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
function projectMatches(term) {
|
||||
return app.projects
|
||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||
// current note is left out — a note that links to itself is a loop nobody meant
|
||||
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||
function noteMatches(term) {
|
||||
return notes
|
||||
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
function rank(a, b, term) {
|
||||
const al = a.toLowerCase();
|
||||
const bl = b.toLowerCase();
|
||||
const ap = al.startsWith(term);
|
||||
const bp = bl.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return al.localeCompare(bl, 'da');
|
||||
}
|
||||
|
||||
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||
|
||||
// A click on a note link follows it; a click anywhere else re-colours the code,
|
||||
// as it did before. The link is contenteditable="false", so the click is a click
|
||||
// and not a caret landing in the middle of a word.
|
||||
function onEditorClick(event) {
|
||||
const link = event.target.closest?.('.notelink');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||
return;
|
||||
}
|
||||
colourCode();
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole thing. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
if (item.kind === 'note') {
|
||||
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||
// become one when the note is next opened. A space after, as its own text,
|
||||
// so the caret leaves the atomic link and the next word is a word.
|
||||
range.setStart(partial.node, partial.start - 2);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
|
||||
);
|
||||
document.execCommand('insertText', false, ' ');
|
||||
} else {
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
// A trailing space, or the suggestions come straight back for the tag.
|
||||
document.execCommand('insertText', false, `${item.label} `);
|
||||
}
|
||||
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
@@ -470,6 +616,16 @@
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// A code block is the one place the browser keeps the caret inside on
|
||||
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||
// monospace last line traps everything typed after it in monospace.
|
||||
const block = currentBlock();
|
||||
if (block && block.tagName === 'PRE') {
|
||||
event.preventDefault();
|
||||
exitPre(block);
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
// After the split, not before it.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
@@ -678,7 +834,7 @@
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onclick={onEditorClick}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
@@ -698,7 +854,7 @@
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
{#each suggestions as item, i (item.kind + item.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
@@ -706,12 +862,17 @@
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
{#if item.kind === 'note'}
|
||||
<span class="dot note" aria-hidden="true">[[</span>
|
||||
{item.label}
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{item.label}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -864,6 +1025,18 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* A note suggestion wears the brackets that open it where a project wears its
|
||||
colour, so the two kinds are told apart at a glance in the same list. */
|
||||
.suggestions .dot.note {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: none;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -1023,6 +1196,18 @@
|
||||
margin: var(--s2) 0;
|
||||
}
|
||||
|
||||
/* A note link reads as a link: the accent colour, underlined on hover, and a
|
||||
pointer so it is plainly something to click rather than to edit. */
|
||||
.page :global(a.notelink) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.page :global(a.notelink:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
@@ -23,6 +23,49 @@
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let dueTime = $state('');
|
||||
// Length in minutes, as a string for the field. Only meaningful with a time —
|
||||
// see the field below — so it is seeded and shown alongside the clock.
|
||||
let duration = $state('');
|
||||
let snoozeCustom = $state('');
|
||||
|
||||
const snoozed = $derived(!!task?.snoozed_until && new Date(task.snoozed_until) > new Date());
|
||||
const snoozedText = $derived(
|
||||
snoozed
|
||||
? new Date(task.snoozed_until).toLocaleString(tag(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: ''
|
||||
);
|
||||
|
||||
async function snoozeTo(iso) {
|
||||
snoozeCustom = '';
|
||||
try {
|
||||
await app.snooze(task.id, iso);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Quick picks, in local time, as ISO for the API. This evening is six; the
|
||||
// morning ones are nine; next week is the coming Monday.
|
||||
const atLocal = (d, h) => {
|
||||
d.setHours(h, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
};
|
||||
const thisEvening = () => atLocal(new Date(), 18);
|
||||
const tomorrowMorning = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return atLocal(d, 9);
|
||||
};
|
||||
const nextWeek = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + (((8 - d.getDay()) % 7) || 7));
|
||||
return atLocal(d, 9);
|
||||
};
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
@@ -57,6 +100,7 @@
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||
duration = task.duration_min ? String(task.duration_min) : '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
@@ -436,10 +480,50 @@
|
||||
disabled={!dueDate}
|
||||
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||
/>
|
||||
<!-- Length in minutes, and only with a time to hang it on: a duration
|
||||
without a start is a length of nothing, and the calendar has
|
||||
nowhere to draw it. Empty clears it. -->
|
||||
<input
|
||||
type="number"
|
||||
class="dur"
|
||||
min="0"
|
||||
step="15"
|
||||
aria-label={t('detail.duration')}
|
||||
placeholder={t('detail.durationShort')}
|
||||
bind:value={duration}
|
||||
disabled={!dueTime}
|
||||
onchange={() =>
|
||||
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snooze: park it out of the way until a moment, without touching when it is
|
||||
due. While snoozed the row greys and sinks to the bottom of its list. -->
|
||||
<div class="field">
|
||||
<span class="field-label">{t('detail.snooze')}</span>
|
||||
{#if snoozed}
|
||||
<div class="snoozed-now">
|
||||
<span>{t('task.snoozedUntil', { when: snoozedText })}</span>
|
||||
<button class="snooze-btn" onclick={() => snoozeTo('')}>{t('detail.wake')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="snooze-row">
|
||||
<button class="snooze-btn" onclick={() => snoozeTo(thisEvening())}>{t('detail.thisEvening')}</button>
|
||||
<button class="snooze-btn" onclick={() => snoozeTo(tomorrowMorning())}>{t('detail.tomorrow')}</button>
|
||||
<button class="snooze-btn" onclick={() => snoozeTo(nextWeek())}>{t('detail.nextWeek')}</button>
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="snooze-custom"
|
||||
aria-label={t('detail.snoozeCustom')}
|
||||
bind:value={snoozeCustom}
|
||||
onchange={() => snoozeCustom && snoozeTo(new Date(snoozeCustom).toISOString())}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="labels">{t('detail.labels')}</label>
|
||||
<input
|
||||
@@ -731,6 +815,11 @@
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.due input.dur {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.due input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -741,13 +830,47 @@
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
label {
|
||||
label,
|
||||
.field-label {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.snooze-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1);
|
||||
align-items: center;
|
||||
}
|
||||
.snooze-btn {
|
||||
width: auto;
|
||||
padding: var(--s1) var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.snooze-btn:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.snooze-custom {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 9rem;
|
||||
}
|
||||
.snoozed-now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
@@ -992,5 +1115,21 @@
|
||||
width: auto;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
/* Two columns is half a phone each: Prioritet collapses to "Ing" and the
|
||||
due-row (date + fixed-width time + duration) runs off the right edge. One
|
||||
column per field gives each its full width; the due-row wraps if even that
|
||||
is not enough. */
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.due {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.due input[type='date'] {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -120,7 +120,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
|
||||
// A snoozed task sinks below the rest, then rises back when its time passes —
|
||||
// the same order the server sorts a list in, kept here so a drag-sorted list
|
||||
// agrees rather than jumping when it reloads.
|
||||
const snoozedNow = (t) => t.snoozed_until && new Date(t.snoozed_until) > new Date();
|
||||
let ordered = $derived(
|
||||
[...tasks].sort((a, b) => {
|
||||
const sa = snoozedNow(a) ? 1 : 0;
|
||||
const sb = snoozedNow(b) ? 1 : 0;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.sort_order - b.sort_order;
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#each ordered as task (task.id)}
|
||||
|
||||
@@ -63,9 +63,30 @@
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
// Snoozed while its time is still ahead. Greys the row and adds a "slumret til"
|
||||
// mark, so a parked task reads as set-aside rather than forgotten.
|
||||
let snoozed = $derived(task.snoozed_until && new Date(task.snoozed_until) > new Date());
|
||||
let snoozedLabel = $derived.by(() => {
|
||||
if (!snoozed) return null;
|
||||
const at = new Date(task.snoozed_until);
|
||||
const midnight = new Date();
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const days = Math.round((new Date(at.toDateString()) - midnight) / 86400000);
|
||||
const clock = at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
|
||||
if (days <= 0) return clock;
|
||||
if (days === 1) return t('task.tomorrow') + ' ' + clock;
|
||||
return at.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }) + ' ' + clock;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="row" class:leaving class:completed={task.completed} data-priority={task.priority}>
|
||||
<div
|
||||
class="row"
|
||||
class:leaving
|
||||
class:completed={task.completed}
|
||||
class:snoozed
|
||||
data-priority={task.priority}
|
||||
>
|
||||
<button
|
||||
class="check"
|
||||
class:checked={task.completed || leaving}
|
||||
@@ -100,6 +121,14 @@
|
||||
{assignee.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if snoozed}
|
||||
<span class="snooze-mark" title={t('task.snoozedUntil', { when: snoozedLabel })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h8l-8 8h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{snoozedLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if due}
|
||||
<span class="due" data-tone={due.tone}>
|
||||
{due.text}{#if time} {time}{/if}
|
||||
@@ -183,6 +212,26 @@
|
||||
text-decoration-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* Parked, not gone: dimmed so it reads as set aside, but still there and still
|
||||
readable. The checkbox and the snooze mark keep their weight, so it can be
|
||||
woken or ticked off without peering at it. */
|
||||
.row.snoozed .content,
|
||||
.row.snoozed .description,
|
||||
.row.snoozed .meta {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.snooze-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--ink-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.snooze-mark svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
|
||||
@@ -16,10 +16,26 @@
|
||||
*/
|
||||
export const da = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Del i',
|
||||
'notes.private': 'Kun mig',
|
||||
'notes.shareWith': 'Læg i projekt',
|
||||
'notes.private': 'Intet projekt',
|
||||
'notes.shared': 'Noten er delt med projektets folk.',
|
||||
'notes.unshared': 'Noten er din igen.',
|
||||
// Deling med enkeltpersoner, ved siden af projektet.
|
||||
'notes.groupSharedWithMe': 'Delt med mig',
|
||||
'notes.linkOpen': 'Åbn',
|
||||
'notes.linkMissing': 'Noten blev ikke fundet.',
|
||||
'notes.sharedBy': 'Delt af {name}',
|
||||
'notes.sharePeople': 'Del med personer',
|
||||
'notes.sharePick': 'Vælg en person …',
|
||||
'notes.shareButton': 'Del',
|
||||
'notes.shareAdd': 'Tilføj',
|
||||
'notes.shareRoleViewer': 'Kan læse',
|
||||
'notes.shareRoleEditor': 'Kan rette',
|
||||
'notes.shareRemove': 'Fjern adgang',
|
||||
'notes.shareNobody': 'Ikke delt med nogen endnu.',
|
||||
'notes.sharedToast': 'Delt med {name}.',
|
||||
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||
'notes.save': 'Gem',
|
||||
'notes.format': 'Formatér',
|
||||
'notes.bold': 'Fed',
|
||||
@@ -583,6 +599,15 @@ export const da = {
|
||||
'detail.none': 'Ingen',
|
||||
'detail.due': 'Forfalder',
|
||||
'detail.dueTime': 'Klokkeslæt',
|
||||
'detail.duration': 'Længde i minutter',
|
||||
'detail.snooze': 'Slumre',
|
||||
'detail.wake': 'Væk nu',
|
||||
'detail.thisEvening': 'I aften',
|
||||
'detail.tomorrow': 'I morgen',
|
||||
'detail.nextWeek': 'Næste uge',
|
||||
'detail.snoozeCustom': 'Vælg tidspunkt',
|
||||
'task.snoozedUntil': 'Slumret til {when}',
|
||||
'detail.durationShort': 'min',
|
||||
'detail.labels': 'Etiketter',
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
@@ -735,6 +760,7 @@ export const da = {
|
||||
'cal.settings': 'Indstillinger',
|
||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||
'cal.allDay': 'Hele dagen',
|
||||
'cal.resize': 'Træk for at ændre længden',
|
||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||
'cal.beyondWindow':
|
||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||
|
||||
@@ -12,10 +12,26 @@
|
||||
*/
|
||||
export const en = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Share in',
|
||||
'notes.private': 'Only me',
|
||||
'notes.shareWith': 'File in project',
|
||||
'notes.private': 'No project',
|
||||
'notes.shared': "The note is shared with the project's people.",
|
||||
'notes.unshared': 'The note is yours again.',
|
||||
// Sharing with individual people, alongside the project.
|
||||
'notes.groupSharedWithMe': 'Shared with me',
|
||||
'notes.linkOpen': 'Open',
|
||||
'notes.linkMissing': 'That note could not be found.',
|
||||
'notes.sharedBy': 'Shared by {name}',
|
||||
'notes.sharePeople': 'Share with people',
|
||||
'notes.sharePick': 'Choose someone …',
|
||||
'notes.shareButton': 'Share',
|
||||
'notes.shareAdd': 'Add',
|
||||
'notes.shareRoleViewer': 'Can view',
|
||||
'notes.shareRoleEditor': 'Can edit',
|
||||
'notes.shareRemove': 'Remove access',
|
||||
'notes.shareNobody': 'Not shared with anyone yet.',
|
||||
'notes.sharedToast': 'Shared with {name}.',
|
||||
'notes.unsharedPerson': '{name} can no longer see the note.',
|
||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||
'notes.save': 'Save',
|
||||
'notes.format': 'Format',
|
||||
'notes.bold': 'Bold',
|
||||
@@ -579,6 +595,15 @@ export const en = {
|
||||
'detail.none': 'None',
|
||||
'detail.due': 'Due',
|
||||
'detail.dueTime': 'Time',
|
||||
'detail.duration': 'Length in minutes',
|
||||
'detail.snooze': 'Snooze',
|
||||
'detail.wake': 'Wake now',
|
||||
'detail.thisEvening': 'This evening',
|
||||
'detail.tomorrow': 'Tomorrow',
|
||||
'detail.nextWeek': 'Next week',
|
||||
'detail.snoozeCustom': 'Pick a time',
|
||||
'task.snoozedUntil': 'Snoozed until {when}',
|
||||
'detail.durationShort': 'min',
|
||||
'detail.labels': 'Labels',
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
@@ -728,6 +753,7 @@ export const en = {
|
||||
'cal.settings': 'Settings',
|
||||
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
||||
'cal.allDay': 'All day',
|
||||
'cal.resize': 'Drag to change the length',
|
||||
'cal.openInGoogle': 'Open in Google Calendar',
|
||||
'cal.beyondWindow':
|
||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||
|
||||
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''')
|
||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||
*/
|
||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
||||
// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
|
||||
// links on one line stay two.
|
||||
const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
|
||||
const MARK = "\u0000";
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
// Lifted out before anything escapes or marks the text, and dropped back in
|
||||
// whole at the end — the same placeholder trick the image already used, now for
|
||||
// two kinds of thing the surrounding markup must not touch.
|
||||
const pieces = [];
|
||||
const lift = (html) => {
|
||||
pieces.push(html);
|
||||
return `${MARK}${pieces.length - 1}${MARK}`;
|
||||
};
|
||||
|
||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
||||
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
||||
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||
const images = [];
|
||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
||||
return `${MARK}${images.length - 1}${MARK}`;
|
||||
});
|
||||
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
|
||||
|
||||
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
|
||||
// atomic token — contenteditable="false" — so it moves and deletes as one thing,
|
||||
// and it carries the title it points at in data-note; the editor reads that on a
|
||||
// click. htmlToMarkdown turns it back into [[title]] exactly.
|
||||
lifted = lifted.replace(
|
||||
NOTELINK,
|
||||
(_, title) =>
|
||||
lift(
|
||||
`<a class="notelink" data-note="${attr(title)}" contenteditable="false">${escapeHtml(title)}</a>`
|
||||
)
|
||||
);
|
||||
|
||||
const marked = escapeHtml(lifted)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
|
||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +254,13 @@ function inlineToMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
// A note link goes back to the brackets it came from, by the title it carries
|
||||
// rather than by the text it shows — the two are the same today, but the title
|
||||
// is the thing that must survive the round trip.
|
||||
if (node.tagName === 'A' && node.classList?.contains('notelink')) {
|
||||
return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
|
||||
}
|
||||
|
||||
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||
switch (node.tagName) {
|
||||
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
||||
|
||||
@@ -403,6 +403,32 @@ class AppState {
|
||||
await this.update(id, { due_date: date, due_time: clock });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets how long a task is, in minutes. Nothing else about it moves — the date
|
||||
* and time are left alone, so making a task longer on the calendar does not
|
||||
* also reschedule it.
|
||||
*/
|
||||
/**
|
||||
* Snoozes a task until an ISO time, or wakes it when `until` is empty. Nothing
|
||||
* else about the task moves — a snooze is about when you want to see it, not when
|
||||
* it is due. Optimistic, so it greys and sinks at once.
|
||||
*/
|
||||
async snooze(id, until) {
|
||||
await this.#optimistic(
|
||||
id,
|
||||
{ snoozed_until: until || undefined },
|
||||
() => api.snoozeTask(id, until || '')
|
||||
);
|
||||
}
|
||||
|
||||
async resize(id, minutes) {
|
||||
const previous = this.get(id);
|
||||
if (!previous) return;
|
||||
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
|
||||
if (previous.duration_min === rounded) return;
|
||||
await this.update(id, { duration_min: rounded });
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const previous = [...this.tasks];
|
||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
@@ -146,14 +147,28 @@
|
||||
if (query.trim()) return [{ key: 'fundet', label: '', notes: ordered, plain: true }];
|
||||
|
||||
const out = [];
|
||||
const favourites = ordered.filter((n) => n.pinned);
|
||||
|
||||
// Andres noter først, samlet. Som Favoritter-gruppen vises den kun, når der
|
||||
// er noget i den — har man ingen delte noter, findes overskriften ikke og
|
||||
// tager ingen plads. En delt note bor kun her, ikke også nede i en tidsbunke.
|
||||
const sharedWithMe = ordered.filter((n) => n.shared_with_me);
|
||||
if (sharedWithMe.length) {
|
||||
out.push({
|
||||
key: 'delt-med-mig',
|
||||
label: t('notes.groupSharedWithMe'),
|
||||
notes: sharedWithMe,
|
||||
people: true
|
||||
});
|
||||
}
|
||||
|
||||
const favourites = ordered.filter((n) => n.pinned && !n.shared_with_me);
|
||||
if (favourites.length) {
|
||||
out.push({ key: 'favoritter', label: t('notes.groupFavourites'), notes: favourites, star: true });
|
||||
}
|
||||
|
||||
let current = null;
|
||||
for (const note of ordered) {
|
||||
if (note.pinned) continue;
|
||||
if (note.pinned || note.shared_with_me) continue;
|
||||
const bucket = bucketOf(note);
|
||||
if (!current || current.key !== bucket.key) {
|
||||
current = { key: bucket.key, label: bucket.label, notes: [] };
|
||||
@@ -170,6 +185,97 @@
|
||||
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||
let selectedId = $state(null);
|
||||
|
||||
// Deling af den åbne note med enkeltpersoner. Kun ejeren ser panelet, så det
|
||||
// hentes kun for en note, man selv har lavet.
|
||||
let shares = $state([]);
|
||||
let shareCandidates = $state([]);
|
||||
let sharePick = $state('');
|
||||
let shareRole = $state('viewer');
|
||||
let shareBusy = $state(false);
|
||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||
|
||||
// The whole of sharing — a project and named people — lives in a popover behind
|
||||
// one button, so the note's footer stays a status and three buttons rather than
|
||||
// a row that runs off its own width.
|
||||
let shareOpen = $state(false);
|
||||
let shareWrap = $state(null);
|
||||
// Closing on a switch of note, and only then: the panel belongs to the note it
|
||||
// was opened on. Tracked by id rather than by the object, so a save that returns
|
||||
// a fresh copy of the same note does not slam the panel shut mid-use.
|
||||
let sharePanelFor = $state(null);
|
||||
$effect(() => {
|
||||
const id = selected?.id ?? null;
|
||||
if (id !== sharePanelFor) {
|
||||
shareOpen = false;
|
||||
sharePanelFor = id;
|
||||
}
|
||||
});
|
||||
function onShareOutside(event) {
|
||||
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const id = selected?.id;
|
||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
||||
// under den nye.
|
||||
shares = [];
|
||||
shareCandidates = [];
|
||||
sharePick = '';
|
||||
if (!id || selected.created_by !== app.user?.id) return;
|
||||
let alive = true;
|
||||
api
|
||||
.noteShares(id)
|
||||
.then((r) => {
|
||||
if (!alive || selected?.id !== id) return;
|
||||
shares = r.shares ?? [];
|
||||
shareCandidates = r.candidates ?? [];
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
const byName = (a, b) => (a.name ?? '').localeCompare(b.name ?? '');
|
||||
|
||||
async function addShare() {
|
||||
if (!selected || !sharePick || shareBusy) return;
|
||||
const person = shareCandidates.find((p) => p.id === sharePick);
|
||||
shareBusy = true;
|
||||
try {
|
||||
await api.shareNote(selected.id, sharePick, shareRole);
|
||||
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
|
||||
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
|
||||
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
|
||||
sharePick = '';
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
shareBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeShareRole(userId, role) {
|
||||
try {
|
||||
await api.shareNote(selected.id, userId, role);
|
||||
shares = shares.map((s) => (s.user.id === userId ? { ...s, role } : s));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShare(userId) {
|
||||
const person = shares.find((s) => s.user.id === userId)?.user;
|
||||
try {
|
||||
await api.unshareNote(selected.id, userId);
|
||||
shares = shares.filter((s) => s.user.id !== userId);
|
||||
if (person) shareCandidates = [...shareCandidates, person].sort(byName);
|
||||
app.toast(t('notes.unsharedPerson', { name: person?.name ?? '' }));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flere ad gangen.
|
||||
*
|
||||
@@ -548,11 +654,56 @@
|
||||
// What the note points at, for the panel under the editor.
|
||||
let links = $derived(selected?.links ?? []);
|
||||
|
||||
/**
|
||||
* Follows a link from the panel to the thing it names.
|
||||
*
|
||||
* A [[note]] resolves by its title to a note — the one on the list if it is
|
||||
* there, otherwise looked up — and opens it in place. A #project and a task go
|
||||
* to their own pages. Until now these were text and nothing else: the note said
|
||||
* it pointed somewhere and there was no way to go.
|
||||
*/
|
||||
async function openLink(link) {
|
||||
if (link.kind === 'note') {
|
||||
const wanted = link.target_id.toLowerCase();
|
||||
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
||||
if (here) {
|
||||
open(here);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
||||
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
|
||||
if (hit) open(hit);
|
||||
else app.toast(t('notes.linkMissing'));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (link.kind === 'project') {
|
||||
const project = app.projects.find(
|
||||
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||
);
|
||||
if (project) goto(`/projekt/${project.id}`);
|
||||
return;
|
||||
}
|
||||
if (link.kind === 'task') {
|
||||
goto(`/opgave/${link.target_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onclick={onShareOutside}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && shareOpen) shareOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="notes">
|
||||
<aside>
|
||||
<div class="head">
|
||||
@@ -650,6 +801,14 @@
|
||||
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
||||
{#if group.star}
|
||||
<span class="mark favmark" aria-hidden="true">★</span>
|
||||
{:else if group.people}
|
||||
<span class="mark peoplemark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="9" cy="8" r="3.2" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M16 6.2a3 3 0 0 1 0 5.6M17.5 19c0-2.2-1-3.9-2.6-4.7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
||||
{/if}
|
||||
@@ -692,6 +851,19 @@
|
||||
{projectName(note.project_id)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if note.shared_with_me && note.owner}
|
||||
<!-- Hvis note delt med mig: hvem den kom fra, i personens egen
|
||||
avatar-farve. Samme form som projekt-mærkaten, så listen har
|
||||
ét sprog for "det her hører til nogen". -->
|
||||
<span
|
||||
class="filed person"
|
||||
style="--project: {note.owner.avatar_color}"
|
||||
title={t('notes.sharedBy', { name: note.owner.name })}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{note.owner.name}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="preview">{preview(note)}</span>
|
||||
</button>
|
||||
@@ -699,6 +871,10 @@
|
||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||
der er tre hundrede bred — og de bruges sjældent, mens
|
||||
titlen og uddraget læses hele tiden. -->
|
||||
<!-- Ingen knapper på en note, en anden ejer: at arkivere eller
|
||||
stjernemarkere ændrer selve noten, og den er ikke min at lægge
|
||||
væk eller pinne. Rækken læses og åbnes; resten er ejerens. -->
|
||||
{#if !note.shared_with_me}
|
||||
<div class="rowactions">
|
||||
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||
til flere; det her er til den ene, man står med — og i
|
||||
@@ -724,6 +900,7 @@
|
||||
{note.pinned ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -738,6 +915,8 @@
|
||||
{#if selected}
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
notes={notes}
|
||||
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||
onchange={(body) => {
|
||||
draft = body;
|
||||
typed();
|
||||
@@ -750,29 +929,121 @@
|
||||
{#if links.length}
|
||||
<span class="links">
|
||||
{#each links as link}
|
||||
<span class="link">{label(link)}</span>
|
||||
<button class="link" onclick={() => openLink(link)} title={t('notes.linkOpen')}>
|
||||
{label(link)}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Sharing a note is filing it in a project.
|
||||
Not an access list of its own: projects already have members, roles
|
||||
and invitations, all of them tested and all of them things people
|
||||
have already learnt here. A second way to give somebody access is a
|
||||
second place to get it wrong, and the two would disagree on the day
|
||||
one of them was changed. -->
|
||||
<label class="share">
|
||||
<span class="hint">{t('notes.shareWith')}</span>
|
||||
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
||||
<option value="">{t('notes.private')}</option>
|
||||
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<!-- All of sharing behind one button. A note has two ways in — a
|
||||
project and named people — and both belong to the owner; the
|
||||
panel gathers them rather than lining them up across the footer. -->
|
||||
{#if ownsSelected}
|
||||
<div class="sharewrap" bind:this={shareWrap}>
|
||||
<button
|
||||
class="button"
|
||||
class:on={shareOpen}
|
||||
aria-expanded={shareOpen}
|
||||
aria-haspopup="dialog"
|
||||
onclick={() => (shareOpen = !shareOpen)}
|
||||
>
|
||||
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
|
||||
</button>
|
||||
|
||||
{#if shareOpen}
|
||||
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
|
||||
<!-- Filing it in a project: everybody who can read the project
|
||||
reads the note. A real <label> around the select, so it is
|
||||
named for a screen reader and not only for the eye. -->
|
||||
<section>
|
||||
<label class="field">
|
||||
<span class="phead">{t('notes.shareWith')}</span>
|
||||
<select
|
||||
aria-label={t('notes.shareWith')}
|
||||
value={selected.project_id ?? ''}
|
||||
onchange={(e) => share(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('notes.private')}</option>
|
||||
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Handing it to named people, without a project between. -->
|
||||
<section>
|
||||
<span class="phead">{t('notes.sharePeople')}</span>
|
||||
{#if shares.length}
|
||||
<ul class="sharelist">
|
||||
{#each shares as sh (sh.user.id)}
|
||||
<li>
|
||||
<span class="who">
|
||||
<span
|
||||
class="avatar"
|
||||
style="--who: {sh.user.avatar_color}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{sh.user.name}
|
||||
</span>
|
||||
<select
|
||||
value={sh.role}
|
||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
||||
>
|
||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||
</select>
|
||||
<button
|
||||
class="unshare"
|
||||
onclick={() => removeShare(sh.user.id)}
|
||||
title={t('notes.shareRemove')}
|
||||
aria-label={t('notes.shareRemove')}>×</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span class="none">{t('notes.shareNobody')}</span>
|
||||
{/if}
|
||||
{#if shareCandidates.length}
|
||||
<div class="addshare">
|
||||
<select bind:value={sharePick}>
|
||||
<option value="">{t('notes.sharePick')}</option>
|
||||
{#each shareCandidates as person (person.id)}
|
||||
<option value={person.id}>{person.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={shareRole}>
|
||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||
</select>
|
||||
<button
|
||||
class="button"
|
||||
disabled={!sharePick || shareBusy}
|
||||
onclick={addShare}
|
||||
>
|
||||
{t('notes.shareAdd')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||
{#if ownsSelected}
|
||||
<!-- Laid away, not thrown away: archive is the note leaving the list
|
||||
without leaving the account, and it reads "bring it back" once it
|
||||
already has. Owner only, like delete. -->
|
||||
<button class="button" onclick={() => archiveOne(selected)}>
|
||||
{selected.archived_at ? t('notes.unarchive') : t('notes.archive')}
|
||||
</button>
|
||||
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{:else}
|
||||
@@ -1392,18 +1663,180 @@
|
||||
.link {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 var(--s1);
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.share {
|
||||
/* Sharing lives in a popover above the Del button, so the footer stays a status
|
||||
and a few buttons rather than a row that runs off its own width. */
|
||||
.sharewrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.button.on {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.sharewrap .count {
|
||||
margin-left: 0.4em;
|
||||
padding: 0 0.45em;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--line-strong);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.sharepanel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--s2));
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 20rem;
|
||||
max-width: 80vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sharepanel section,
|
||||
.sharepanel .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.share select {
|
||||
.sharepanel .phead {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.sharepanel select {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
}
|
||||
|
||||
/* The person icon on the Delt med mig heading, sized to the row of text it
|
||||
sits in rather than to a fixed pixel. */
|
||||
h3.group .peoplemark {
|
||||
flex: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
h3.group .peoplemark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The person chip: the project mark's shape in a person's own colour. No ▸
|
||||
before it — the coloured dot already says "belongs to", and two markers on
|
||||
one short chip is noise. It sits next to the title rather than pushed to the
|
||||
right, so in the Delt med mig group it reads as "Ferieplan — Sofie". */
|
||||
.filed.person {
|
||||
margin-left: 0.5em;
|
||||
max-width: 40%;
|
||||
}
|
||||
.filed.person::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.sharepanel .none {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.sharelist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sharelist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.sharelist .who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sharelist .avatar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--who, var(--line-strong));
|
||||
}
|
||||
|
||||
.sharelist select {
|
||||
font-size: var(--text-xs);
|
||||
padding: 1px var(--s1);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The × that takes access away. A word would crowd the row; the mark is enough
|
||||
beside a name and a role. */
|
||||
.unshare {
|
||||
flex: none;
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.unshare:hover {
|
||||
color: var(--danger, var(--ink));
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.addshare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--s1);
|
||||
}
|
||||
/* The person picker takes its own row, so the role and the button below it never
|
||||
get squeezed to nothing in the panel's width. */
|
||||
.addshare select:first-child {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.addshare .button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.addshare select {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
}
|
||||
|
||||