Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cee9bfb6e | ||
|
|
9b067f7057 | ||
|
|
0c45080dc2 |
@@ -44,6 +44,11 @@ node_modules.nosync/
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||
# af det samme.
|
||||
/web/.playwright-data-notes/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
+11
-1
@@ -53,7 +53,7 @@ paths:
|
||||
/api/v1/auth/setup:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance still needs its first account.
|
||||
summary: What this instance is, and whether it still needs its first account.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -64,6 +64,16 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
needs_setup: { type: boolean }
|
||||
edition:
|
||||
type: string
|
||||
enum: [full, notes]
|
||||
description: >
|
||||
`full` is tasks, projects and notes together. `notes` is
|
||||
the notes half: the task routes are not mounted at all, so
|
||||
an instance that is not a task manager cannot be asked to
|
||||
be one through its API. The interface reads this before
|
||||
anybody is signed in, because it decides what there is to
|
||||
show rather than what this person may see.
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Create the first account, which becomes the administrator.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||
// instans", så den følger med uden en ekstra tur.
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Edition string `json:"edition"`
|
||||
}{n == 0, s.edition()})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "github.com/kristianwind/verdande/internal/config"
|
||||
|
||||
// edition is what this instance tells the world it is.
|
||||
//
|
||||
// Never the empty string, even when the config was built by hand in a test and
|
||||
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||
// full program" from "this build forgot to say", and the first of those is the
|
||||
// answer that has always been true.
|
||||
func (s *Server) edition() string {
|
||||
if s.cfg.NotesOnly() {
|
||||
return config.EditionNotes
|
||||
}
|
||||
return config.EditionFull
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
|
||||
// The interface cannot gate itself on something it is not told, and it has to be
|
||||
// told before anybody is signed in — an instance's shape is not a permission.
|
||||
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||
for _, tc := range []struct{ edition, want string }{
|
||||
{config.EditionFull, "full"},
|
||||
{config.EditionNotes, "notes"},
|
||||
{"", "full"}, // a config built by hand is the program as it has always been
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if got := body["edition"]; got != tc.want {
|
||||
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
// And the field it has always had is still there: adding one must not
|
||||
// quietly replace the answer the sign-in screen depends on.
|
||||
if _, ok := body["needs_setup"]; !ok {
|
||||
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||
// so it is the one door that could still open onto the half the router just shut.
|
||||
//
|
||||
// Not a theoretical case. An instance converted from a full one still has the
|
||||
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want int
|
||||
}{
|
||||
{config.EditionFull, 1},
|
||||
{config.EditionNotes, 0},
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
userID, _ := me["id"].(string)
|
||||
if userID == "" {
|
||||
t.Fatalf("no user: %v", me)
|
||||
}
|
||||
|
||||
// Created through the store rather than the API: in the notes edition there
|
||||
// is no route to create one, and the row this is about is the row that is
|
||||
// already there.
|
||||
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||
found, _ := body["tasks"].([]any)
|
||||
if len(found) != tc.want {
|
||||
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||
}
|
||||
// The full edition's number is the control: a search that finds nothing at
|
||||
// all would satisfy the notes case for the wrong reason.
|
||||
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-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)
|
||||
|
||||
@@ -468,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)
|
||||
|
||||
@@ -583,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
|
||||
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
|
||||
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
|
||||
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
|
||||
// ind til den halvdel, resten af programmet lige har lukket.
|
||||
var tasks []store.Task
|
||||
var err error
|
||||
if !s.cfg.NotesOnly() {
|
||||
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
Search: query,
|
||||
// Completed tasks are included: "what was that thing I did last week" is
|
||||
// a search, and excluding them makes the box feel broken.
|
||||
IncludeCompleted: true,
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Noter-udgaven, kørt som sit eget program.
|
||||
*
|
||||
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
|
||||
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
|
||||
* opsætning lavede, så den laver sin egen.
|
||||
*
|
||||
* Og det er hele grunden til at prøven findes som en separat server frem for som
|
||||
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
|
||||
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
|
||||
* bruger møder det.
|
||||
*/
|
||||
|
||||
const KONTO = {
|
||||
email: 'noter@example.dk',
|
||||
name: 'Noteskriver',
|
||||
password: 'et langt kodeord til test'
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
|
||||
// så den anden gang er det en almindelig login.
|
||||
const opret = page.getByRole('button', { name: 'Opret konto' });
|
||||
const login = page.getByRole('button', { name: 'Log ind' });
|
||||
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
|
||||
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
|
||||
// tredive sekunder på en knap, der aldrig kom.
|
||||
await expect(opret.or(login)).toBeVisible();
|
||||
|
||||
if (await opret.isVisible()) {
|
||||
await page.getByLabel(/Navn/).fill(KONTO.name);
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await opret.click();
|
||||
// Beaconens besked står øverst første gang; den er ikke det her prøvens
|
||||
// ærinde, men den dækker for det, der er.
|
||||
const besked = page.getByRole('region', { name: /melder, at den findes/ });
|
||||
if (await besked.isVisible().catch(() => false)) {
|
||||
await besked.getByRole('button', { name: 'Behold den' }).click();
|
||||
}
|
||||
} else {
|
||||
await page.getByLabel(/E-mail/).fill(KONTO.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
|
||||
await page.getByRole('button', { name: 'Log ind' }).click();
|
||||
}
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
|
||||
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
|
||||
// det at vide, før nogen er logget ind.
|
||||
const svar = await page.request.get('/api/v1/auth/setup');
|
||||
expect((await svar.json()).edition).toBe('notes');
|
||||
});
|
||||
|
||||
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
|
||||
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
|
||||
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
|
||||
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
|
||||
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
|
||||
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
});
|
||||
|
||||
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
|
||||
page
|
||||
}) => {
|
||||
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
|
||||
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
|
||||
// andet program end det, der kører her.
|
||||
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
|
||||
await page.goto(sti);
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await felt.click();
|
||||
await page.keyboard.type('Kvartalsregnskabet');
|
||||
await felt.blur();
|
||||
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
|
||||
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
|
||||
|
||||
// Noten findes — ellers beviser det næste ingenting.
|
||||
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
|
||||
// Og søgningen på serveren svarer uden opgaver overhovedet.
|
||||
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
|
||||
expect((await svar.json()).tasks ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
|
||||
// Det her er forskellen på skjult og fraværende, målt udefra.
|
||||
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
|
||||
const svar = await page.request.get(sti);
|
||||
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
|
||||
}
|
||||
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
|
||||
// ovenstående bestå på en server, der var helt væk.
|
||||
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
|
||||
});
|
||||
+83
-36
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = join(here, '..');
|
||||
const dataDir = join(here, '.playwright-data');
|
||||
const notesDataDir = join(here, '.playwright-data-notes');
|
||||
|
||||
// A clean instance every run: the first test creates the first account, which only
|
||||
// works on an empty database.
|
||||
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
|
||||
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
|
||||
if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(notesDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
|
||||
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
|
||||
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
|
||||
const NOTES_PORT = 8098;
|
||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||
// every browser makes. The passkey test cannot run against a loopback address.
|
||||
@@ -72,6 +78,10 @@ export default defineConfig({
|
||||
{ name: 'setup', testMatch: /.*\.setup\.js/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
testIgnore: /edition\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -95,6 +105,21 @@ export default defineConfig({
|
||||
*
|
||||
* WebKit here is the same engine Safari is, which is the whole point.
|
||||
*/
|
||||
/**
|
||||
* Noter-udgaven, som sit eget program.
|
||||
*
|
||||
* Egen server, egen port, egen database, og ingen `storageState`: den her
|
||||
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
|
||||
* at låne dens cookie ville prøve to instanser som var de én.
|
||||
*
|
||||
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
|
||||
*/
|
||||
{
|
||||
name: 'notes-edition',
|
||||
testMatch: /edition\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -103,42 +128,64 @@ export default defineConfig({
|
||||
}
|
||||
],
|
||||
|
||||
webServer: {
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
webServer: [
|
||||
{
|
||||
// The whole chain, every run.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
// The frontend is embedded in the binary at compile time, from a copy under
|
||||
// cmd/verdande/webbuild — so a suite that merely started the server would
|
||||
// test whatever frontend was copied there last, which during development is
|
||||
// nearly always the wrong one. Testing a stale build is worse than not
|
||||
// testing: it reports green on code that is not running.
|
||||
command: [
|
||||
'npm run build',
|
||||
'rm -rf ../cmd/verdande/webbuild',
|
||||
'cp -r build ../cmd/verdande/webbuild',
|
||||
'cd .. && go run -tags embedweb ./cmd/verdande'
|
||||
].join(' && '),
|
||||
cwd: here,
|
||||
url: `${baseURL}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
// The first run compiles the binary and builds nothing else; a cold Go
|
||||
// build on a CI runner is comfortably under two minutes.
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
},
|
||||
|
||||
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
|
||||
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
|
||||
// det her koster en opstart og ikke en oversættelse.
|
||||
{
|
||||
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
|
||||
cwd: here,
|
||||
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${NOTES_PORT}`,
|
||||
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
|
||||
VERDANDE_DATA_DIR: notesDataDir,
|
||||
VERDANDE_EDITION: 'notes',
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
|
||||
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
|
||||
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
|
||||
// skal ⌘K ikke være stedet, det viser sig.
|
||||
...(app.notesOnly ? [] : tasks).map((t) => ({
|
||||
kind: 'task',
|
||||
id: t.id,
|
||||
label: t.content,
|
||||
|
||||
@@ -155,9 +155,12 @@
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
|
||||
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
|
||||
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
|
||||
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
|
||||
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
!app.notesOnly && app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
@@ -185,7 +188,7 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
|
||||
@@ -43,6 +43,27 @@ class AppState {
|
||||
*/
|
||||
unreachable = $state(false);
|
||||
|
||||
/**
|
||||
* Hvilken udgave af programmet den her instans er.
|
||||
*
|
||||
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
|
||||
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
|
||||
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
|
||||
* det er en knap, der fører et sted hen, hvor der ikke er noget.
|
||||
*
|
||||
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
|
||||
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
|
||||
*
|
||||
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
|
||||
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
|
||||
* program.
|
||||
*/
|
||||
edition = $state('full');
|
||||
|
||||
get notesOnly() {
|
||||
return this.edition === 'notes';
|
||||
}
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
@@ -108,6 +129,17 @@ class AppState {
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
||||
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
||||
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
||||
const shape = api
|
||||
.setupState()
|
||||
.then((state) => {
|
||||
if (state?.edition) this.edition = state.edition;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
try {
|
||||
this.user = await api.me();
|
||||
this.unreachable = false;
|
||||
@@ -131,6 +163,10 @@ class AppState {
|
||||
// arriving. A 401 is the server answering, and that is a real sign-out.
|
||||
this.unreachable = e?.code === 'offline';
|
||||
} finally {
|
||||
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
|
||||
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
|
||||
// en instans, der ikke har nogen.
|
||||
await shape;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,39 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
/**
|
||||
* Opgavernes sider findes ikke i noter-udgaven.
|
||||
*
|
||||
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
|
||||
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
|
||||
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
|
||||
*
|
||||
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
|
||||
* Adressen hører bare til et andet program end det, der kører her — og nogen,
|
||||
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
|
||||
* mening frem for i en blindgyde.
|
||||
*
|
||||
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
|
||||
* væk fra, og derfra tilbage igen.
|
||||
*/
|
||||
const TASK_PAGES = [
|
||||
'/',
|
||||
'/upcoming',
|
||||
'/uddelegeret',
|
||||
'/kalender',
|
||||
'/faerdige',
|
||||
'/opgave',
|
||||
'/filter',
|
||||
'/etiket'
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (!app.notesOnly) return;
|
||||
const here = $page.url.pathname;
|
||||
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
|
||||
if (taskPage) goto('/noter', { replaceState: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user