Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 |
@@ -805,6 +805,28 @@ paths:
|
|||||||
"200": { $ref: "#/components/responses/Task" }
|
"200": { $ref: "#/components/responses/Task" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"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:
|
/api/v1/tasks/quick-add:
|
||||||
post:
|
post:
|
||||||
tags: [tasks]
|
tags: [tasks]
|
||||||
|
|||||||
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|||||||
return out
|
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 {
|
for _, sub := range subs {
|
||||||
err := push.Send(ctx, push.Subscription{
|
err := push.Send(ctx, push.Subscription{
|
||||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||||
}, payload, push.VAPID{
|
}, payload, push.VAPID{
|
||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: subject,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
out.Sent++
|
out.Sent++
|
||||||
|
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||||
|
s.log.Warn("mark push delivered", "err", delErr)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Warn, not Debug.
|
// Warn, not Debug.
|
||||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|||||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
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:verdande@example.invalid, 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:verdande@example.invalid"
|
||||||
|
}
|
||||||
|
|
||||||
// --- subscription endpoints -----------------------------------------------------------
|
// --- subscription endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
type pushSubscriptionRequest struct {
|
type pushSubscriptionRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||||
|
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||||
|
// every push to an Apple device was refused. With no real address, the instance's
|
||||||
|
// own https URL is used instead — valid, real, and accepted.
|
||||||
|
func TestVAPIDSubject(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
baseURL string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"a real address is used as-is", "todo@example.dk", "https://verdande.example.dk", "mailto:todo@example.dk"},
|
||||||
|
{"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,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("/complete", s.handleCompleteTask)
|
||||||
r.Post("/reopen", s.handleReopenTask)
|
r.Post("/reopen", s.handleReopenTask)
|
||||||
r.Post("/move", s.handleMoveTask)
|
r.Post("/move", s.handleMoveTask)
|
||||||
|
r.Post("/snooze", s.handleSnoozeTask)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,23 +16,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type taskJSON struct {
|
type taskJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
SectionID string `json:"section_id,omitempty"`
|
SectionID string `json:"section_id,omitempty"`
|
||||||
ParentID string `json:"parent_id,omitempty"`
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
DueDate string `json:"due_date,omitempty"`
|
DueDate string `json:"due_date,omitempty"`
|
||||||
DueDatetime string `json:"due_datetime,omitempty"`
|
DueDatetime string `json:"due_datetime,omitempty"`
|
||||||
DurationMin *int `json:"duration_min,omitempty"`
|
DurationMin *int `json:"duration_min,omitempty"`
|
||||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||||
RepeatText string `json:"recurrence_text,omitempty"`
|
RepeatText string `json:"recurrence_text,omitempty"`
|
||||||
AssigneeID string `json:"assignee_id,omitempty"`
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
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
|
// 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.
|
// fetched when one is opened, because the point is that you see it in the list.
|
||||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
|||||||
if t.CompletedAt != nil {
|
if t.CompletedAt != nil {
|
||||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
if t.SnoozedUntil != nil {
|
||||||
|
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||||
|
}
|
||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type snoozeRequest struct {
|
||||||
|
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||||
|
// but when the task wants to be seen — its due date and everything else stay put.
|
||||||
|
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req snoozeRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var until *time.Time
|
||||||
|
if strings.TrimSpace(req.Until) != "" {
|
||||||
|
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||||
|
if perr != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until = &parsed
|
||||||
|
}
|
||||||
|
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||||
|
s.storeError(w, r, "snooze task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verb := "task.snoozed"
|
||||||
|
if until == nil {
|
||||||
|
verb = "task.unsnoozed"
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||||
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||||
|
// it puts it back. The list order is what the row's dimming rides on.
|
||||||
|
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||||
|
aID, _ := a["id"].(string)
|
||||||
|
|
||||||
|
// Snooze Alfa two days out.
|
||||||
|
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||||
|
t.Error("the response does not carry snoozed_until")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alfa now sorts after Bravo, though it was created first.
|
||||||
|
order := func() []string {
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
var out []string
|
||||||
|
for _, raw := range list["tasks"].([]any) {
|
||||||
|
out = append(out, raw.(map[string]any)["content"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
got := order()
|
||||||
|
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||||
|
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake it, and it comes back above Bravo.
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||||
|
t.Errorf("waking left snoozed_until set: %q", s)
|
||||||
|
}
|
||||||
|
if got := order(); got[0] != "Alfa" {
|
||||||
|
t.Errorf("woken task did not return: order = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+38
-13
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
|||||||
|
|
||||||
for _, t := range cal.Tasks {
|
for _, t := range cal.Tasks {
|
||||||
writeTodo(&b, t, cal.Domain)
|
writeTodo(&b, t, cal.Domain)
|
||||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||||
if t.DueDatetime != nil && !t.Completed {
|
// 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)
|
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) {
|
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")
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
// 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.
|
// sharing a UID would make a client treat them as two versions of one item.
|
||||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
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))
|
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||||
if t.Description != "" {
|
if t.Description != "" {
|
||||||
writeLine(b, "DESCRIPTION:"+escape(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") }
|
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
|
// 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
|
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||||
// summary at that character in every client.
|
// summary at that character in every client.
|
||||||
|
|||||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
|||||||
|
|
||||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||||
// or a feed is useless in the calendar most people actually keep.
|
// 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())
|
out := Render(sample())
|
||||||
|
|
||||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||||
strings.Count(out, "BEGIN:VEVENT"))
|
// 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.
|
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
// they sit on their own day rather than bleeding into the next.
|
||||||
t.Error("the event's end does not reflect the task's duration")
|
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.
|
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||||
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
// 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
|
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||||
// silently unsubscribing somebody's phone.
|
// 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 {
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||||
if permanent {
|
if permanent {
|
||||||
// Not scoped by user, and that is right here: this is the push service
|
// Not scoped by user, and that is right here: this is the push service
|
||||||
|
|||||||
+37
-5
@@ -29,6 +29,9 @@ type Task struct {
|
|||||||
AssigneeID string
|
AssigneeID string
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
CompletedBy string
|
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`
|
// 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.
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
CreatedBy string
|
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,
|
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.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.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
|
FROM tasks t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
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,
|
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.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.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)
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
|
|||||||
if f.CompletedOnly {
|
if f.CompletedOnly {
|
||||||
return "t.completed_at DESC, t.id DESC"
|
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) {
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
// stays; it loses its author.
|
// stays; it loses its author.
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var dueDatetime, completedAt sql.NullInt64
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
var duration sql.NullInt64
|
var duration, snoozedUntil sql.NullInt64
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
&recurrence, &assignee, &completedAt, &completedBy,
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
&createdBy, &t.SortOrder, &created, &updated,
|
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t, err
|
return t, err
|
||||||
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
v := int(duration.Int64)
|
v := int(duration.Int64)
|
||||||
t.DurationMin = &v
|
t.DurationMin = &v
|
||||||
}
|
}
|
||||||
|
if snoozedUntil.Valid {
|
||||||
|
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||||
|
t.SnoozedUntil = &v
|
||||||
|
}
|
||||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|||||||
+69
-7
@@ -127,6 +127,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
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 }) => {
|
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -2638,21 +2664,28 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
await ed.click();
|
await ed.click();
|
||||||
|
await page.keyboard.type('Mine planer');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
await page.keyboard.type('Se ');
|
await page.keyboard.type('Se ');
|
||||||
await page.keyboard.type('[[Rejse');
|
await page.keyboard.type('[[Rejse');
|
||||||
|
|
||||||
// The picker offers the note by title, and choosing it closes the brackets.
|
// 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' });
|
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click();
|
await option.click();
|
||||||
await expect(ed).toContainText('[[Rejseplan til Berlin]]');
|
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
|
||||||
|
await expect(inline).toBeVisible();
|
||||||
|
|
||||||
// And the link is real: it turns up in the note's links, not just as text.
|
// It survives the round-trip through Markdown and a reload.
|
||||||
await ed.blur();
|
await ed.blur();
|
||||||
await expect(page.locator('.links')).toContainText('Rejseplan til Berlin');
|
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 the chip opens the note it names.
|
// And it is a link you can follow: clicking it opens the note it names.
|
||||||
await page.locator('.links .link', { hasText: 'Rejseplan til Berlin' }).click();
|
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
|
||||||
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
@@ -3151,6 +3184,34 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
|||||||
expect(trouble).toEqual([]);
|
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 }) => {
|
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
@@ -3286,7 +3347,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
|||||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
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);
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export const api = {
|
|||||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||||
|
|
||||||
quickAdd: (text, projectId, sectionId) =>
|
quickAdd: (text, projectId, sectionId) =>
|
||||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
let { note, notes = [], onchange, onsave } = $props();
|
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -486,23 +486,54 @@
|
|||||||
return al.localeCompare(bl, 'da');
|
return al.localeCompare(bl, 'da');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole name, and closes a note link. */
|
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||||
|
|
||||||
|
// A click on a note link follows it; a click anywhere else re-colours the code,
|
||||||
|
// as it did before. The link is contenteditable="false", so the click is a click
|
||||||
|
// and not a caret landing in the middle of a word.
|
||||||
|
function onEditorClick(event) {
|
||||||
|
const link = event.target.closest?.('.notelink');
|
||||||
|
if (link) {
|
||||||
|
event.preventDefault();
|
||||||
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
colourCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
function accept(item) {
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) return;
|
if (!partial) return;
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(partial.node, partial.start);
|
|
||||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
// A note link closes its own brackets — the opener is already in the text —
|
if (item.kind === 'note') {
|
||||||
// and both end in a space, because the next thing typed is a word and not
|
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||||
// more of the tag, and without it the suggestions come straight back.
|
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||||
const inserted = item.kind === 'note' ? `${item.label}]] ` : `${item.label} `;
|
// become one when the note is next opened. A space after, as its own text,
|
||||||
document.execCommand('insertText', false, inserted);
|
// 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 = [];
|
suggestions = [];
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
}
|
}
|
||||||
@@ -803,7 +834,7 @@
|
|||||||
readState();
|
readState();
|
||||||
readSuggestions();
|
readSuggestions();
|
||||||
}}
|
}}
|
||||||
onclick={colourCode}
|
onclick={onEditorClick}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -1165,6 +1196,18 @@
|
|||||||
margin: var(--s2) 0;
|
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) {
|
.page :global(blockquote) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s3);
|
padding-left: var(--s3);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.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';
|
import { REPEATS } from '$lib/when.js';
|
||||||
|
|
||||||
let { task, onclose } = $props();
|
let { task, onclose } = $props();
|
||||||
@@ -26,6 +26,46 @@
|
|||||||
// Length in minutes, as a string for the field. Only meaningful with a time —
|
// 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.
|
// see the field below — so it is seeded and shown alongside the clock.
|
||||||
let duration = $state('');
|
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 labels = $state('');
|
||||||
let projectId = $state('');
|
let projectId = $state('');
|
||||||
let recurrence = $state('');
|
let recurrence = $state('');
|
||||||
@@ -459,6 +499,31 @@
|
|||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="labels">{t('detail.labels')}</label>
|
<label for="labels">{t('detail.labels')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -765,13 +830,47 @@
|
|||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label,
|
||||||
|
.field-label {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--ink-faint);
|
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,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
@@ -1016,5 +1115,21 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
border-left: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -120,7 +120,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
|
// A snoozed task sinks below the rest, then rises back when its time passes —
|
||||||
|
// the same order the server sorts a list in, kept here so a drag-sorted list
|
||||||
|
// agrees rather than jumping when it reloads.
|
||||||
|
const snoozedNow = (t) => t.snoozed_until && new Date(t.snoozed_until) > new Date();
|
||||||
|
let ordered = $derived(
|
||||||
|
[...tasks].sort((a, b) => {
|
||||||
|
const sa = snoozedNow(a) ? 1 : 0;
|
||||||
|
const sb = snoozedNow(b) ? 1 : 0;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return a.sort_order - b.sort_order;
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each ordered as task (task.id)}
|
{#each ordered as task (task.id)}
|
||||||
|
|||||||
@@ -63,9 +63,30 @@
|
|||||||
})
|
})
|
||||||
: null
|
: 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>
|
</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
|
<button
|
||||||
class="check"
|
class="check"
|
||||||
class:checked={task.completed || leaving}
|
class:checked={task.completed || leaving}
|
||||||
@@ -100,6 +121,14 @@
|
|||||||
{assignee.name}
|
{assignee.name}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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}
|
{#if due}
|
||||||
<span class="due" data-tone={due.tone}>
|
<span class="due" data-tone={due.tone}>
|
||||||
{due.text}{#if time} {time}{/if}
|
{due.text}{#if time} {time}{/if}
|
||||||
@@ -183,6 +212,26 @@
|
|||||||
text-decoration-color: var(--ink-faint);
|
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 {
|
.check {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|||||||
@@ -600,6 +600,13 @@ export const da = {
|
|||||||
'detail.due': 'Forfalder',
|
'detail.due': 'Forfalder',
|
||||||
'detail.dueTime': 'Klokkeslæt',
|
'detail.dueTime': 'Klokkeslæt',
|
||||||
'detail.duration': 'Længde i minutter',
|
'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.durationShort': 'min',
|
||||||
'detail.labels': 'Etiketter',
|
'detail.labels': 'Etiketter',
|
||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
|
|||||||
@@ -596,6 +596,13 @@ export const en = {
|
|||||||
'detail.due': 'Due',
|
'detail.due': 'Due',
|
||||||
'detail.dueTime': 'Time',
|
'detail.dueTime': 'Time',
|
||||||
'detail.duration': 'Length in minutes',
|
'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.durationShort': 'min',
|
||||||
'detail.labels': 'Labels',
|
'detail.labels': 'Labels',
|
||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
|
|||||||
+33
-6
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''')
|
|||||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||||
*/
|
*/
|
||||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
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";
|
const MARK = "\u0000";
|
||||||
|
|
||||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
function inlineToHtml(text) {
|
function inlineToHtml(text) {
|
||||||
|
// Lifted out before anything escapes or marks the text, and dropped back in
|
||||||
|
// whole at the end — the same placeholder trick the image already used, now for
|
||||||
|
// two kinds of thing the surrounding markup must not touch.
|
||||||
|
const pieces = [];
|
||||||
|
const lift = (html) => {
|
||||||
|
pieces.push(html);
|
||||||
|
return `${MARK}${pieces.length - 1}${MARK}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
// 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
|
// 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.
|
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||||
const images = [];
|
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
|
||||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
|
||||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
|
||||||
return `${MARK}${images.length - 1}${MARK}`;
|
// 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)
|
const marked = escapeHtml(lifted)
|
||||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
|
|||||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
.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.TEXT_NODE) return node.textContent;
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
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('');
|
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||||
switch (node.tagName) {
|
switch (node.tagName) {
|
||||||
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
||||||
|
|||||||
@@ -408,6 +408,19 @@ class AppState {
|
|||||||
* and time are left alone, so making a task longer on the calendar does not
|
* and time are left alone, so making a task longer on the calendar does not
|
||||||
* also reschedule it.
|
* 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) {
|
async resize(id, minutes) {
|
||||||
const previous = this.get(id);
|
const previous = this.get(id);
|
||||||
if (!previous) return;
|
if (!previous) return;
|
||||||
|
|||||||
@@ -916,6 +916,7 @@
|
|||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
|
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
typed();
|
typed();
|
||||||
@@ -1035,6 +1036,12 @@
|
|||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
{#if ownsSelected}
|
{#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>
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user