Compare commits

...
Author SHA1 Message Date
Kristian Wind d67b61d3c6 Merge pull request #17 from kristianwind/fix-mono-block-escape
CI / Go (push) Failing after 13m12s
CI / OpenAPI is valid (push) Successful in 9s
CI / Docker image builds (push) Successful in 1m52s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 6m13s
Fix: let a monospace line be left — on return and through the menu
2026-08-24 09:34:38 +02:00
kristianwindandClaude Opus 5 f0d18bb800 fix(notes): let a monospace line be left — on return and through the menu
A <pre> is preformatted, so the browser answered Enter by inserting a <br> and
keeping the caret inside it. A monospace last line therefore trapped everything
typed after it in monospace, with no way back to body by typing — and the style
menu could not get out either: `formatBlock('p')` on a line inside a <pre> nests a
<p> *inside* the block (`<pre>…<p>body</p></pre>`), so the text stayed monospace
and the note was left malformed. Reported as "monospace keeps going and I can
neither delete it nor switch to body".

Two fixes, both in the editor:

- Plain Enter in a <pre> now steps out: the tail after the caret becomes a
  paragraph below the block, and that is where the caret lands. Shift+Enter is
  untouched, so a soft line break still writes multi-line code inside the block —
  Enter to leave, Shift+Enter to stay.

- The style menu converts anything touching a <pre> by hand, replacing the element
  rather than reformatting inside it, so a monospace line goes cleanly back to body
  (or to a heading) instead of nesting.

The split can leave an empty text node in the new paragraph, 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. So an empty paragraph is given a <br> to stand in, which was
the difference between the caret landing in the new line and the text reappearing
in the code.

e2e: a monospace line left on Enter is body after it, Shift+Enter stays monospace,
the menu returns it to body, and the whole thing survives the round-trip through
Markdown and a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:27:44 +02:00
Kristian Wind 7f8d925299 Merge pull request #15 from kristianwind/share-notes-between-users
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 6m39s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m33s
Release / Build and publish image (push) Failing after 5s
Share notes with people, not only through a project
2026-08-24 08:33:06 +02:00
kristianwind cdab817141 Merge main: pick up the calendar e2e flake fix 2026-08-24 08:25:51 +02:00
Kristian Wind 2e76ced5fa Merge pull request #16 from kristianwind/fix-flaky-calendar-drag
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m45s
CI / Docker image builds (push) Successful in 1m47s
CI / Go (push) Failing after 14m5s
Fix flaky calendar e2e: count the test's own events, not the whole day
2026-08-24 08:25:41 +02:00
kristianwindandClaude Opus 5 643b30555e test(e2e): follow the renamed share label, and cover person-to-person sharing
The project-filing control was relabelled "Del i" → "Læg i projekt" and its empty
option "Kun mig" → "Intet projekt" (a note can now be shared with people while
under no project, so "kun mig" was no longer true). The two e2e tests that drove
that control by its old label are updated to the new one.

And a new test for the feature itself: an owner shares a note with a second
account from the Del med personer panel, the recipient's own list grows a Delt
med mig group with the note in it, a viewer's row carries no star or archive
control, and unsharing takes it back off their list. The recipient is 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:59:57 +02:00
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:36:55 +02:00
15 changed files with 1293 additions and 50 deletions
+86
View File
@@ -3072,6 +3072,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 +3970,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 +3994,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.
+51 -18
View File
@@ -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
}
+181
View File
@@ -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)
}
+199
View File
@@ -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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
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, "sofie@example.dk", "Sofie")
owner.newUser(t, "anders@example.dk", "Anders")
sofie := userID(t, owner, "sofie@example.dk")
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, "kristian@example.dk")
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")
}
}
+5
View File
@@ -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)
@@ -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);
+133
View File
@@ -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()
}
+13 -1
View File
@@ -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.
+44 -3
View File
@@ -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.
+128 -6
View File
@@ -2441,7 +2441,7 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
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' });
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 +2451,7 @@ 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.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 +2460,66 @@ 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('sofie@example.dk');
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();
const share = page.locator('.people-share');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await page.getByRole('button', { name: 'Del', exact: true }).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
}) => {
@@ -2920,10 +2980,10 @@ 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.
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 +3049,68 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
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']);
+6
View File
@@ -405,6 +405,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'),
+98
View File
@@ -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;
@@ -470,6 +558,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();
+15 -2
View File
@@ -16,10 +16,23 @@
*/
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.sharedBy': 'Delt af {name}',
'notes.sharePeople': 'Del med personer',
'notes.sharePick': 'Vælg en person …',
'notes.shareAdd': 'Del',
'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',
+15 -2
View File
@@ -12,10 +12,23 @@
*/
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.sharedBy': 'Shared by {name}',
'notes.sharePeople': 'Share with people',
'notes.sharePick': 'Choose someone …',
'notes.shareAdd': 'Share',
'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',
+289 -18
View File
@@ -146,14 +146,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 +184,77 @@
// 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);
$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.
*
@@ -650,6 +735,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 +785,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 +805,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 +834,7 @@
{note.pinned ? '★' : '☆'}
</button>
</div>
{/if}
</div>
</li>
{/each}
@@ -754,25 +865,77 @@
{/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>
<!-- Filing a note in a project is one way to share it: everybody who can
read the project reads the note. Owner only, because moving a note
the owner is not in would take it away from them. -->
{#if ownsSelected}
<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>
<!-- The other way: hand the note to named people, without a project
between. The owner picks who and at what role; the list below is
who can already see it. -->
<div class="people-share">
<span class="hint">{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}
</div>
{/if}
<div class="actions">
<button class="button" onclick={save}>{t('notes.save')}</button>
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{#if ownsSelected}
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{/if}
</div>
</footer>
{:else}
@@ -1408,6 +1571,114 @@
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;
}
/* Del med personer, under the note. */
.people-share {
display: flex;
flex-direction: column;
gap: var(--s1);
width: 100%;
}
.people-share .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;
}
.addshare select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}
/* Real buttons. They were bare words, which on a Mac reads as a web page rather
than as something you can press. */
.actions {