Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 fc4877c3bd fix(mobile): stop the task drawer's priority/due row overflowing
On a phone the detail drawer's field grid stayed two columns, so each
field got half the viewport. Prioritet collapsed to "Ing" and the due
row — a date input plus fixed-width time and duration fields — ran off
the right edge. Below 560px the grid now stacks to one column per field
and the due row wraps.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 12:24:29 +02:00
Kristian Wind 2da679d629 Merge pull request #28 from kristianwind/task-snooze
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 8m11s
CI / Docker image builds (push) Successful in 1m35s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m17s
Snooze: park a task at the bottom until a time
2026-08-25 11:07:39 +02:00
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 10:59:34 +02:00
Kristian Wind fa816b2e41 Merge pull request #27 from kristianwind/ics-alldays-for-dated-tasks
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m52s
CI / Docker image builds (push) Successful in 1m19s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 13m49s
Fix: date-only tasks now appear in a subscribed calendar (ICS)
2026-08-25 08:48:47 +02:00
kristianwindandClaude Opus 5 60d66b728e fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only
for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or
Google ignores VTODO entirely and draws only events — so a task with just a due
date, which is most of them, appeared in the verdande app's own calendar and
nowhere in the calendar it had been subscribed into. "Verdande does not update in my
calendar, only in the verdande app."

Every dated, unfinished task is an event now: a clock time makes a timed event as
before, a bare date an all-day one (a DATE start and an exclusive next-day end, so
it stays on its own day). Finished tasks stay off the calendar, as they were.

Test updated: the sample's timed task and its two date-only tasks all become events
now, the all-day ones in the DATE form, and the finished one still makes none.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:42:25 +02:00
Kristian Wind bae3b3505f Merge pull request #26 from kristianwind/fix-apple-web-push-subject
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m12s
CI / Docker image builds (push) Successful in 1m18s
CI / Go (push) Failing after 14m7s
Fix: Apple rejected all push notifications (VAPID subject)
2026-08-25 08:30:45 +02:00
kristianwindandClaude Opus 5 7b1e7ba7c9 fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject
Every Web Push to an iPhone or a Mac was refused, silently as far as the person
waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an
instance with no mail configured it fell back to mailto:[email protected],
and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified
against web.push.apple.com on the live subscriptions — that mailto gets a 403, the
instance's https URL a 201.

So the subject is now a real mailto when SMTP.From is a real address, and otherwise
the instance's own https base URL — which is a valid VAPID subject, points at
something that exists, and every push service including Apple accepts.

Two things found alongside it, both fixed:

- A successful push did not clear the failure count. A subscription drops at ten
  failures, and all four live ones sat at nine from this bug — one flaky push from
  being deleted despite working. MarkPushDelivered zeroes the count and moves
  last_used_at on every delivery, so a subscription that recovers is not still
  carrying its old failures toward the cliff.

- A reminder with no project made a notification whose URL was "/projekt/" — a
  dead link. Empty project now points at the app root.

Test: the subject is a real mailto when there is one, and the https URL when there
is not — including for the localhost default, which is not a real address.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:24:20 +02:00
Kristian Wind c0dbea10c5 Merge pull request #25 from kristianwind/note-footer-archive
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m8s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
Add an Archive button to the open note's footer
2026-08-24 15:45:14 +02:00
kristianwindandClaude Opus 5 a70ca93102 feat(notes): an Archive button in the open note's footer
Archiving a single note meant finding its row in the list and its hover-only
button. With a note open to read or write, the one place you would reach for it —
its own footer, beside Gem and Slet — had no way to put it away.

There is one now: Arkivér between Save and Delete, which reads "bring it back" once
the note is already archived. Owner only, like delete, and it runs the same archive
the row button does — the note leaves the list without leaving the account.

e2e: archiving from the footer takes the note off the list, the archive holds it
with a bring-back button, and bringing it back returns it. The existing bulk-archive
test is scoped to the selection bar, now that "Arkivér" also lives in the footer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 15:37:17 +02:00
Kristian Wind 2bda3a26db Merge pull request #24 from kristianwind/inline-note-links
CI / Go (push) Failing after 13m26s
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 6m45s
CI / Docker image builds (push) Successful in 1m38s
Release / Build and publish image (push) Failing after 7s
Make [[note]] an inline clickable link, not bracketed text
2026-08-24 13:48:59 +02:00
kristianwindandClaude Opus 5 8022502015 feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a
real link behind the scenes — it showed in the panel under the note — but in the
note itself it stayed brackets, and "it doesn't become a link" is exactly how that
reads. A wiki link is a link where it is written.

Now a [[note]] renders inline as the title, in the accent colour, and a click on it
opens the note it points at. It is atomic — contenteditable="false" — so it moves
and deletes as one thing and a click is a click, not a caret landing mid-word. And
it appears the moment it is chosen: the completion drops the finished link in,
brackets and all swallowed, rather than text waiting to become a link when the note
is next opened.

The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way
it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by
the title it carries — so what is on disk is still the same Markdown, and a note
opened, saved and reopened is unchanged.

e2e: choosing a note writes an inline link, it survives the round-trip through
Markdown and a reload, and clicking it opens the note it names. Full suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:41:04 +02:00
Kristian Wind b07938e351 Merge pull request #23 from kristianwind/clickable-note-links
CI / Go (push) Failing after 14m28s
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m18s
CI / Docker image builds (push) Successful in 1m29s
Release / Build and publish image (push) Failing after 6s
Fix: a [[note]] link is one you can actually follow
2026-08-24 13:19:40 +02:00
kristianwindandClaude Opus 5 de95df44c1 fix(notes): a [[note]] link is one you can follow
Picking a note from the [[ completion wrote [[Title]] and the server made a real
link from it — but the link only ever showed in the panel under the note as plain
text. It said the note pointed somewhere and gave no way to go there, which reads
as "it didn't become a link at all".

The links in that panel are buttons now. A [[note]] resolves by its title to the
note — the one already on the list, or looked up if not — and opens it in place; a
#project goes to its page; a task link to the task. Titles with punctuation work,
brackets and parentheses included, because the match is on the whole title, not a
parse of it.

e2e: after linking a note, clicking its chip opens the note it names.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:11:46 +02:00
Kristian Wind 2d7b6a1d5e Merge pull request #22 from kristianwind/fix-notes-export-order
CI / OpenAPI is valid (push) Successful in 10s
CI / Go (push) Failing after 13m11s
CI / Docker image builds (push) Successful in 1m40s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 7m10s
Fix: export notes in a stable order (flaky export test)
2026-08-24 12:44:36 +02:00
kristianwindandClaude Opus 5 4bbc49fe7e fix(notes): export the notes in a stable order
AllNotes returns newest-first by the second, so two notes written in the same
second have no order between them — and the notes export walks that list assigning
filenames, giving the first note of a name the plain filename and the next the
"(2)". With the order undecided, which of two same-titled notes got which name was
a coin toss that landed differently on each export, and the export test failed
roughly one run in three.

Sort the export by when each note was made, with the id to break a same-second
tie. The first note of a name keeps the name; the archive is the same twice
running. Ids are time-ordered, so the tie-break agrees with creation order rather
than fighting it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:39:11 +02:00
Kristian Wind db2bf619ce Merge pull request #21 from kristianwind/note-link-autocomplete
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m42s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m24s
Fix: [[ completes a note link, like # completes a project
2026-08-24 12:30:04 +02:00
kristianwindandClaude Opus 5 6116ed2e21 fix(notes): [[ completes a note link, the way # completes a project
Typing [[ did nothing. The toolbar hint offered "[[ another note" and the parser
turned a fully-typed [[Title]] into a link on save, but the completion that makes
it usable — the one # has for projects — was never there, so you had to know and
type a note's title exactly, brackets and all.

Now [[ opens the same picker # does, over the notes the page has loaded: the
current note is left out (a note linking to itself is a loop nobody meant), and
one without a title yet cannot be pointed at. Choosing a note closes its own
brackets — the opener is already in the text — and leaves a space, so the next
word is a word and not more of the tag. On save the link is real and shows in the
note's links, not merely as text.

The picker's two kinds are told apart where a project wears its colour: a note
suggestion wears the brackets that open it.

e2e: [[ offers a note by title, choosing it writes [[Title]] into the note, and
the link lands in the note's links. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:22:38 +02:00
Kristian Wind 1ba26ee723 Merge pull request #20 from kristianwind/site-features-light-shots
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m50s
CI / Docker image builds (push) Successful in 1m44s
CI / Go (push) Failing after 14m5s
Release / Build and publish image (push) Failing after 6s
Website: new features and light-theme screenshots
2026-08-24 10:37:07 +02:00
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:30:00 +02:00
Kristian Wind 72b6ea3803 Merge pull request #19 from kristianwind/task-durations-calendar
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m58s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 13m55s
Task durations: draw, resize, and edit a task's length
2026-08-24 10:22:37 +02:00
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 09:58:02 +02:00
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-08-24 07:36:55 +02:00
64 changed files with 2539 additions and 156 deletions
+108
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+63 -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
}
@@ -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"`)
+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, "[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")
}
}
+33 -2
View File
@@ -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 {
+33
View File
@@ -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)
}
})
}
}
+6
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)
@@ -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)
})
})
})
+70 -17
View File
@@ -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))
}
+47
View File
@@ -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)
}
}
+38 -13
View File
@@ -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.
+18 -9
View File
@@ -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;
+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.
+11
View File
@@ -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
+37 -5
View File
@@ -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, &sectionID, &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
+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.
+37
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+294 -8
View File
@@ -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.
+43
View File
@@ -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,
+7
View File
@@ -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'),
+88 -3
View File
@@ -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);
}
+226 -41
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
// 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);
+141 -2
View File
@@ -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>
+12 -1
View File
@@ -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)}
+50 -1
View File
@@ -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}&nbsp;{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;
+28 -2
View File
@@ -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.',
+28 -2
View File
@@ -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.',
+33 -6
View File
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
* 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 `![](http://…)` 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(/&lt;u&gt;(.+?)&lt;\/u&gt;/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
+26
View File
@@ -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);
+455 -22
View File
@@ -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);
}