Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c45080dc2 | ||
|
|
bba2dab209 | ||
|
|
438095e252 | ||
|
|
3132bba7e5 | ||
|
|
44b28f1cd9 | ||
|
|
b497b76be4 | ||
|
|
dba99b7ecc | ||
|
|
f34690f41a | ||
|
|
1bb2c9cda6 | ||
|
|
cac6c14a23 | ||
|
|
f632fb8643 | ||
|
|
eabb00ecc2 | ||
|
|
d2b5821e57 | ||
|
|
4874f7bbd8 | ||
|
|
8bf4f8fbe4 | ||
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 | ||
|
|
de673260a3 | ||
|
|
804c81f74e | ||
|
|
87054ac8ca | ||
|
|
c62dcc3d7c |
@@ -848,6 +848,20 @@ paths:
|
||||
project_id:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
section_id:
|
||||
type: string
|
||||
description: >
|
||||
The section the box was opened in. A "/section" in the text
|
||||
wins, because typing it is a choice and standing there is a
|
||||
circumstance.
|
||||
parent_id:
|
||||
type: string
|
||||
description: >
|
||||
Makes it a sub-task. A sub-task lives in its parent's
|
||||
project, so a "#project" on the same line is not honoured —
|
||||
it comes back as `unknown_project` rather than being
|
||||
silently dropped. Dates, labels and priority are read the
|
||||
same way as for any other line.
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
@@ -3143,6 +3157,40 @@ paths:
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/projects.zip:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every project's tasks as a CSV, in a zip.
|
||||
description: >
|
||||
One CSV per project, in the column layout Todoist writes — so the files
|
||||
import into Todoist and into anything else that reads that shape. A zip
|
||||
rather than one wide file because that is the shape on the other side,
|
||||
and because a single project can be lifted out of it without opening a
|
||||
spreadsheet. The per-project link does the same for one project; this is
|
||||
the whole account in one download.
|
||||
responses:
|
||||
"200":
|
||||
description: A zip archive.
|
||||
content:
|
||||
application/zip:
|
||||
schema: { type: string, format: binary }
|
||||
|
||||
/api/v1/export/tasks.ics:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Every dated task as a calendar of VTODOs.
|
||||
description: >
|
||||
One file, not a zip: a calendar is made to hold many things, and each
|
||||
task carries its project with it, so nothing is lost by putting them
|
||||
together. Tasks without a due date are left out — a VTODO with no date
|
||||
is a line most readers drop on the floor.
|
||||
responses:
|
||||
"200":
|
||||
description: An iCalendar file.
|
||||
content:
|
||||
text/calendar:
|
||||
schema: { type: string }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
|
||||
@@ -38,6 +38,28 @@ type Config struct {
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// Edition decides which half of the program this instance serves.
|
||||
//
|
||||
// "full" is tasks, projects and notes together — what Verdande has always
|
||||
// been. "notes" mounts the notes half and the base underneath it, and leaves
|
||||
// the task routes unmounted: not hidden in the interface, absent from the
|
||||
// router, so an instance that is not a task manager cannot be asked to be one
|
||||
// through its API.
|
||||
//
|
||||
// Notes already stand nearly alone — they reference no task type anywhere in
|
||||
// the store, and the one link between the halves is a `note_links` row whose
|
||||
// kind happens to be "task". What is NOT separable is everything underneath
|
||||
// both: accounts, sessions, invites, sharing, notifications, attachments,
|
||||
// search. Hence one program with two faces rather than two programs: the
|
||||
// alternative is two copies of the base, and two copies of a thing is how the
|
||||
// copies start to disagree.
|
||||
//
|
||||
// Projects stay mounted in both. In the notes edition a project is a
|
||||
// notebook — the same column, the same sharing, a different word — because a
|
||||
// second grouping concept beside `note_shares` would be a second thing to keep
|
||||
// in step for a difference nobody can see.
|
||||
Edition string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
|
||||
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
|
||||
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
|
||||
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
|
||||
// som ikke burde.
|
||||
switch c.Edition {
|
||||
case EditionFull, EditionNotes:
|
||||
case "":
|
||||
c.Edition = EditionFull
|
||||
default:
|
||||
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
// The two faces of the program. Strings rather than a bool, because a bool named
|
||||
// `NotesOnly` answers one question and this one will be asked again — and a
|
||||
// third edition would mean changing every reading of it rather than adding a case.
|
||||
const (
|
||||
EditionFull = "full"
|
||||
EditionNotes = "notes"
|
||||
)
|
||||
|
||||
// NotesOnly is the one question the router asks. Written as a method so the
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
|
||||
// edition. That is the silent direction of this fault: an instance set up to be a
|
||||
// notes program would serve every task route, and the only way to notice would be
|
||||
// to spot that something works which should not.
|
||||
func TestUnknownEditionIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string // the edition we expect, or "" when Load must fail
|
||||
}{
|
||||
{"", EditionFull}, // unset is the full program, as it has always been
|
||||
{EditionFull, EditionFull},
|
||||
{EditionNotes, EditionNotes},
|
||||
{"note", ""}, // a letter short
|
||||
{"Notes", ""}, // the right word, the wrong case
|
||||
{"none", ""}, // plausible and meaning nothing
|
||||
} {
|
||||
t.Setenv("VERDANDE_EDITION", tc.value)
|
||||
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
|
||||
|
||||
got, err := Load()
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
|
||||
continue
|
||||
}
|
||||
if got.Edition != tc.want {
|
||||
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
|
||||
}
|
||||
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
|
||||
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The notes edition is a different program, and this is what makes that true.
|
||||
//
|
||||
// A gate in a route tree is the easiest thing in the world to believe and the
|
||||
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||||
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||||
// So this does not check that a gate is *present*. It walks both routers and
|
||||
// compares the two sets of routes they actually serve.
|
||||
//
|
||||
// The difference is written down below. A route added later that differs between
|
||||
// the editions — in either direction — fails here until somebody says which half
|
||||
// it belongs to. That is the point: the list is the decision, and the walk is
|
||||
// what keeps the decision honest.
|
||||
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
full := routesOf(t, config.EditionFull)
|
||||
notes := routesOf(t, config.EditionNotes)
|
||||
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/upcoming",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
"POST /api/v1/tasks/{taskID}/comments",
|
||||
"POST /api/v1/tasks/{taskID}/complete",
|
||||
"POST /api/v1/tasks/{taskID}/move",
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
|
||||
var unexpected, stillThere []string
|
||||
for r := range full {
|
||||
if notes[r] {
|
||||
if withheld[r] {
|
||||
stillThere = append(stillThere, r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !withheld[r] {
|
||||
unexpected = append(unexpected, r)
|
||||
}
|
||||
}
|
||||
// And the other direction: the notes edition must not grow a route the full
|
||||
// one lacks. A gate that adds is not a gate either.
|
||||
var onlyInNotes []string
|
||||
for r := range notes {
|
||||
if !full[r] {
|
||||
onlyInNotes = append(onlyInNotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unexpected)
|
||||
sort.Strings(stillThere)
|
||||
sort.Strings(onlyInNotes)
|
||||
for _, r := range unexpected {
|
||||
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||||
}
|
||||
for _, r := range stillThere {
|
||||
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||||
}
|
||||
for _, r := range onlyInNotes {
|
||||
t.Errorf("served only by the notes edition: %s", r)
|
||||
}
|
||||
|
||||
// Printed on the green as well as the red. "The editions differ correctly" over
|
||||
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||||
// them means anything — so the number is on screen either way.
|
||||
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||||
|
||||
// The half that stops this being a deletion rather than a gate: what the notes
|
||||
// edition is FOR has to still be there.
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routesOf builds a server at one edition and returns every route it serves.
|
||||
func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
out := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
out[method+" "+route] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(out) < 50 {
|
||||
// A walk that found almost nothing would make every comparison below pass.
|
||||
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllCSV is every project at once, one CSV per project in a zip.
|
||||
//
|
||||
// A zip rather than one wide CSV with a project column: Todoist's own export is a
|
||||
// file per project, so that is the shape an importer on the other side expects —
|
||||
// and the shape somebody can pick a single project out of without opening a
|
||||
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
|
||||
// rather than one long file.
|
||||
//
|
||||
// It exists because the per-project links are per project: fifteen projects meant
|
||||
// fifteen downloads to move your own tasks somewhere else, which is an export that
|
||||
// technically works and nobody uses.
|
||||
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Two projects can share a name — they are still two projects, and a zip with
|
||||
// one entry written twice loses one of them. Same guard as the notes export.
|
||||
used := map[string]int{}
|
||||
for _, project := range projects {
|
||||
exported, err := s.buildExport(r, user, &project)
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
|
||||
// better than a complete-looking file with a project missing from it.
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
|
||||
if err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllICS is every dated task in one calendar.
|
||||
//
|
||||
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
|
||||
// many things and every reader takes a file with hundreds of VTODOs. The project
|
||||
// travels with each task as its own field, so nothing is lost by merging them.
|
||||
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range projects {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
|
||||
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||
// takes, rebuilding the task tree from parent ids.
|
||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||
|
||||
+52
-36
@@ -285,6 +285,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Everything past this point needs a complete login.
|
||||
r.Group(func(r chi.Router) {
|
||||
// Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i
|
||||
// fladen — fraværende fra routeren, så en instans, der ikke er et
|
||||
// opgaveprogram, heller ikke kan bedes om at være det gennem sit API.
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
@@ -294,15 +298,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/mcp", s.handleMCP)
|
||||
|
||||
r.Get("/today", s.handleToday)
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
if full {
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
}
|
||||
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
if full {
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
|
||||
@@ -456,6 +464,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
|
||||
@@ -466,16 +476,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
}
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
if full {
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
}
|
||||
r.Get("/people", s.handlePeople)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
@@ -581,27 +595,29 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
r.Post("/quick-add", s.handleQuickAdd)
|
||||
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
r.Route("/{taskID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -501,6 +501,18 @@ type quickAddRequest struct {
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
|
||||
// ParentID makes the new task a sub-task of an existing one. It is what a
|
||||
// pasted list uses for its indented lines, and what the sub-task field at the
|
||||
// foot of a task uses — both want the same parsing of dates, #project and
|
||||
// @labels that a top-level task gets, so they come through here rather than
|
||||
// through a second, dumber path.
|
||||
//
|
||||
// A sub-task lives in its parent's project. Saying otherwise would describe a
|
||||
// task whose parent is somewhere else, which the list views cannot draw and
|
||||
// nobody means; a "#project" on an indented line is therefore not honoured,
|
||||
// and the caller is told so the word does not just vanish.
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -517,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A parent decides the project before anything else is read: a sub-task
|
||||
// somewhere other than its parent is not a shape this program has.
|
||||
parent := (*store.Task)(nil)
|
||||
if req.ParentID != "" {
|
||||
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
parent = found
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
@@ -532,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
// The parent has the last word, and the word it overrules is reported the same
|
||||
// way an unknown project is — the task is created either way.
|
||||
if parent != nil {
|
||||
if parsed.Project != "" && projectID != parent.ProjectID {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
projectID = parent.ProjectID
|
||||
}
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
@@ -570,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parent != nil {
|
||||
t.ParentID = parent.ID
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
if err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
|
||||
}
|
||||
|
||||
// The whole point of import and export together: what goes in comes back out.
|
||||
|
||||
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
|
||||
//
|
||||
// The line still goes through the same parser as a top-level one — a sub-task
|
||||
// with a date is an ordinary thing to paste, and a second, dumber creation path
|
||||
// for indented lines would be a second place for "i morgen" to mean something
|
||||
// different.
|
||||
func TestQuickAddSubTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Flytte garagen",
|
||||
})
|
||||
parentID, _ := parent["id"].(string)
|
||||
if parentID == "" {
|
||||
t.Fatalf("no parent: %v", parent)
|
||||
}
|
||||
|
||||
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Rydde hylderne p1", "parent_id": parentID,
|
||||
})
|
||||
if got := child["parent_id"]; got != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", got, parentID)
|
||||
}
|
||||
// The line was parsed, not stored raw: the priority marker is gone from the
|
||||
// text and present as a priority.
|
||||
if got := child["content"]; got != "Rydde hylderne" {
|
||||
t.Errorf("content = %v, want the line without its marker", got)
|
||||
}
|
||||
if got := child["priority"]; got != float64(1) {
|
||||
t.Errorf("priority = %v, want 1", got)
|
||||
}
|
||||
|
||||
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
|
||||
otherID, _ := other["id"].(string)
|
||||
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
|
||||
})
|
||||
if got := moved["project_id"]; got == otherID {
|
||||
t.Errorf("the sub-task left its parent's project")
|
||||
}
|
||||
// And it is said out loud rather than the word just disappearing from the
|
||||
// title — the failure this reports is exactly the one that used to be silent.
|
||||
if got := moved["unknown_project"]; got != "Andetsteds" {
|
||||
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// raw fetches a download — a zip or a calendar — as bytes.
|
||||
//
|
||||
// `do` decodes JSON, which these two are not: it would report an empty body and a
|
||||
// parse error where the interesting thing is the bytes themselves.
|
||||
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body, resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
// TestExportEverythingAtOnce covers the two whole-account exports.
|
||||
//
|
||||
// The per-project links worked and were unusable at any size: fifteen projects
|
||||
// meant fifteen downloads to move your own tasks somewhere else. These two are
|
||||
// the same data without the fifteen clicks.
|
||||
//
|
||||
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
|
||||
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
|
||||
// exactly the shape an export bug takes.
|
||||
func TestExportEverythingAtOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Two projects, so the zip has to hold more than the one that happens to be
|
||||
// first — a loop that exports only the last project passes a one-project test.
|
||||
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
|
||||
firstID, _ := first["id"].(string)
|
||||
secondID, _ := second["id"].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Skifte daek", "project_id": secondID,
|
||||
})
|
||||
|
||||
t.Run("csv zip holds one file per project", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
|
||||
if ct != "application/zip" {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a zip: %v", err)
|
||||
}
|
||||
|
||||
found := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
found[f.Name] = string(text)
|
||||
}
|
||||
if len(found) < 2 {
|
||||
t.Fatalf("want a file per project, got %d: %v", len(found), found)
|
||||
}
|
||||
|
||||
// The task has to be IN the file named after its own project. A zip with the
|
||||
// right filenames and every task in the wrong one is the failure this is for.
|
||||
var sawMoms, sawDaek bool
|
||||
for name, text := range found {
|
||||
// Filenames are lower-cased by ics.Filename, so the comparison is too —
|
||||
// asserting on the stored capitalisation would be asserting on a detail
|
||||
// the exporter is free to change.
|
||||
lower := strings.ToLower(name)
|
||||
if strings.Contains(text, "Betal moms") {
|
||||
sawMoms = true
|
||||
if !strings.Contains(lower, "firma") {
|
||||
t.Errorf("Betal moms landed in %q, not in Firma", name)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
sawDaek = true
|
||||
if !strings.Contains(lower, "hjem") {
|
||||
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawMoms || !sawDaek {
|
||||
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ics holds every dated task across projects", func(t *testing.T) {
|
||||
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
|
||||
if !strings.HasPrefix(ct, "text/calendar") {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
text := string(body)
|
||||
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
|
||||
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
|
||||
}
|
||||
if !strings.Contains(text, "Betal moms") {
|
||||
t.Errorf("the dated task is missing:\n%s", text)
|
||||
}
|
||||
// Undated tasks are deliberately left out — a VTODO with no date is a line
|
||||
// most readers drop. Asserted so the omission stays a decision.
|
||||
if strings.Contains(text, "Skifte daek") {
|
||||
t.Errorf("an undated task was exported:\n%s", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
@@ -819,6 +819,15 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
|
||||
står under "It counts itself". Plausible på vores egen maskine: ingen
|
||||
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
|
||||
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
|
||||
<script
|
||||
defer
|
||||
data-domain="verdandetodo.com"
|
||||
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
|
||||
></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readList } from '../src/lib/pastelist.js';
|
||||
|
||||
/**
|
||||
* Læsningen af en indsat liste, prøvet for sig.
|
||||
*
|
||||
* Reglerne er små og mange, og de er billigere at stille her end gennem en
|
||||
* browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
|
||||
* gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
|
||||
* sammen, ikke at hvert tilfælde er rigtigt.
|
||||
*/
|
||||
test.describe('en indsat liste læses som opgaver og underopgaver', () => {
|
||||
test('mærker fjernes, teksten bliver', () => {
|
||||
expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
|
||||
{ depth: 0, text: 'køb mælk', done: false },
|
||||
{ depth: 0, text: 'ring til Per', done: false },
|
||||
{ depth: 0, text: 'hent pakken', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('nummererede lister er også lister', () => {
|
||||
expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
|
||||
{ depth: 0, text: 'først', done: false },
|
||||
{ depth: 0, text: 'så', done: false },
|
||||
{ depth: 0, text: 'til sidst', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('en afkrydsning er indhold, ikke notation', () => {
|
||||
// Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
|
||||
// kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
|
||||
expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
|
||||
{ depth: 0, text: 'betalt', done: true },
|
||||
{ depth: 0, text: 'ikke betalt', done: false },
|
||||
{ depth: 0, text: 'også betalt', done: true }
|
||||
]);
|
||||
});
|
||||
|
||||
test('indrykning bærer niveauet, uanset hvor bred den er', () => {
|
||||
// To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
|
||||
// alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
|
||||
const to = readList('a\n b\nc');
|
||||
const fire = readList('a\n b\nc');
|
||||
const tab = readList('a\n\tb\nc');
|
||||
const want = [
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false },
|
||||
{ depth: 0, text: 'c', done: false }
|
||||
];
|
||||
expect(to).toEqual(want);
|
||||
expect(fire).toEqual(want);
|
||||
expect(tab).toEqual(want);
|
||||
});
|
||||
|
||||
test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
|
||||
// Kopierer man fra et dokument, følger der ofte indrykning med på alle
|
||||
// linjer. Den er ikke et niveau — der er ikke noget at være under.
|
||||
expect(readList(' a\n b')).toEqual([
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('tomme linjer er luft i det kopierede, ikke punkter', () => {
|
||||
// Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
|
||||
// liste.
|
||||
expect(readList('a\n\n b\n\n c')).toEqual([
|
||||
{ depth: 0, text: 'a', done: false },
|
||||
{ depth: 1, text: 'b', done: false },
|
||||
{ depth: 1, text: 'c', done: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('teksten røres ikke ud over mærket', () => {
|
||||
// Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
|
||||
// linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
|
||||
// mange.
|
||||
expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
|
||||
{ depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
|
||||
{ depth: 0, text: 'andet', done: false }
|
||||
]);
|
||||
});
|
||||
});
|
||||
+709
-21
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Venter på, at en gemning faktisk er nået frem til serveren.
|
||||
*
|
||||
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
|
||||
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
|
||||
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
|
||||
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
|
||||
* og fejler på noget, der ikke er i stykker.
|
||||
*
|
||||
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
|
||||
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
|
||||
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
|
||||
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
|
||||
*
|
||||
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
|
||||
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
|
||||
*
|
||||
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
|
||||
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
|
||||
* allerede kommet og gået.
|
||||
*/
|
||||
function gemt(side, sti) {
|
||||
return side.waitForResponse(
|
||||
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
|
||||
);
|
||||
}
|
||||
|
||||
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
|
||||
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
|
||||
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
|
||||
|
||||
/**
|
||||
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
|
||||
*
|
||||
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
|
||||
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
|
||||
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
|
||||
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
|
||||
* findes den titel, prøven leder efter, slet ikke.
|
||||
*
|
||||
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
|
||||
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
|
||||
*
|
||||
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
|
||||
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
|
||||
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
|
||||
*/
|
||||
async function nyNote(side) {
|
||||
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
return felt;
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||
// reason that has nothing to do with what the test is about.
|
||||
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
|
||||
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
|
||||
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
|
||||
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
|
||||
const flyttet = projektGemt(page);
|
||||
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||
await expect(async () => {
|
||||
await sidebar
|
||||
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||
}).toPass({ timeout: 10000 });
|
||||
await flyttet;
|
||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
const about = page.getByLabel('Om gruppen');
|
||||
await expect(about).toBeVisible();
|
||||
await about.fill('Alt der larmer og støver.');
|
||||
|
||||
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
|
||||
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
|
||||
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
|
||||
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
|
||||
// der ikke er i stykker. Svaret er det, der kan ventes på.
|
||||
const gemt = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PATCH' &&
|
||||
new URL(r.url()).pathname.includes('/project-groups/') &&
|
||||
r.ok()
|
||||
);
|
||||
await about.blur();
|
||||
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
||||
await gemt;
|
||||
|
||||
// And it survives a reload, which is the difference between the page keeping up
|
||||
// and the server taking it.
|
||||
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
|
||||
// And the round trip, which is the thing that can quietly ruin a note: what is
|
||||
// stored is Markdown, and reopening it has to give back the same document. A
|
||||
// converter that disagrees with itself reshapes a note a little on every save.
|
||||
const rundtur = noteGemt(page);
|
||||
await page_.blur();
|
||||
await rundtur;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
||||
|
||||
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
|
||||
await body.click();
|
||||
await gæst.keyboard.press('Control+End');
|
||||
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
||||
const gemt = noteGemt(gæst);
|
||||
await body.blur();
|
||||
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
||||
await gemt;
|
||||
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
|
||||
'Aftale om levering\nse [[Prisliste i kroner]]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
|
||||
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
|
||||
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
|
||||
// på den er grøn med det samme og venter på ingenting.
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
await page.keyboard.type(text);
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await felt.blur();
|
||||
const titel = text.split('\n')[0];
|
||||
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
|
||||
// gik i begge:
|
||||
//
|
||||
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
|
||||
// delstreng og uden hensyn til store og små bogstaver — den fandt også
|
||||
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
|
||||
// af hele suiten.
|
||||
//
|
||||
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
|
||||
// tekst med den titel i, så et opslag på teksten fandt tre.
|
||||
//
|
||||
// `button.row strong` er rækkens titelelement, og en forankret regex er både
|
||||
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
|
||||
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
|
||||
await expect(
|
||||
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
|
||||
|
||||
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||
const kildeGemt = noteGemt(page);
|
||||
await body.blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await kildeGemt;
|
||||
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||
|
||||
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||
await expect(inline).toBeVisible();
|
||||
|
||||
// It survives the round-trip through Markdown and a reload.
|
||||
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
|
||||
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
|
||||
const linketGemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(900);
|
||||
await linketGemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
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();
|
||||
const ed = await nyNote(page);
|
||||
await page.keyboard.type('Titel');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
|
||||
// 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 nyNote(page);
|
||||
await page.keyboard.type('Rundtur');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
||||
await page.keyboard.type('docker pull');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('og så videre i almindelig skrift');
|
||||
const rundturGemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(1200);
|
||||
await rundturGemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
|
||||
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||
const date = page.locator('#due');
|
||||
const day = await date.inputValue();
|
||||
const datoGemt = opgaveGemt(page);
|
||||
await date.fill(day);
|
||||
await date.blur();
|
||||
await page.waitForTimeout(400);
|
||||
await datoGemt;
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(
|
||||
@@ -4657,16 +4760,23 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
|
||||
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
|
||||
// Gem-knap at huske.
|
||||
//
|
||||
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
|
||||
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
|
||||
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
|
||||
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
|
||||
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
|
||||
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
|
||||
// enten skrevet på den forkerte note eller tabt undervejs.
|
||||
//
|
||||
// Og der ventes IKKE på "Gemt": den tekst er hvilestanden, ikke et signal.
|
||||
// Feltet siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før
|
||||
// den første gemning overhovedet er begyndt. En prøve, der venter på den, får
|
||||
// grønt med det samme og måler ingenting. Det var den fejl, der gjorde den her
|
||||
// prøve ustabil: rettelsen var en ventetid, der ikke kunne vente på noget.
|
||||
// Rækken i listen er det rigtige tegn — den kommer først, når serveren har
|
||||
// svaret.
|
||||
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await expect(body).toHaveText('');
|
||||
await body.fill(`${title}\n\nnoget om den`);
|
||||
await body.blur();
|
||||
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -4687,14 +4797,518 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
|
||||
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
|
||||
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
||||
|
||||
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
|
||||
// en søgning tilbage, man selv havde ryddet væk.
|
||||
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
|
||||
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
|
||||
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
|
||||
// tilbage til den første. Man kunne søge og ikke vælge.
|
||||
await list.getByText('Fyrreskov syd').click();
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
|
||||
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
|
||||
|
||||
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
|
||||
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
|
||||
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
|
||||
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
|
||||
// kunne så ikke komme tilbage til den note, man kom fra.
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
await palette.getByRole('textbox').fill('Fyrreskov nord');
|
||||
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||
|
||||
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
|
||||
// den skrives kun af navigation. Den første udgave skrev den ved hvert
|
||||
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
|
||||
// prøven nedenfor.
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]q=/);
|
||||
await expect(list.getByText('Egeskov')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En ny note åbnes med det samme, uden at editoren forsvinder undervejs.
|
||||
*
|
||||
* Serveren tømmer `body` på listerækker med vilje, og siden brugte den tomme krop
|
||||
* som tegn på "det her er en række, hent noten hel". En nyoprettet note er også
|
||||
* tom — og hel — så den blev sendt samme vej: editoren blev pillet af skærmen,
|
||||
* noten hentet igen, og editoren sat op på ny.
|
||||
*
|
||||
* Det hul var ikke kun et blink. Mens det stod åbent, var der ingen valgt note, og
|
||||
* gemningen går tavst tilbage uden en. Skrev man i det sekund, var teksten væk —
|
||||
* ingen fejl, ingen toast, bare en tom note. Det er også det, der gjorde
|
||||
* søgeprøven ovenfor ustabil: i CI var maskinen langsom nok til at ramme hullet.
|
||||
*
|
||||
* Prøven tæller opslagene frem for at måle et blink. Et blink er en tidsting og
|
||||
* ville kræve, at prøven var hurtigere end programmet — præcis den fejl, den er
|
||||
* skrevet for at lukke. Et opslag, der ikke sker, er et tal.
|
||||
*/
|
||||
test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
const fetched = [];
|
||||
page.on('request', (r) => {
|
||||
// Selve noten, ikke dens delinger: /notes/<id> og ikke /notes/<id>/shares.
|
||||
const m = new URL(r.url()).pathname.match(/^\/api\/v1\/notes\/([0-9a-f-]+)$/);
|
||||
if (r.method() === 'GET' && m) fetched.push(m[1]);
|
||||
});
|
||||
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
|
||||
// Editoren er der, og den er tom — ikke væk og på vej tilbage.
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await expect(body).toHaveText('');
|
||||
|
||||
await body.fill('Grantræ\n\nnoget om det');
|
||||
await body.blur();
|
||||
await expect(page.locator('.list').getByText('Grantræ', { exact: true })).toBeVisible();
|
||||
|
||||
// Noten blev aldrig hentet igen: svaret på oprettelsen var den hele.
|
||||
expect(fetched).toEqual([]);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et totalt udfald siger "ingen forbindelse" — ikke "log ind".
|
||||
*
|
||||
* Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står `user`
|
||||
* som null, og null betyder "logget ud" alle andre steder i programmet — så et
|
||||
* udfald blev vist som login-skærmen. Appen gav altså sessionen skylden for, at
|
||||
* nettet var nede, og tilbød et kodeordsfelt, der umuligt kunne virke.
|
||||
*
|
||||
* Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke: man
|
||||
* taster kodeordet, der sker ingenting, og man konkluderer, at kontoen er i
|
||||
* stykker. Det skete i virkeligheden 17. september 2026, da instansens DNS-post
|
||||
* blev slettet.
|
||||
*
|
||||
* Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt forespørgsel
|
||||
* er præcis det, en manglende DNS-post giver browseren — fetch afviser, uden at der
|
||||
* nogensinde kom en status. En 500 ville være serveren, der svarer, og det er den
|
||||
* anden sag.
|
||||
*/
|
||||
test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({ page }) => {
|
||||
await page.route('**/api/v1/**', (route) => route.abort());
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Prøvetiden skal kunne rumme hele forsøgsvinduet: forespørgselslaget prøver
|
||||
// igen i femten sekunder, før det kalder det et udfald. Det er med vilje —
|
||||
// et blink på nettet må ikke smide folk ud — så prøven må vente det ud.
|
||||
await expect(page.getByText('Ingen forbindelse', { exact: true })).toBeVisible({
|
||||
timeout: 25000
|
||||
});
|
||||
|
||||
// Og ikke kodeordsfeltet. Det er hele pointen.
|
||||
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Klokkens panel bliver inden for skærmen på en telefon.
|
||||
*
|
||||
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
|
||||
* kant end det — der er en temaknap og en kantafstand til højre for den — så
|
||||
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
|
||||
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
|
||||
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
|
||||
* den.
|
||||
*
|
||||
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
|
||||
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
|
||||
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
|
||||
* det stikke ud til højre, er ikke en rettelse.
|
||||
*/
|
||||
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: /^Beskeder/ }).click();
|
||||
const panel = page.getByRole('dialog', { name: 'Beskeder' });
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const kasse = await panel.boundingBox();
|
||||
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
|
||||
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
|
||||
|
||||
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
|
||||
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
|
||||
// ud på telefonen.
|
||||
const overskrift = panel.getByText('Beskeder', { exact: true });
|
||||
const tekst = await overskrift.boundingBox();
|
||||
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En punktliste kan have niveauer, og de overlever gemningen.
|
||||
*
|
||||
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
|
||||
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
|
||||
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
|
||||
*
|
||||
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
|
||||
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
|
||||
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
|
||||
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
|
||||
*
|
||||
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
|
||||
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
|
||||
*/
|
||||
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Indkøb');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
|
||||
await page.keyboard.type('Frugt');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Aebler');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Niveauet er der med det samme: en liste inde i en liste.
|
||||
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Indkøb/ }).click();
|
||||
|
||||
// Og efter turen gennem Markdown står det der stadig.
|
||||
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
|
||||
|
||||
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
|
||||
await ed.locator('ul ul li').click();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(ed.locator('ul ul li')).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt.
|
||||
*
|
||||
* Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i
|
||||
* den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien
|
||||
* brugte `textContent` — hvor et <br> er den tomme streng. Så en blok skrevet med
|
||||
* shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På
|
||||
* skærmen, ikke kun i filen, og uden at man havde rørt teksten.
|
||||
*
|
||||
* Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme
|
||||
* svar". Der var to, og de var uenige om netop det tegn.
|
||||
*/
|
||||
test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Farvelagt');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('echo en');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('echo to');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
// Ud af blokken, så farvelægningen tager fat i den.
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('End');
|
||||
await ed.getByText('Farvelagt', { exact: true }).click();
|
||||
|
||||
// Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så
|
||||
// den skal ud, før teksten læses — præcis det, koden selv gør.
|
||||
const tekst = await ed.locator('pre').evaluate((el) => {
|
||||
const kopi = el.cloneNode(true);
|
||||
kopi.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||
return kopi.innerText.trim();
|
||||
});
|
||||
expect(tekst).toBe('echo en\necho to');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
|
||||
*
|
||||
* Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
|
||||
* feltet". Det passer for et <input>, hvor ⌘B ikke gør noget. I en contenteditable
|
||||
* ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
|
||||
* noterne foldede menuen sig, hver gang man ville fremhæve et ord.
|
||||
*
|
||||
* Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
|
||||
* halvt færdig: ordet skal også blive fedt.
|
||||
*/
|
||||
test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const foldet = () => page.locator('.shell.sidebar-collapsed').count();
|
||||
expect(await foldet()).toBe(0);
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Fremhævet');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('ordet');
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
|
||||
// Enten <b> eller <strong>: browserens egen fed er <b>, omformeren skriver
|
||||
// <strong>, og begge læses som fed på vejen tilbage.
|
||||
await expect(ed.locator('b, strong')).toContainText('ordet');
|
||||
expect(await foldet()).toBe(0);
|
||||
|
||||
// Og uden for et skrivefelt gør den stadig det, den er sat til.
|
||||
await page.getByRole('heading', { name: 'Noter' }).click();
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
expect(await foldet()).toBe(1);
|
||||
await page.keyboard.press('ControlOrMeta+b');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
|
||||
*
|
||||
* Monotype går uden om `execCommand`, fordi formatBlock pakker et <p> ind i
|
||||
* <pre>'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
|
||||
* altså det ene blok markøren står i — så af en markering over ti linjers
|
||||
* terminaludskrift blev den første linje til kode og resten stod tilbage som
|
||||
* brødtekst.
|
||||
*
|
||||
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
|
||||
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
|
||||
* hegn i filen.
|
||||
*/
|
||||
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Udskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje et');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje to');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('linje tre');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('p')).toHaveCount(3);
|
||||
|
||||
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
|
||||
// af den første til enden af den sidste.
|
||||
await ed.getByText('linje et', { exact: true }).click();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+End');
|
||||
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
|
||||
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
|
||||
const blok = ed.locator('pre');
|
||||
await expect(blok).toHaveCount(1);
|
||||
await expect(ed.locator('p')).toHaveCount(0);
|
||||
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Udskrift/ }).click();
|
||||
|
||||
await expect(ed.locator('pre')).toHaveCount(1);
|
||||
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
|
||||
'linje et\nlinje to\nlinje tre'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hele kontoens opgaver kan hentes i én omgang.
|
||||
*
|
||||
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
|
||||
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
|
||||
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
|
||||
*
|
||||
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
|
||||
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
|
||||
* det er præcis den form, en fejl i en eksport har.
|
||||
*/
|
||||
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
|
||||
|
||||
await page.goto('/indstillinger/data');
|
||||
|
||||
const hent = async (navn) => {
|
||||
const venter = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: navn }).click();
|
||||
const fil = await venter;
|
||||
const sti = await fil.path();
|
||||
return { sti, navn: fil.suggestedFilename() };
|
||||
};
|
||||
|
||||
const zip = await hent('Alle opgaver (CSV)');
|
||||
expect(zip.navn).toMatch(/\.zip$/);
|
||||
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
|
||||
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
|
||||
expect(size).toBeGreaterThan(100);
|
||||
|
||||
const kal = await hent('Alle opgaver (kalender)');
|
||||
expect(kal.navn).toMatch(/\.ics$/);
|
||||
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
|
||||
expect(tekst).toContain('BEGIN:VTODO');
|
||||
expect(tekst).toContain('Betal moms');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En indsat liste bliver til opgaver og underopgaver.
|
||||
*
|
||||
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
|
||||
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
|
||||
* hele mast sammen.
|
||||
*
|
||||
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
|
||||
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
|
||||
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
|
||||
* billigere at stille.
|
||||
*/
|
||||
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
|
||||
'\n'
|
||||
);
|
||||
|
||||
await page.getByLabel('Ny opgave').click();
|
||||
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
|
||||
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
|
||||
await page.evaluate((tekst) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', tekst);
|
||||
document
|
||||
.querySelector('[data-quickadd]')
|
||||
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, liste);
|
||||
|
||||
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
|
||||
// man skal opdage bagefter.
|
||||
const visning = page.getByRole('group', { name: 'Indsat liste' });
|
||||
await expect(visning.locator('li')).toHaveCount(4);
|
||||
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
|
||||
|
||||
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
|
||||
await expect(visning).toHaveCount(0);
|
||||
|
||||
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
|
||||
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
|
||||
|
||||
// Og de indrykkede hænger under den linje, de stod under — ikke under den
|
||||
// første, den bedste. Det ses i opgavens egen rude.
|
||||
await page.getByText('Flytte garagen', { exact: true }).click();
|
||||
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
|
||||
|
||||
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
|
||||
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
|
||||
// er det, serveren har gemt, stregen er, hvordan den tegnes.
|
||||
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
|
||||
|
||||
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
|
||||
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
|
||||
|
||||
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
|
||||
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
|
||||
// undertasks under den — uden at nogen skal vælge noget.
|
||||
await page.evaluate((tekst) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', tekst);
|
||||
document
|
||||
.querySelector('input[aria-label="Ny undertask"]')
|
||||
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, '- Købe maling\n- Låne stige');
|
||||
|
||||
const flere = page.getByRole('group', { name: 'Indsat liste' });
|
||||
await expect(flere.locator('li')).toHaveCount(2);
|
||||
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
|
||||
|
||||
// To mere under den samme opgave: fire undertasks, én af dem lukket.
|
||||
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En kodeblok med flere linjer kommer tilbage med dem alle.
|
||||
*
|
||||
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
|
||||
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
|
||||
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
|
||||
* skrive flere linjer og ikke gemme dem.
|
||||
*
|
||||
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
|
||||
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
|
||||
* ovenfor stopper lige før.
|
||||
*/
|
||||
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await nyNote(page);
|
||||
await page.keyboard.type('Opskrift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||
await page.keyboard.type('first line');
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
await page.keyboard.type('second line');
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const gemt = noteGemt(page);
|
||||
await ed.blur();
|
||||
await gemt;
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Opskrift/ }).click();
|
||||
|
||||
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
|
||||
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
|
||||
//
|
||||
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
|
||||
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
|
||||
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
|
||||
// en blocks tekst — bare fra den anden side.
|
||||
const blok = ed.locator('pre');
|
||||
await expect(blok).toHaveCount(1);
|
||||
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
|
||||
'first line\nsecond line'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
||||
*
|
||||
@@ -4821,3 +5435,77 @@ test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
|
||||
*
|
||||
* Fra en skærmoptagelse, to billeder i sekundet:
|
||||
*
|
||||
* 2,5 s feltet "verd" listen ufiltreret
|
||||
* 3,0 s feltet "nde" listen filtreret
|
||||
* 6,0 s feltet "verdande" listen ufiltreret
|
||||
* 6,5 s feltet tomt listen filtreret
|
||||
*
|
||||
* To fejl oven i hinanden.
|
||||
*
|
||||
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
|
||||
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
|
||||
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
|
||||
*
|
||||
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
|
||||
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
|
||||
*/
|
||||
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
|
||||
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const krop = page.getByLabel('Notens tekst');
|
||||
await krop.fill('Kaffemølle\n\nnoget om den');
|
||||
await krop.blur();
|
||||
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
|
||||
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
|
||||
|
||||
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
|
||||
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
|
||||
const felt = page.getByPlaceholder('Søg i noter');
|
||||
await felt.click();
|
||||
await page.keyboard.type('kaffemølle', { delay: 40 });
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
|
||||
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
|
||||
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
|
||||
// Det nyeste svar vinder, også når det kommer først.
|
||||
//
|
||||
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
|
||||
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
|
||||
// ikke længere bliver stillet.
|
||||
let held;
|
||||
await page.route('**/api/v1/notes?*', async (route) => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
if (q === 'aaaaa' && !held) {
|
||||
held = route;
|
||||
return; // holdes, indtil den næste er kommet igennem
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await felt.fill('');
|
||||
await felt.fill('aaaaa');
|
||||
await page.waitForTimeout(500);
|
||||
await felt.fill('kaffemølle');
|
||||
await page.waitForTimeout(900);
|
||||
if (held) await held.continue();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
|
||||
await expect(felt).toHaveValue('kaffemølle');
|
||||
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
|
||||
|
||||
await page.unroute('**/api/v1/notes?*');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
+9
-2
@@ -365,8 +365,13 @@ export const api = {
|
||||
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 }),
|
||||
quickAdd: (text, projectId, sectionId, parentId) =>
|
||||
post('/tasks/quick-add', {
|
||||
text,
|
||||
project_id: projectId,
|
||||
section_id: sectionId,
|
||||
parent_id: parentId
|
||||
}),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
@@ -511,6 +516,8 @@ export const api = {
|
||||
importCSV: (data) => post('/import/csv', data),
|
||||
exportAccountURL: () => '/api/v1/export/account',
|
||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
||||
exportAllCSVURL: () => '/api/v1/export/projects.zip',
|
||||
exportAllICSURL: () => '/api/v1/export/tasks.ics',
|
||||
// Through `upload`, not `request`: a multipart body has to set its own boundary,
|
||||
// and the browser only does that when nothing has claimed the header.
|
||||
importNotes: (file) => upload('/notes/import', file),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
/**
|
||||
* What the app shows when it could not reach the server at all.
|
||||
*
|
||||
* Before this, an outage rendered the sign-in screen: the boot asks who you
|
||||
* are, the request never gets an answer, `user` stays null, and null means
|
||||
* "logged out" everywhere else. So the app blamed the session for the network
|
||||
* being down, and offered a password box that could not possibly work. That is
|
||||
* worse than an error message, because it looks like it might work — people
|
||||
* type the password, get nothing, and conclude their account is broken.
|
||||
*
|
||||
* It happened for real on 17 September 2026: the instance's DNS record was
|
||||
* deleted, and what the app showed was a slow-loading login page.
|
||||
*
|
||||
* The text says the data is safe, because that is the question somebody
|
||||
* actually has when their notes vanish behind a login box.
|
||||
*/
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let trying = $state(false);
|
||||
|
||||
async function again() {
|
||||
trying = true;
|
||||
try {
|
||||
await app.load();
|
||||
} finally {
|
||||
trying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<h1>verdande</h1>
|
||||
</div>
|
||||
|
||||
<h2>{t('net.downTitle')}</h2>
|
||||
<p class="lede">{t('net.downBody')}</p>
|
||||
|
||||
<button onclick={again} disabled={trying}>
|
||||
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s4);
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
@@ -133,20 +133,78 @@
|
||||
// `<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);
|
||||
//
|
||||
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
|
||||
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
|
||||
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
|
||||
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
|
||||
const blocks = selectedBlocks();
|
||||
if (!blocks.length) return apply('formatBlock', style.tag);
|
||||
|
||||
if (style.tag === 'pre') {
|
||||
mergeToMono(blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
|
||||
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
|
||||
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
|
||||
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
|
||||
const pres = blocks.filter((b) => b.tagName === 'PRE');
|
||||
for (const b of pres) swapTag(b, style.tag);
|
||||
if (pres.length === blocks.length) {
|
||||
readState();
|
||||
changed();
|
||||
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.
|
||||
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
|
||||
*
|
||||
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
|
||||
* som før.
|
||||
*/
|
||||
function replaceBlock(block, tag) {
|
||||
function selectedBlocks() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount || !editor) return [];
|
||||
const range = selection.getRangeAt(0);
|
||||
return [...editor.children].filter((b) => range.intersectsNode(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lægger flere blokke sammen til én monotypeblok.
|
||||
*
|
||||
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
|
||||
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
|
||||
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
|
||||
* de er.
|
||||
*/
|
||||
function mergeToMono(blocks) {
|
||||
const pre = document.createElement('pre');
|
||||
// blockText og ikke textContent: et <br> har den tomme streng som
|
||||
// textContent, og det er netop <br>, der står for et linjeskift inde i en
|
||||
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
|
||||
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
|
||||
if (!pre.textContent) pre.appendChild(document.createElement('br'));
|
||||
|
||||
blocks[0].replaceWith(pre);
|
||||
for (const b of blocks.slice(1)) b.remove();
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(pre);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
|
||||
function swapTag(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.
|
||||
@@ -156,6 +214,16 @@
|
||||
while (block.firstChild) el.appendChild(block.firstChild);
|
||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||
block.replaceWith(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const el = swapTag(block, tag);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
@@ -380,7 +448,7 @@
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = codeTextOf(pre);
|
||||
const text = blockText(pre);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||
btn.textContent = t('notes.copied');
|
||||
@@ -394,19 +462,16 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
|
||||
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
|
||||
*
|
||||
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
||||
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
||||
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
||||
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
|
||||
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
|
||||
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
|
||||
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
|
||||
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
|
||||
* væk fra den. På skærmen, ikke kun i filen.
|
||||
*/
|
||||
function codeTextOf(pre) {
|
||||
const from = pre.querySelector('code') ?? pre;
|
||||
return [...from.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
@@ -419,7 +484,7 @@
|
||||
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||
// spiste den kode, den var sat der for at kopiere.
|
||||
const code = codeTextOf(pre);
|
||||
const code = blockText(pre);
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
@@ -788,6 +853,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Står markøren i en punktliste eller en nummereret liste?
|
||||
*
|
||||
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
|
||||
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
|
||||
* ikke blive uenige om, hvad der er en liste.
|
||||
*/
|
||||
const inList = () =>
|
||||
document.queryCommandState('insertUnorderedList') ||
|
||||
document.queryCommandState('insertOrderedList');
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
@@ -816,6 +892,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
|
||||
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
|
||||
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
|
||||
if (event.key === 'Tab' && inList()) {
|
||||
event.preventDefault();
|
||||
apply(event.shiftKey ? 'outdent' : 'indent');
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -20,6 +20,31 @@
|
||||
let open = $state(false);
|
||||
let wrap = $state(null);
|
||||
|
||||
/**
|
||||
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
|
||||
*
|
||||
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
|
||||
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
|
||||
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
|
||||
* klippet. Der stod tre en halv linje tekst uden for skærmen.
|
||||
*
|
||||
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
|
||||
* kantafstand til højre for klokken — men så ville den her fil kende sine
|
||||
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
|
||||
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
|
||||
* skærmen med en kant i hver side.
|
||||
*/
|
||||
let below = $state(0);
|
||||
|
||||
function place() {
|
||||
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
if (open) place();
|
||||
}
|
||||
|
||||
const KINDS = {
|
||||
assigned: 'notif.assigned',
|
||||
'note.changed': 'notif.noteChanged',
|
||||
@@ -72,6 +97,7 @@
|
||||
|
||||
<svelte:window
|
||||
onclick={outside}
|
||||
onresize={() => open && place()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && open) open = false;
|
||||
}}
|
||||
@@ -81,7 +107,7 @@
|
||||
<button
|
||||
class="bell"
|
||||
class:on={open}
|
||||
onclick={() => (open = !open)}
|
||||
onclick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={app.unread
|
||||
@@ -101,7 +127,7 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
||||
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
|
||||
<header>
|
||||
<span>{t('notif.title')}</span>
|
||||
{#if app.unread}
|
||||
@@ -193,6 +219,21 @@
|
||||
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
|
||||
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
|
||||
toplinjens egen menuknap, så de to skifter form samtidig. */
|
||||
@media (max-width: 820px) {
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: calc(var(--below) + 6px);
|
||||
left: var(--s3);
|
||||
right: var(--s3);
|
||||
width: auto;
|
||||
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||
|
||||
let text = $state('');
|
||||
@@ -97,6 +98,60 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
/**
|
||||
* En indsat liste bliver til opgaver frem for til én opgave med det hele i.
|
||||
*
|
||||
* Browseren smider linjeskiftene væk, når man indsætter i et `<input>`, så uden
|
||||
* det her blev en kopieret liste til én opgave med punkterne mast sammen. En
|
||||
* enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
|
||||
* som altid.
|
||||
*
|
||||
* Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
|
||||
* opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
|
||||
* stillet mens der stadig er en Fortryd-knap.
|
||||
*/
|
||||
let pending = $state([]);
|
||||
|
||||
function onpaste(event) {
|
||||
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (!pasted.includes('\n')) return;
|
||||
|
||||
const rows = readList(pasted);
|
||||
if (rows.length < 2) return;
|
||||
|
||||
event.preventDefault();
|
||||
pending = rows;
|
||||
}
|
||||
|
||||
async function createPending() {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
|
||||
// Den seneste opgave på hvert niveau, så en indrykket linje havner under den
|
||||
// linje, den stod under — og ikke under den første, den bedste.
|
||||
const parents = [];
|
||||
let made = 0;
|
||||
for (const row of pending) {
|
||||
const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
|
||||
const task = await app.quickAdd(row.text, projectId, undefined, parentId);
|
||||
// Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
|
||||
// resten af listen ind uden den, de hang under, og det er værre end at
|
||||
// standse et sted, man kan se.
|
||||
if (!task) break;
|
||||
parents[row.depth] = task.id;
|
||||
parents.length = row.depth + 1;
|
||||
if (row.done) await app.complete(task.id);
|
||||
onadded?.(task);
|
||||
made++;
|
||||
}
|
||||
|
||||
submitting = false;
|
||||
// Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
|
||||
// den kom og prøve resten igen.
|
||||
pending = pending.slice(made);
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event?.preventDefault();
|
||||
const value = text.trim();
|
||||
@@ -164,13 +219,32 @@
|
||||
aria-label={t('task.new')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{onpaste}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if text.trim()}
|
||||
{#if text.trim() && !pending.length}
|
||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||
{/if}
|
||||
|
||||
{#if pending.length}
|
||||
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
|
||||
<ul>
|
||||
{#each pending as row, i (i)}
|
||||
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<button type="button" class="submit" disabled={submitting} onclick={createPending}>
|
||||
{t('task.pastedAdd', { n: String(pending.length) })}
|
||||
</button>
|
||||
<button type="button" class="cancel" onclick={() => (pending = [])}>
|
||||
{t('task.pastedCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- What the parser understands, spelled out while somebody is typing into it.
|
||||
It was in the placeholder before, which is the one place a hint cannot be:
|
||||
the placeholder disappears at the first keystroke, so the help was gone
|
||||
@@ -190,6 +264,57 @@
|
||||
</form>
|
||||
|
||||
<style>
|
||||
/* Den indsatte liste, før den bliver til opgaver. Ligger i flowet og ikke
|
||||
svævende: den skal kunne klikkes på, og den er noget, man læser færdig frem
|
||||
for et vink, der forsvinder. */
|
||||
.pasted {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: var(--s2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
padding: var(--s2);
|
||||
}
|
||||
|
||||
.pasted ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--s2);
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pasted li {
|
||||
/* Indrykningen er hele pointen med visningen: den viser, hvad der bliver en
|
||||
underopgave, mens der stadig er en vej tilbage. */
|
||||
padding: 2px 0 2px calc(var(--depth) * var(--s4));
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pasted li.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.pasted .actions {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
|
||||
.syntax {
|
||||
/* Nothing to click, and it floats over the first task in the list — which it
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -282,6 +283,73 @@
|
||||
|
||||
// --- sub-tasks ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
|
||||
*
|
||||
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
|
||||
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
|
||||
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
|
||||
* punkter under punkter kommer ind som den stod.
|
||||
*
|
||||
* Som i quick-add vises den, før den oprettes — og af samme grund.
|
||||
*/
|
||||
let pastedSubtasks = $state([]);
|
||||
|
||||
function onPasteSubtasks(event) {
|
||||
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (!pasted.includes('\n')) return;
|
||||
const rows = readList(pasted);
|
||||
if (rows.length < 2) return;
|
||||
event.preventDefault();
|
||||
pastedSubtasks = rows;
|
||||
}
|
||||
|
||||
async function createPastedSubtasks() {
|
||||
if (addingPasted) return;
|
||||
addingPasted = true;
|
||||
|
||||
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
|
||||
// seneste linje ovenfor, som i quick-add.
|
||||
const parents = [task.id];
|
||||
let made = 0;
|
||||
for (const row of pastedSubtasks) {
|
||||
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
|
||||
if (!created) break;
|
||||
parents[row.depth + 1] = created.id;
|
||||
parents.length = row.depth + 2;
|
||||
if (row.done) await app.complete(created.id);
|
||||
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
|
||||
// resten hører under deres egen.
|
||||
if (row.depth === 0) subtasks = [...subtasks, created];
|
||||
made++;
|
||||
}
|
||||
|
||||
addingPasted = false;
|
||||
pastedSubtasks = pastedSubtasks.slice(made);
|
||||
countSubtasks();
|
||||
}
|
||||
|
||||
let addingPasted = $state(false);
|
||||
|
||||
/**
|
||||
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
|
||||
*
|
||||
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
|
||||
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
|
||||
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
|
||||
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
|
||||
*
|
||||
* Samme regel som resten af butikken: en lokal handling slår igennem med det
|
||||
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
|
||||
*/
|
||||
function countSubtasks() {
|
||||
app.upsert({
|
||||
...task,
|
||||
subtask_count: subtasks.length,
|
||||
subtask_done: subtasks.filter((s) => s.completed).length
|
||||
});
|
||||
}
|
||||
|
||||
async function addSubtask(event) {
|
||||
event.preventDefault();
|
||||
const text = newSubtask.trim();
|
||||
@@ -294,6 +362,7 @@
|
||||
parent_id: task.id
|
||||
});
|
||||
subtasks = [...subtasks, created];
|
||||
countSubtasks();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -635,8 +704,31 @@
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addSubtask}>
|
||||
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
|
||||
<input
|
||||
bind:value={newSubtask}
|
||||
onpaste={onPasteSubtasks}
|
||||
placeholder={t('detail.addSubtask')}
|
||||
aria-label={t('detail.newSubtask')}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{#if pastedSubtasks.length}
|
||||
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
|
||||
<ul>
|
||||
{#each pastedSubtasks as row, i (i)}
|
||||
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="pasted-actions">
|
||||
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
|
||||
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
|
||||
</button>
|
||||
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
|
||||
{t('task.pastedCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -741,6 +833,43 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
|
||||
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
|
||||
.pasted {
|
||||
margin-top: var(--s2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s2);
|
||||
}
|
||||
.pasted ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--s2);
|
||||
padding: 0;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pasted li {
|
||||
padding: 2px 0 2px calc(var(--depth) * var(--s4));
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.pasted li.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.pasted-actions {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
}
|
||||
.pasted-actions .plain {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linked-note {
|
||||
display: block;
|
||||
padding: var(--s2);
|
||||
|
||||
@@ -237,6 +237,9 @@ export const da = {
|
||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'net.downTitle': 'Ingen forbindelse',
|
||||
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downRetry': 'Prøv igen',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -260,6 +263,9 @@ export const da = {
|
||||
|
||||
// --- a task in a list ----------------------------------------------------------
|
||||
'task.add': 'Tilføj',
|
||||
'task.pastedTitle': 'Indsat liste',
|
||||
'task.pastedAdd': 'Opret {n} opgaver',
|
||||
'task.pastedCancel': 'Fortryd',
|
||||
'task.new': 'Ny opgave',
|
||||
'task.syntaxProject': 'projekt',
|
||||
'task.syntaxSection': 'sektion',
|
||||
@@ -608,6 +614,8 @@ export const da = {
|
||||
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
|
||||
'data.notesImported': '{n} noter og {files} filer hentet ind.',
|
||||
'data.exportNotes': 'Alle noter som Markdown-filer',
|
||||
'data.exportAllCSV': 'Alle opgaver (CSV)',
|
||||
'data.exportAllICS': 'Alle opgaver (kalender)',
|
||||
'data.exportAccount': 'Hele kontoen som JSON',
|
||||
'data.exportOne': 'Ét projekt',
|
||||
'data.pickProject': 'Vælg et projekt',
|
||||
|
||||
@@ -234,6 +234,9 @@ export const en = {
|
||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'net.downTitle': 'No connection',
|
||||
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downRetry': 'Try again',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -257,6 +260,9 @@ export const en = {
|
||||
|
||||
// --- a task in a list ----------------------------------------------------------
|
||||
'task.add': 'Add',
|
||||
'task.pastedTitle': 'Pasted list',
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.syntaxProject': 'project',
|
||||
'task.syntaxSection': 'section',
|
||||
@@ -604,6 +610,8 @@ export const en = {
|
||||
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
|
||||
'data.notesImported': '{n} notes and {files} files brought in.',
|
||||
'data.exportNotes': 'Every note as Markdown files',
|
||||
'data.exportAllCSV': 'All tasks (CSV)',
|
||||
'data.exportAllICS': 'All tasks (calendar)',
|
||||
'data.exportAccount': 'The whole account as JSON',
|
||||
'data.exportOne': 'One project',
|
||||
'data.pickProject': 'Pick a project',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* En indsat liste, læst som opgaver og underopgaver.
|
||||
*
|
||||
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
|
||||
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
|
||||
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
|
||||
*
|
||||
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
|
||||
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
|
||||
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
|
||||
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
|
||||
* faktisk er set, klarer begge uden at kende tallet på forhånd.
|
||||
*
|
||||
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
|
||||
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
|
||||
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
|
||||
* uløste opgaver, har tabt det, man indsatte den for.
|
||||
*
|
||||
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
|
||||
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
|
||||
* forstå en opgavelinje, ikke to.
|
||||
*/
|
||||
|
||||
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
|
||||
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
|
||||
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
|
||||
const BOX = /^\[([ xX])\]\s*/;
|
||||
|
||||
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
|
||||
function width(indent) {
|
||||
return indent.replace(/\t/g, ' ').length;
|
||||
}
|
||||
|
||||
export function readList(text) {
|
||||
const out = [];
|
||||
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
|
||||
const levels = [];
|
||||
|
||||
for (const raw of (text ?? '').split(/\r?\n/)) {
|
||||
const indent = /^[ \t]*/.exec(raw)[0];
|
||||
let rest = raw.slice(indent.length).trim();
|
||||
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
|
||||
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
|
||||
if (!rest) continue;
|
||||
|
||||
rest = rest.replace(MARKER, '');
|
||||
let done = false;
|
||||
const box = BOX.exec(rest);
|
||||
if (box) {
|
||||
done = box[1] !== ' ';
|
||||
rest = rest.slice(box[0].length);
|
||||
}
|
||||
rest = rest.trim();
|
||||
if (!rest) continue;
|
||||
|
||||
const w = width(indent);
|
||||
while (levels.length && w < levels[levels.length - 1]) levels.pop();
|
||||
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
|
||||
|
||||
out.push({ depth: levels.length - 1, text: rest, done });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
+44
-8
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||
|
||||
/**
|
||||
* Teksten i et blok, med linjeskiftene i behold.
|
||||
*
|
||||
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
|
||||
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
|
||||
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
|
||||
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
|
||||
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
|
||||
* tiden.
|
||||
*
|
||||
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
|
||||
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
|
||||
* gemning.
|
||||
*
|
||||
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
|
||||
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
|
||||
* der allerede har tabt linjeskiftet.
|
||||
*/
|
||||
export function blockText(node) {
|
||||
let out = '';
|
||||
for (const n of node.childNodes) {
|
||||
if (n.nodeType !== Node.ELEMENT_NODE) {
|
||||
out += n.textContent ?? '';
|
||||
continue;
|
||||
}
|
||||
if (n.classList?.contains('copy')) continue;
|
||||
if (n.tagName === 'BR') {
|
||||
out += '\n';
|
||||
continue;
|
||||
}
|
||||
out += blockText(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
emit('```' + lang);
|
||||
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
||||
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
||||
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
||||
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
||||
const code = [...child.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
const code = blockText(child);
|
||||
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
|
||||
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
|
||||
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
|
||||
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
|
||||
if (li.tagName === 'UL' || li.tagName === 'OL') {
|
||||
list(li, depth + 1, emit);
|
||||
continue;
|
||||
}
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
|
||||
@@ -29,6 +29,20 @@ class AppState {
|
||||
people = $state([]);
|
||||
tasks = $state([]);
|
||||
loading = $state(true);
|
||||
|
||||
/**
|
||||
* True when the boot never reached the server — as opposed to reaching it and
|
||||
* being told the session is gone.
|
||||
*
|
||||
* The two are indistinguishable from `user === null`, and they are opposite
|
||||
* things to the person looking at the screen: one means "log ind", the other
|
||||
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
|
||||
* total outage used to show the login screen, so the app blamed the user's
|
||||
* session for the network being down — and a login page that cannot possibly
|
||||
* work is worse than an error, because it looks like it might.
|
||||
*/
|
||||
unreachable = $state(false);
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
@@ -96,6 +110,7 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
this.unreachable = false;
|
||||
// The interface's language follows the account, so it is set the moment
|
||||
// the session is known — before anything has drawn a string.
|
||||
i18n.set(this.user.locale);
|
||||
@@ -111,6 +126,10 @@ class AppState {
|
||||
this.loadNotifications();
|
||||
} catch (e) {
|
||||
this.user = null;
|
||||
// `offline` is the one code the request layer makes up itself: it is what
|
||||
// is thrown when the retry window ran out without any status ever
|
||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||
this.unreachable = e?.code === 'offline';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -505,9 +524,9 @@ class AppState {
|
||||
* request is fast and the box clears immediately, which is where the
|
||||
* responsiveness actually needs to be.
|
||||
*/
|
||||
async quickAdd(text, projectId) {
|
||||
async quickAdd(text, projectId, sectionId, parentId) {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId);
|
||||
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||
this.upsert(task);
|
||||
return task;
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
@@ -129,8 +130,20 @@
|
||||
// Folding the sidebar had a button and nothing else, and a rectangle with a
|
||||
// line down it is not a word: somebody who wanted the menu out of the way
|
||||
// asked for the feature that was already there. ⌘B is where an editor puts
|
||||
// it. Works while typing as well — it moves nothing in the field.
|
||||
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
|
||||
// it.
|
||||
//
|
||||
// Men ikke i en rig editor. Her stod der "works while typing as well — it
|
||||
// moves nothing in the field", og det passer for et <input>, hvor ⌘B ikke
|
||||
// gør noget. I en contenteditable ER ⌘B fed tekst, og `preventDefault()`
|
||||
// nedenfor stoppede browseren i at gøre den — så i noterne foldede
|
||||
// sidebjælken sig i stedet for at gøre ordet fedt. Feltet, markøren står i,
|
||||
// har førsteret til en tast, det selv bruger.
|
||||
const writing = el?.isContentEditable || el?.closest?.('[contenteditable="true"]');
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
(event.key === 'b' || event.key === 'B') &&
|
||||
!writing
|
||||
) {
|
||||
event.preventDefault();
|
||||
sidebar.toggle();
|
||||
return;
|
||||
@@ -166,6 +179,10 @@
|
||||
<div class="booting"></div>
|
||||
{:else if standalone}
|
||||
{@render children()}
|
||||
{:else if app.unreachable}
|
||||
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
|
||||
kodeord hjælper ikke mod en manglende forbindelse. -->
|
||||
<NoConnection />
|
||||
{:else if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
|
||||
@@ -393,6 +393,8 @@
|
||||
do with a Content-Disposition, and the session cookie rides along. -->
|
||||
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||||
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
|
||||
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
|
||||
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
|
||||
</div>
|
||||
|
||||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
||||
@@ -410,6 +412,8 @@
|
||||
<p class="hint">{t('data.importNotesHint')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
|
||||
projekt, og det er en anden opgave end at flytte hele kontoen. -->
|
||||
<div class="field">
|
||||
<label for="export-project">{t('data.exportOne')}</label>
|
||||
<select id="export-project" bind:value={exportProject}>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { afterNavigate, 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';
|
||||
@@ -485,65 +485,94 @@
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning, man kommer med, og en søgning, man kan gå videre med.
|
||||
* En søgning, man kommer med.
|
||||
*
|
||||
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
|
||||
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
|
||||
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
|
||||
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
|
||||
* tilbage til den normale liste."
|
||||
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
|
||||
* træffere frem for den almindelige liste.
|
||||
*
|
||||
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
|
||||
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
|
||||
* deles — og frem for alt ikke lyver, når man har tømt feltet.
|
||||
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
|
||||
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
|
||||
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
|
||||
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
|
||||
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
|
||||
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
|
||||
*
|
||||
* `settled` er det, de to retninger er blevet enige om — og den læses med
|
||||
* `untrack`, hvilket er hele forskellen på at virke og ikke.
|
||||
*
|
||||
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
|
||||
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
|
||||
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
|
||||
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
|
||||
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
|
||||
* på den fælles markør.
|
||||
* En markør, der skal holde to retninger enige, er en markør, der taber
|
||||
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
|
||||
* den eneste, der læser den.
|
||||
*/
|
||||
let settled = $state(null);
|
||||
let cameWith = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||
if (inURL === untrack(() => settled)) return;
|
||||
settled = inURL;
|
||||
if (inURL === untrack(() => cameWith)) return;
|
||||
cameWith = inURL;
|
||||
draftQuery = inURL;
|
||||
query = inURL;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const now = query;
|
||||
if (now === untrack(() => settled)) return;
|
||||
settled = now;
|
||||
const url = new URL(untrack(() => $page.url));
|
||||
if (now) url.searchParams.set('q', now);
|
||||
else url.searchParams.delete('q');
|
||||
replaceState(url, {});
|
||||
});
|
||||
|
||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
||||
// opens it rather than dropping the person on a list to find it again.
|
||||
let asked = $derived($page.url.searchParams.get('note'));
|
||||
$effect(() => {
|
||||
if (!asked || selectedId === asked) return;
|
||||
const found = notes.find((n) => n.id === asked);
|
||||
/**
|
||||
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
|
||||
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
|
||||
* den igen.
|
||||
*
|
||||
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
|
||||
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
|
||||
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
|
||||
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
|
||||
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
|
||||
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
|
||||
* sprang tilbage til den første.
|
||||
*
|
||||
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
|
||||
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
|
||||
* ved hver ændring.
|
||||
*/
|
||||
function openFromURL(url) {
|
||||
const id = url?.searchParams.get('note');
|
||||
if (!id || id === selectedId) return;
|
||||
const found = notes.find((n) => n.id === id);
|
||||
if (found) open(found);
|
||||
else api.note(asked).then(open).catch(() => {});
|
||||
});
|
||||
else api.note(id).then(open).catch(() => {});
|
||||
}
|
||||
|
||||
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
|
||||
// den anden.
|
||||
//
|
||||
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
|
||||
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
|
||||
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
|
||||
// eller fulgt udefra åbnede så ingenting.
|
||||
//
|
||||
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
|
||||
// programmet, monteres siden ikke om.
|
||||
onMount(() => openFromURL($page.url));
|
||||
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
|
||||
|
||||
/**
|
||||
* Hvilken hentning der gælder.
|
||||
*
|
||||
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
|
||||
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
|
||||
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
|
||||
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
|
||||
* ikke gøre det umuligt.
|
||||
*/
|
||||
let newest = 0;
|
||||
|
||||
async function load(q) {
|
||||
const mine = ++newest;
|
||||
try {
|
||||
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||
notes = (await api.notes(params)).notes ?? [];
|
||||
const got = (await api.notes(params)).notes ?? [];
|
||||
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
|
||||
// det er bare ikke længere svaret på det, der blev spurgt om.
|
||||
if (mine !== newest) return;
|
||||
notes = got;
|
||||
status = 'ready';
|
||||
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||
} catch (e) {
|
||||
if (mine !== newest) return;
|
||||
status = 'failed';
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -560,7 +589,17 @@
|
||||
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
||||
* at vente; teksten kommer, når den er der.
|
||||
*/
|
||||
async function open(note) {
|
||||
/**
|
||||
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
|
||||
*
|
||||
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
|
||||
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
|
||||
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
|
||||
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
|
||||
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
|
||||
* skrevet der forsvandt sporløst.
|
||||
*/
|
||||
async function open(note, { whole = false } = {}) {
|
||||
clearTimeout(timer);
|
||||
if (!note) {
|
||||
selectedId = null;
|
||||
@@ -576,9 +615,9 @@
|
||||
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
||||
// sidde med en tom note, der lige havde haft en titel.
|
||||
selectedId = note.id;
|
||||
if (note.body) {
|
||||
if (whole || note.body) {
|
||||
selected = note;
|
||||
draft = note.body;
|
||||
draft = note.body ?? '';
|
||||
return;
|
||||
}
|
||||
selected = null;
|
||||
@@ -690,7 +729,8 @@
|
||||
try {
|
||||
const note = await api.createNote({ body: '' });
|
||||
notes = [note, ...notes];
|
||||
open(note);
|
||||
// Svaret er hele noten, så den skal ikke hentes igen — se open().
|
||||
await open(note, { whole: true });
|
||||
document.querySelector('.editor textarea')?.focus();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
|
||||
Reference in New Issue
Block a user