Compare commits

..
8 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 5e670119db feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik
nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som
"fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser
tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af
teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra
både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte,
den ikke kan holde.

Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren
står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det
under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens
den er åben, som enhver anden fuldførelse.

Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B,
⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i
programmet, hvor det, man skriver, bliver læst for betydning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:36:31 +02:00
Kristian WindandClaude Opus 5 3223989a9d feat(notes): første linje er titlen, resten er brødtekst
Release / Build and publish image (push) Failing after 9m57s
En ny note åbner på sin titel, og linjen efter er brødtekst — uden at man vælger
noget. Det er det, Apple Notes gør, og det er også hvad listen har brug for:
første linje bliver notens navn, så en note, der begynder i brødtekst, hedder sin
egen første sætning.

To ting undervejs. Reserven for en tom note blev aldrig brugt, fordi en tom tekst
ikke gengives som ingenting men som ét tomt afsnit — den prøves nu på teksten og
ikke på resultatet. Og Chromium svarer på retur med et bart <div>, som læses som
brødtekst men ikke er formateret som det: afstanden mellem afsnit forsvandt
stille i det øjeblik, man trykkede retur frem for at vælge Brødtekst i menuen.

Listen viste desuden den rå Markdown i forhåndsvisningen — "# Møde med Anders" og
"**fed**" — hvilket er præcis det, editoren blev bygget for at holde op med at
vise. Den viser nu sætningen frem for notationen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:23:42 +02:00
Kristian WindandClaude Opus 5 f557d319ca feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have
dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om
skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken
rækkefølge man vil have dem i, er en kendsgerning om personen.

En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og
manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at
strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige
forlod menuen.

To ting, prøverne fandt undervejs, og begge var mine:

Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede
modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi
rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det.
Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet
var en menuflytning.

Migration 0019.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:19:46 +02:00
Kristian WindandClaude Opus 5 90bdf4b725 feat(look): en akse mere — hvordan det læses, ikke hvor lyst det er
Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft),
Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud
til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver.

To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal
ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at
vælge en skrift må ikke trække temaet med sig.

Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op
i én skrift og derefter i en anden, og det er et synligt spring.

Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst
genkendelig — det var det, der blev bedt om — men et program med ét muligt
udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en
indstilling blandt flere frem for det eneste, det kan være.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:07:39 +02:00
Kristian WindandClaude Opus 5 1bbae698d5 feat(notes): en note deles ved at ligge i et projekt
Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og
invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i
forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert,
og de to ville være uenige den dag, den ene blev ændret.

To ting, prøven fandt, som begge var rigtige fejl.

Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så
en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags.

Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i
det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser
nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:03:28 +02:00
Kristian WindandClaude Opus 5 1b02eecdab feat(notes): rich text, med Apple Notes' egne stilarter
Man skriver nu i formateret tekst frem for i Markdown-kildekode. Titel,
Overskrift, Underrubrik, Brødtekst, Monotype, Blokcitat, punkt- og talliste, plus
fed, kursiv, understreget og gennemstreget — samme ordforråd som Apple Notes, for
det er det, en, der kommer derfra, allerede kan.

Filen under er stadig Markdown. Det er ikke et kompromis, nogen skal leve med i
stilhed: det er grunden til, at eksporten er til at stole på — filen, en note
bliver til, er den fil, den allerede var. Understregning er den ene undtagelse;
den findes ikke i noget Markdown-dialekt og gemmes som <u>, som enhver læser
sender uændret igennem. Det er bedre end at fjerne en stilart, kilden har.

contenteditable med execCommand. Den er forældet og også det eneste, alle browsere
er enige om; at skrive en markeringsmodel fra bunden er ugers arbejde og en ny
slags fejl netop dér, hvor tabt tekst er utilgiveligt.

Tre ting, prøverne fandt, som alle ville have kostet tekst:
Værktøjslinjen stjal markeringen, når man klikkede — knapperne tager ikke fokus
længere. Browseren skrev <span style="font-weight: normal"> for en ophævet fed,
hvilket en oversætter, der læser tags, taber; der bedes nu om tags, og styles
læses alligevel, fordi det ikke honoreres alle steder. Og markeringen med
Shift+Home tog hele dokumentet, ikke linjen.

Favoritter er stjernen på hver note. Noteoversigten har fået luft mod venstre
kant, og Gem og Slet er knapper frem for bare ord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 16:58:53 +02:00
Kristian WindandClaude Opus 5 5757f06e7a feat(notes): man kan se hvad man skriver
Release / Build and publish image (push) Failing after 10m0s
Formatering vises, mens man skriver. Overskrifter, fed, kursiv, kode, citater,
punkter, #tags og [[links]].

Et spejl bag et gennemsigtigt tekstfelt — samme greb som hurtig tilføjelse bruger
til at vise, hvad parseren forstod. En contenteditable ville lade teksten style
direkte og ville også ødelægge fortryd, autokorrektur og IME. Det er en dårlig
byttehandel i det ene sted i programmet, hvor nogen skriver en halv time i træk.

Mærkerne bliver stående, dæmpede. Obsidian skjuler dem uden for den linje,
markøren står i; at dæmpe dem altid er en mindre idé, der ikke flytter tekst under
markøren mens den bevæger sig — og den overlever mødet med et tekstfelt.

Overskrifter skifter vægt og farve, aldrig størrelse: en større skrift ville fylde
flere linjer end tekstfeltet gør, og så skred alt nedenunder.

Hele risikoen ved grebet er, at de to lag skal flugte tegn for tegn. Prøven måler
det frem for at stole på det: skrifttype, linjehøjde, ombrydning, polstring og den
faktiske højde af de to lag med samme tekst i.

Undervejs: kursiv-passet slugte " og " efter et fed-udtryk, fordi den løse stjerne
stadig var synlig for det. Hvert pas læser nu en kopi, hvor de tidligere pas'
tegn er blændet ud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:38:18 +02:00
Kristian WindandClaude Opus 5 5f04fe56bc feat(notes): note-værktøjer i MCP, og sidebjælken ryddet op
Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple
Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på
maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note
ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til.

Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet
forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den
række, der blev skrevet.

Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de
samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af
programmet, og i den række læste de som endnu et filter.

Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af
papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken
visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved
siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse
af hvad den er til.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:33:54 +02:00
23 changed files with 2113 additions and 106 deletions
+32 -3
View File
@@ -263,6 +263,36 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/nav-order:
put:
tags: [auth]
summary: The order of the fixed views in the sidebar.
description: |
I dag, Kommende, Venter and the inbox, in whatever order this person wants
them. On the account rather than in the browser: which order you want them
in is a fact about you and should be the same on the phone as on the laptop.
A list of keys rather than a number per view. The set of views changes with
the program, and a key that no longer exists costs nothing — the sidebar
draws what it recognises and appends anything new at the end, which is what
makes adding a view later not a migration. An empty list means the order the
program ships with.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
order:
type: array
maxItems: 20
items: { type: string }
example: ["inbox", "today", "upcoming", "delegated"]
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/me:
get:
tags: [auth]
@@ -3278,7 +3308,7 @@ components:
properties:
id: { type: string }
project_id: { type: string }
title: { type: string, description: "Taken from the first line when not given." }
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
body: { type: string, description: Markdown }
pinned: { type: boolean }
created_at: { type: string, format: date-time }
@@ -3296,8 +3326,7 @@ components:
type: object
properties:
project_id: { type: string, description: "Empty files it under no project." }
title: { type: string }
body: { type: string }
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Mailbox:
+30 -1
View File
@@ -32,6 +32,7 @@ type userJSON struct {
// about the work and not about the screen — the sidebar's width is the
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
CreatedAt string `json:"created_at"`
}
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
if collapsed == nil {
collapsed = []string{}
}
order := u.NavOrder
if order == nil {
order = []string{}
}
return userJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// handleSetNavOrder records the order of the fixed views in the sidebar.
//
// Its own route for the same reason as the folds: this writes on every drop, and
// sharing the profile's path would make a reorder send a name and a timezone it
// was never asked to change.
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
Order []string `json:"order"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.Order) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
return
}
user := userFrom(r.Context())
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
s.storeError(w, r, "set nav order", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
+166
View File
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
}, "task_id", "body"),
}, s.mcpAddComment)
s.registerNoteTools(m)
return m
}
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
}
return value
}
// --- notes ---------------------------------------------------------------------
// registerNoteTools adds the note half of the MCP surface.
//
// Separate from the task tools for one reason worth stating: this is how a note
// gets in from somewhere else. Apple Notes has no export worth the name, but a
// model running on the machine can read Notes.app and write what it finds through
// these — one note at a time, with the text as Markdown. That is a migration
// nobody has to build a parser for.
func (s *Server) registerNoteTools(m *mcp.Server) {
m.Register(mcp.Tool{
Name: "search_notes",
Description: "Find notes by text. Matches title and body, and is generous about " +
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
"With no query it returns the most recent notes. This is the tool for " +
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("Words to look for in the title and body."),
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
}),
}, s.mcpSearchNotes)
m.Register(mcp.Tool{
Name: "create_note",
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
"also exactly what an export produces, so nothing is converted and nothing " +
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
"pinned": mcp.Bool("Keep it at the top of the list."),
}),
}, s.mcpCreateNote)
m.Register(mcp.Tool{
Name: "update_note",
Description: "Change a note. Only the fields given are changed. Sending `body` " +
"replaces the whole text, so read the note first if you mean to add to it — " +
"and the links are read again from the new text, so a #tag removed is a link " +
"removed.",
InputSchema: mcp.Schema(map[string]any{
"note_id": mcp.Str("Which note."),
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
"pinned": mcp.Bool("Pin it or unpin it."),
}),
}, s.mcpUpdateNote)
}
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Text string `json:"text"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(args, &params)
limit := params.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
var notes []store.Note
var err error
if strings.TrimSpace(params.Text) == "" {
notes, err = s.db.LooseNotes(ctx, userID)
} else {
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
}
if err != nil {
return nil, err
}
// The same filter the HTTP surface applies, for the same reason: a search must
// not become a way to read what you cannot open.
out := make([]map[string]any, 0, len(notes))
for _, n := range notes {
if !s.mcpMayRead(ctx, &n, userID) {
continue
}
out = append(out, map[string]any{
"id": n.ID, "title": n.Title, "body": n.Body,
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
})
if len(out) >= limit {
break
}
}
return map[string]any{"notes": out, "count": len(out)}, nil
}
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
if n.ProjectID == "" {
return n.CreatedBy == userID
}
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
return err == nil
}
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Body string `json:"body"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n := &store.Note{
CreatedBy: userID,
Body: params.Body,
Pinned: params.Pinned,
}
if params.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
n.ProjectID = params.ProjectID
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
// The links are reported back rather than left silent. A note whose #tag was
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
// the row that was written.
return map[string]any{
"id": n.ID, "title": n.Title, "links": n.Links,
}, nil
}
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
NoteID string `json:"note_id"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n, err := s.db.Note(ctx, params.NoteID)
if err != nil {
return nil, err
}
if n == nil || !s.mcpMayRead(ctx, n, userID) {
return nil, fmt.Errorf("no such note")
}
if n.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
}
if params.Body != nil {
n.Body = *params.Body
}
if params.Pinned != nil {
n.Pinned = *params.Pinned
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
}
+6 -8
View File
@@ -57,7 +57,10 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
return
}
found, err := s.db.LooseNotes(r.Context(), user.ID)
// Everything they can see, not only the loose ones. Filing a note in a project
// is how it gets shared, and a list that dropped it at that moment made sharing
// look like deleting.
found, err := s.db.AllNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list notes", err)
return
@@ -94,9 +97,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, n)
}
// No title. It is the note's first line and is derived on every save — a field
// here would be a promise this server cannot keep.
type noteBody struct {
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
@@ -116,9 +120,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
@@ -161,9 +162,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
+1
View File
@@ -178,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -0,0 +1,13 @@
-- The order of the fixed views in the sidebar.
--
-- On the account rather than in the browser, and for the reason the sidebar's
-- width is the other way round: a width is a fact about the screen you are at, and
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
-- on the laptop as on the phone.
--
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
-- is: the set of views changes with the program, and a key that no longer exists
-- costs nothing — the sidebar draws what it recognises and appends anything new at
-- the end. That is what makes adding a view later not a migration.
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
+26 -5
View File
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
}
n.UpdatedAt = now
// A note with no title takes its first line, the way Apple Notes does. A list
// of untitled notes is a list nobody can read.
if strings.TrimSpace(n.Title) == "" {
n.Title = firstLine(n.Body)
}
// The title is always the first line, the way Apple Notes does it. Not "when it
// is empty": derived once and then left alone, it goes stale the moment somebody
// rewrites the opening of a note, and the list ends up calling a note by a name
// that is nowhere in it. The column is a cache of the body, not a field beside
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
@@ -184,6 +185,26 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
ORDER BY pinned DESC, updated_at DESC`, userID)
}
// AllNotes is everything a person can see: their own loose ones, and the ones in
// projects they are part of.
//
// This is what the Notes page lists. Filing a note in a project is how it gets
// shared, and a list that dropped it at that moment would make sharing look like
// deleting — which is exactly how it read before this existed.
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
//
// The reason note_links exists. Without it this would mean reading every note's
+46 -2
View File
@@ -118,10 +118,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// The title is the first line now, so that is where it goes.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Title: "Grønt regnskab",
Body: "Målt på årsbasis for Århus.",
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
}); err != nil {
t.Fatal(err)
}
@@ -245,3 +245,47 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
t.Errorf("after deleting one, the count is %d, want 1", n)
}
}
// The title follows the first line, always — not only the first time.
//
// Derived once and then left alone, it goes stale the moment somebody rewrites
// the opening, and the list ends up calling a note by a name that is nowhere in
// it. That is exactly what happened: a note headed "Ny note" was listed as
// "fdgfgfgh", which was what its first line had been some edits ago.
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Første udgave" {
t.Fatalf("title started as %q", n.Title)
}
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("after rewriting the first line the title is %q", n.Title)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back.Title != "Ny note" {
t.Errorf("the stored title is %q", back.Title)
}
// And a title cannot be set beside the text, because then the two can disagree.
n.Title = "noget andet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("a title set by hand survived as %q", n.Title)
}
}
+33 -5
View File
@@ -36,8 +36,13 @@ type User struct {
// is as true on the laptop as on the desktop. The sidebar's *width* is the
// opposite case and is stored per browser.
SidebarCollapsed []string
CreatedAt time.Time
UpdatedAt time.Time
// NavOrder is the order this person wants the fixed views in, by key. Empty
// means the order the program ships with. Unknown keys are ignored and missing
// ones are appended, so adding a view later is not a migration.
NavOrder []string
CreatedAt time.Time
UpdatedAt time.Time
}
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var totpEnabled, isAdmin int
var created, updated int64
var collapsed string
var collapsed, navOrder string
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
// worst case is a sidebar that opens fully, which is the state it shipped in.
u.SidebarCollapsed = []string{}
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.NavOrder = []string{}
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
}
// SetNavOrder records the order of the fixed views.
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
if order == nil {
order = []string{}
}
raw, err := json.Marshal(order)
if err != nil {
return err
}
res, err := db.ExecContext(ctx,
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
string(raw), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetSidebarCollapsed records which sidebar headings are folded.
//
// Its own method rather than a field on UpdateProfile: that one backs a form with
+227 -1
View File
@@ -1464,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Færdige' }).click();
// Færdige left the sidebar: it is a rare errand next to four entries read every
// day, and it now sits with the other "where did that go" answers.
await page.goto('/indstillinger/data');
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
@@ -1872,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
await box.fill('første i dag');
await box.press('Enter');
// In the project itself before looking at the sidebar. Without this the count
// can be read while the task is still on its way, or — worse — after it landed
// in the inbox because the box that took the text belonged to the previous
// view. Then the assertion below fails for a reason that has nothing to do with
// what it is testing.
await expect(page.getByText('første', { exact: true })).toBeVisible();
// Reloaded before looking. What is asserted is that the number is open tasks
// and comes from the server, which holds on any load.
//
@@ -1951,3 +1961,219 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({
expect(trouble).toEqual([]);
});
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
await page_.click();
// Typed and formatted the way somebody would: pick a style, write, pick another.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Titel' }).click();
await page.keyboard.type('Møde med Anders');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// The styles are real elements, not asterisks on screen. This is the whole of
// "it should feel like Apple Notes".
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// Bold through the toolbar, on a selection made with the keyboard. Not
// Shift+Home — in a contenteditable that is the start of the document, so it
// took the heading with it and the button toggled bold off instead of on. Not a
// double-click either: it leaves the selection empty here.
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
await page.getByRole('button', { name: 'Fed' }).click();
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
// both are read as bold on the way back. Asserting one of them would be
// asserting which browser this is.
await expect(page_.locator('b, strong')).toBeVisible();
// 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.
await page_.blur();
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Møde med Anders');
await expect(back.locator('b, strong')).toBeVisible();
await expect(back).toContainText('kaffe hver uge');
// No stray Markdown on screen: if the marks are showing, the conversion failed
// in the direction nobody notices until a note looks wrong.
await expect(back).not.toContainText('**');
await expect(back).not.toContainText('# ');
expect(trouble).toEqual([]);
});
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Delenoter');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const face = () =>
page.evaluate(() => getComputedStyle(document.body).fontFamily);
const ground = () =>
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
const startFace = await face();
await page.getByRole('button', { name: /^Rolig/ }).click();
const serif = await face();
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
expect(serif.toLowerCase()).toContain('serif');
// The other axis is untouched: choosing a face must not drag the palette with
// it. That is the whole reason they are two settings and not one list.
const darkGround = await ground();
await page.getByRole('button', { name: /^Papir/ }).click();
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
expect(await face(), 'temaet ændrede skriften').toBe(serif);
// And it is on before the first paint, not applied afterwards — otherwise the
// page is laid out once in one face and again in another.
await page.reload();
expect(await face()).toBe(serif);
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
// Back to the default, which carries no attribute at all.
await page.getByRole('button', { name: /^Verdande/ }).click();
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Straight in, without choosing a style. The first line is the title because
// that is what the list calls the note by.
await page.keyboard.type('Titelprøven');
await expect(ed.locator('h1')).toHaveText('Titelprøven');
await page.keyboard.press('Enter');
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// And the line after is body, not a second title. A note where every line is a
// heading is a note with no heading at all.
await expect(ed.locator('h1')).toHaveCount(1);
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// A third line stays body too.
await page.keyboard.press('Enter');
await page.keyboard.type('Og han ringer selv.');
await expect(ed.locator('h1')).toHaveCount(1);
// It survives the trip through Markdown: the title is a heading again on the
// way back, and the body is not.
await ed.blur();
await page.reload();
await page.getByRole('button', { name: /Titelprøven/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Titelprøven');
await expect(back.locator('h1')).toHaveCount(1);
await expect(back).toContainText('ringer selv');
expect(trouble).toEqual([]);
});
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Første udkast');
await page.keyboard.press('Enter');
// Half a tag is enough. The name is long and nobody should have to spell it.
await page.keyboard.type('Handler om #Forslags');
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
await expect(option).toBeVisible();
// Return takes the highlighted one — the list owns the key while it is open.
await page.keyboard.press('Enter');
await expect(ed).toContainText('#Forslagsprojektet');
await expect(option).toBeHidden();
// And the syntax is spelled out under the field, where it is being typed.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
// The title is the first line. It used to be derived once and then left alone,
// so rewriting the opening left the list calling the note by an old name.
await page.keyboard.press('Enter');
await ed.blur();
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
// Rewrite the first line; the list has to follow.
await ed.click();
await page.keyboard.press('ControlOrMeta+Home');
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.type('Andet udkast');
await ed.blur();
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
expect(trouble).toEqual([]);
});
+69
View File
@@ -444,3 +444,72 @@ button {
white-space: nowrap;
border: 0;
}
/* --- looks -----------------------------------------------------------------
*
* A second axis, beside the colour themes.
*
* A theme answers "how bright"; a look answers "what does it feel like to read".
* Typeface, how tight the rows sit, how round the corners are. They are separate
* because they are separate questions: somebody who wants a serif face at night
* should not have to give up the dark palette to get it.
*
* It is also the honest answer to looking like something else. The default is
* deliberately familiar — that is what was asked for — but a program with one
* possible appearance has borrowed a shape it cannot give back. With looks, the
* resemblance is one setting among several rather than the only thing it can be.
*/
/* Serif, roomier, softer. For reading and writing rather than scanning — which is
what a program with notes in it is half the time. */
[data-look='rolig'] {
--font: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
--text-base: 1.0625rem;
--text-sm: 0.9375rem;
--radius: 10px;
--radius-lg: 16px;
--s2: 0.5625rem;
--s3: 0.875rem;
--s4: 1.25rem;
}
[data-look='rolig'] body {
line-height: 1.65;
letter-spacing: 0.006em;
}
/* Tighter. More rows on a screen, squarer corners, less air — for a long list
read at a glance rather than a page read a paragraph at a time. */
[data-look='taet'] {
--text-base: 0.875rem;
--text-sm: 0.78125rem;
--text-xs: 0.6875rem;
--radius: 5px;
--radius-lg: 8px;
--radius-sm: 3px;
--s1: 0.1875rem;
--s2: 0.375rem;
--s3: 0.5625rem;
--s4: 0.75rem;
}
[data-look='taet'] body {
line-height: 1.4;
}
/* One face for everything, including the prose. Not a joke setting: a monospaced
list of tasks lines up in a way a proportional one never does, and some people
read code all day and want the same shapes here. */
[data-look='terminal'] {
--font: var(--font-mono);
--text-base: 0.875rem;
--text-sm: 0.8125rem;
--radius: 3px;
--radius-lg: 4px;
--radius-sm: 2px;
}
[data-look='terminal'] body {
line-height: 1.5;
letter-spacing: -0.01em;
}
+6
View File
@@ -22,6 +22,12 @@
stored ??
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = theme;
// The look changes the typeface and the spacing, so it has to be on
// before first paint too — otherwise the page is laid out once in one
// face and again in another, which is a visible jump.
const look = localStorage.getItem('verdande:look');
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
} catch (e) {
// Private browsing can refuse localStorage; the dark default stands.
}
+1
View File
@@ -188,6 +188,7 @@ export const api = {
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setNavOrder: (order) => put('/auth/nav-order', { order }),
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
+585
View File
@@ -0,0 +1,585 @@
<script>
/**
* The note itself.
*
* Rich text, not Markdown source: somebody who writes in Apple Notes presses
* ⌘B and expects the word to go bold, not to grow asterisks. The file underneath
* is still Markdown — see $lib/richtext.js for the seam — so nothing about the
* export changes.
*
* contenteditable with execCommand. It is deprecated and it is also the only
* thing every browser agrees on; writing a selection model from scratch is weeks
* of work and a new class of bug in the one place where losing text is
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
let { note, onchange, onsave } = $props();
let editor;
let stylesOpen = $state(false);
// Which marks are on where the caret is, so the toolbar shows state rather than
// only offering actions.
let active = $state({});
// Loaded when the note changes, never while it is being typed in: writing to
// innerHTML puts the caret back at the start, which mid-sentence is unusable.
let loadedId = $state(null);
$effect(() => {
if (!editor || !note || note.id === loadedId) return;
// An empty note opens on its title. That is what Apple Notes does, and it is
// also what the list needs: the first line becomes the name of the note, so a
// note whose first line is body text is a note called by its first sentence.
//
// Tested on the text and not on the rendered result: an empty body renders as
// one empty paragraph, not as nothing, so a falsy check here never fired.
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
loadedId = note.id;
active = {};
});
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
const STYLES = [
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
{ key: 'subheading', tag: 'h3', label: 'notes.styleSubheading' },
{ key: 'body', tag: 'p', label: 'notes.styleBody' },
{ key: 'mono', tag: 'pre', label: 'notes.styleMono' },
{ key: 'quote', tag: 'blockquote', label: 'notes.styleQuote' }
];
function apply(command, value) {
editor?.focus();
// Tags, not inline styles. Left to itself the browser writes
// <span style="font-weight: normal"> for an un-bold, which is invisible to a
// converter that reads tags — so the change is lost on the way to Markdown
// and comes back bold. Asking for tags keeps the document in a shape that
// can be written down.
try {
document.execCommand('styleWithCSS', false, false);
} catch {
// Not everywhere. The formats below still work; some of them just produce
// spans, which is why the converter reads styles as well as tags.
}
document.execCommand(command, false, value);
readState();
changed();
}
function setStyle(style) {
stylesOpen = false;
if (style.key === 'bullet') return apply('insertUnorderedList');
if (style.key === 'numbered') return apply('insertOrderedList');
apply('formatBlock', style.tag);
}
/** What the caret is standing in, for the toolbar's pressed states. */
function readState() {
if (!editor) return;
try {
active = {
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strike: document.queryCommandState('strikeThrough'),
bullet: document.queryCommandState('insertUnorderedList'),
numbered: document.queryCommandState('insertOrderedList')
};
} catch {
// Some browsers throw on queryCommandState with no selection. The toolbar
// showing nothing is a smaller problem than the editor throwing.
active = {};
}
}
function changed() {
if (!editor) return;
onchange?.(htmlToMarkdown(editor));
readSuggestions();
}
// --- suggesting a project ------------------------------------------------------
let suggestions = $state([]);
let chosen = $state(0);
/**
* The word being typed after a #, if that is what is happening.
*
* Read from the text node the caret is in rather than from the whole document:
* a note can mention twenty projects, and only the one under the cursor is being
* written.
*/
function partialTag() {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
const before = node.textContent.slice(0, selection.anchorOffset);
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (!match) return null;
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
}
function readSuggestions() {
const partial = partialTag();
if (!partial) {
suggestions = [];
return;
}
const term = partial.term.toLowerCase();
suggestions = app.projects
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
.slice(0, 6);
chosen = 0;
}
/** Replaces the half-typed tag with the whole name. */
function accept(project) {
const partial = partialTag();
if (!partial) return;
const range = document.createRange();
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, because the next thing typed is a word and not more of
// the tag — and without it the suggestions come straight back.
document.execCommand('insertText', false, project.name + ' ');
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
/** The block the caret is standing in. */
function currentBlock() {
const selection = window.getSelection();
if (!selection || !selection.anchorNode || !editor) return null;
let node = selection.anchorNode;
while (node && node.parentNode !== editor) node = node.parentNode;
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
}
/**
* Title for the first line, body for everything after it.
*
* Run after the browser has split the block on Enter, because until then there
* is no second block to change. Pressing return at the end of a heading leaves
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
}
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.
if (suggestions.length) {
if (event.key === 'ArrowDown') {
event.preventDefault();
chosen = (chosen + 1) % suggestions.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
accept(suggestions[chosen]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
suggestions = [];
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
// After the split, not before it.
setTimeout(() => {
keepTitleFirst();
changed();
}, 0);
return;
}
// The shortcuts people already have in their fingers. execCommand handles
// bold and italic itself; underline and strikethrough are claimed here so
// they behave the same way.
if (!(event.metaKey || event.ctrlKey)) return;
const key = event.key.toLowerCase();
if (key === 'u') {
event.preventDefault();
apply('underline');
} else if (key === 'x' && event.shiftKey) {
event.preventDefault();
apply('strikeThrough');
}
}
// Pasted text arrives as whatever the source was — a web page brings its fonts,
// its colours and its layout with it. Only the words are wanted.
function onpaste(event) {
event.preventDefault();
const text = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, text);
changed();
}
</script>
<div class="wrap">
<!-- Nothing in here may take focus. A click that moves focus out of the editor
collapses the selection first, so the format lands on nothing — the button
appears to do nothing at all, which is how it read the first time. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="toolbar" onmousedown={(e) => e.preventDefault()}>
<div class="styles">
<button
class="aa"
onclick={() => (stylesOpen = !stylesOpen)}
aria-expanded={stylesOpen}
aria-label={t('notes.format')}
title={t('notes.format')}>Aa</button
>
{#if stylesOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="menu" role="menu">
{#each STYLES as style}
<button class={style.key} role="menuitem" onclick={() => setStyle(style)}>
{t(style.label)}
</button>
{/each}
<hr />
<button
class:on={active.bullet}
role="menuitem"
onclick={() => setStyle({ key: 'bullet' })}>• {t('notes.styleBullet')}</button
>
<button
class:on={active.numbered}
role="menuitem"
onclick={() => setStyle({ key: 'numbered' })}>1. {t('notes.styleNumbered')}</button
>
</div>
{/if}
</div>
<span class="sep" aria-hidden="true"></span>
<button class:on={active.bold} onclick={() => apply('bold')} title="⌘B" aria-label={t('notes.bold')}>
<strong>B</strong>
</button>
<button class:on={active.italic} onclick={() => apply('italic')} title="⌘I" aria-label={t('notes.italic')}>
<em>I</em>
</button>
<button class:on={active.underline} onclick={() => apply('underline')} title="⌘U" aria-label={t('notes.underline')}>
<u>U</u>
</button>
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
<s>S</s>
</button>
</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="page"
bind:this={editor}
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label={t('notes.body')}
spellcheck="true"
oninput={changed}
onblur={onsave}
onkeyup={() => {
readState();
readSuggestions();
}}
onmouseup={readState}
{onkeydown}
{onpaste}
></div>
{#if suggestions.length}
<!-- Anchored to the editor rather than to the caret. Following the caret means
measuring it, and a list that jumps a few pixels as you type is worse than
one that sits still. -->
<ul class="suggestions" role="listbox">
{#each suggestions as project, i (project.id)}
<li>
<button
class:on={i === chosen}
role="option"
aria-selected={i === chosen}
onmousedown={(e) => {
e.preventDefault();
accept(project);
}}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
#{project.name}
</button>
</li>
{/each}
</ul>
{/if}
<!-- What the text understands, said where it is being typed. The same idea as
the line under the quick-add box: a note is the other place in this program
where what you type is read for meaning. -->
<p class="legend">
{#each SYNTAX as item}
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
{/each}
</p>
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--s1);
padding: 0 0 var(--s2);
border-bottom: 1px solid var(--line);
}
.toolbar button {
min-width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
.toolbar button:hover {
background: var(--surface);
color: var(--ink);
}
.toolbar button.on {
background: var(--surface-raised);
color: var(--ink);
}
.sep {
width: 1px;
height: 18px;
background: var(--line);
margin: 0 var(--s1);
}
.styles {
position: relative;
}
.aa {
font-weight: 560;
}
.menu {
position: absolute;
top: calc(100% + var(--s1));
left: 0;
z-index: 20;
min-width: 210px;
padding: var(--s1);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
display: flex;
flex-direction: column;
}
.menu button {
width: 100%;
text-align: left;
padding: var(--s1) var(--s2);
height: auto;
min-height: 30px;
}
.menu hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--s1) 0;
}
/* The menu shows each style in the style it applies, the way Apple Notes does.
Reading the word "Overskrift" set as one is faster than reading a label. */
.menu .title {
font-size: 1.35rem;
font-weight: 700;
color: var(--ink);
}
.menu .heading {
font-size: 1.1rem;
font-weight: 640;
color: var(--ink);
}
.menu .subheading {
font-weight: 620;
color: var(--ink);
}
.menu .mono {
font-family: var(--mono, ui-monospace, monospace);
}
.menu .quote {
border-left: 2px solid var(--line-strong);
padding-left: var(--s2);
color: var(--ink-muted);
}
.suggestions {
position: absolute;
z-index: 30;
list-style: none;
margin: 0;
padding: var(--s1);
min-width: 200px;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
}
.suggestions button {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
text-align: left;
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
.suggestions button.on {
background: var(--surface);
color: var(--ink);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--s1) var(--s3);
padding-top: var(--s2);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.legend kbd {
font-family: var(--mono, ui-monospace, monospace);
color: var(--ink-muted);
}
.wrap {
position: relative;
}
.page {
flex: 1;
min-height: 50vh;
padding: var(--s3) 0;
outline: none;
line-height: 1.55;
overflow-y: auto;
}
/* The document styles. These are the whole of "it should look like Apple Notes":
a title that is a title, and body text that is comfortable to read for a page
at a time. */
.page :global(h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 var(--s2);
line-height: 1.25;
}
.page :global(h2) {
font-size: 1.2rem;
font-weight: 640;
margin: var(--s3) 0 var(--s1);
}
.page :global(h3) {
font-size: 1rem;
font-weight: 620;
margin: var(--s3) 0 var(--s1);
}
.page :global(p) {
margin: 0 0 var(--s2);
}
.page :global(ul),
.page :global(ol) {
margin: 0 0 var(--s2);
padding-left: var(--s4);
}
.page :global(li) {
margin-bottom: var(--s1);
}
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
border-left: 2px solid var(--line-strong);
color: var(--ink-muted);
}
.page :global(pre) {
margin: 0 0 var(--s2);
font-family: var(--mono, ui-monospace, monospace);
white-space: pre-wrap;
}
.page :global(code) {
font-family: var(--mono, ui-monospace, monospace);
color: var(--accent);
}
</style>
+200 -48
View File
@@ -117,6 +117,70 @@
}
// --- the fixed views ---------------------------------------------------------
// Delegated is only offered where delegating is possible at all: on an instance
// with one person that view can never have anything in it, and a permanent empty
// 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)
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
: null,
// No href: the inbox is a project and is drawn by projectRow, which knows
// its id. It is in this list only so it can be dragged with the others.
{ key: 'inbox', label: 'nav.inbox' }
].filter(Boolean)
);
let orderedNav = $derived(
app
.navOrder(navItems.map((i) => i.key))
.map((key) => navItems.find((i) => i.key === key))
.filter(Boolean)
);
let draggingNav = $state(null);
let overNav = $state(null);
function onNavDragStart(event, key) {
draggingNav = key;
event.dataTransfer.effectAllowed = 'move';
// A payload, because Firefox refuses to start a drag without one.
event.dataTransfer.setData('text/plain', key);
}
function onNavDragOver(event, key) {
if (!draggingNav || key === draggingNav) return;
event.preventDefault();
overNav = key;
}
function clearNavDrag() {
draggingNav = null;
overNav = null;
}
async function onNavDrop(event, key) {
const moved = draggingNav;
// Checked before anything is swallowed. Stopping the event first meant a task
// dragged onto I dag never reached the handler that gives it a date — the row
// took the drop and then discovered it was not for it.
if (!moved || moved === key) {
clearNavDrag();
return;
}
event.preventDefault();
event.stopPropagation();
clearNavDrag();
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
keys.splice(keys.indexOf(key), 0, moved);
await app.setNavOrder(keys);
}
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
@@ -340,56 +404,71 @@
<div class="views">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/"
class:active={current === '/'}
class:receiving={overToday}
onclick={onnavigate}
ondragover={(e) => {
if (!carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span>
{t('nav.today')}
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<!-- The fixed views, in the order this person put them.
Drawn from a list rather than written out, because they can be reordered
now and a hand-written run cannot be. Unknown keys are dropped and new
ones appended, so adding a view later does not strand anybody's order. -->
{#each orderedNav as item (item.key)}
{#if item.key === 'inbox'}
{#if app.inbox}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
{@render projectRow(app.inbox, false)}
</div>
{/if}
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
<!-- I dag also takes a task dropped on it — the most-made rescheduling
there is, and the sidebar is where the pointer already is. The
handlers were lost when this run became a list; the test caught
it, which is the second time that drop has earned its test. -->
<a
href={item.href}
class:active={current === item.href}
class:receiving={item.key === 'today' && overToday}
onclick={onnavigate}
ondragover={(e) => {
if (item.key !== 'today' || !carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
>
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
{t(item.label)}
</a>
</div>
{/if}
{/each}
</div>
<!-- Notes stand apart, with a rule above them.
They are not a fourth way of looking at the same tasks, which is what
everything above is; they are the other half of the program. Sitting in that
run made them read as one more filter. -->
<div class="apart">
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.upcoming')}
{t('notes.title')}
</a>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
person this view can never have anything in it, and a permanent empty
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.delegated')}
</a>
{/if}
<!-- After the three views of what is left, because it is the one that is not
about what is left. It earns a permanent place rather than a toggle in
each view: somebody looking for a task they closed by mistake does not
know which view they closed it in. -->
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('done.title')}
</a>
{#if app.inbox}
{@render projectRow(app.inbox, false)}
<!-- Notes sit with the fixed places rather than among the projects: it is a
place you go, not a thing filed under one. -->
<a href="/noter" class="fixed" onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
{/if}
</div>
<!-- One row for every project the sidebar shows — your own, the Inbox and the
@@ -397,6 +476,21 @@
three copies of that would be three chances to fix a bug in two of them.
Only your own reorder: `sort_order` is a column on the project, so a shared
one stays where its owner put it. -->
<!-- A handle rather than the whole row.
Making the row draggable made everything inside it draggable too, and the
sidebar is already a drop target for tasks — a task dragged onto I dag
stopped landing, because the row had claimed the gesture. A grip claims
nothing until it is grabbed. -->
{#snippet grip(key)}
<span
class="grip"
draggable="true"
ondragstart={(e) => onNavDragStart(e, key)}
ondragend={clearNavDrag}
aria-hidden="true">⠿</span
>
{/snippet}
{#snippet projectRow(project, sortable)}
<a
href="/projekt/{project.id}"
@@ -873,6 +967,64 @@
gap: 1px;
}
.navrow {
position: relative;
display: flex;
align-items: center;
}
/* Takes no room until it is wanted: it sits in the padding the row already has,
so nothing moves when it appears. */
.grip {
position: absolute;
left: -2px;
width: 12px;
text-align: center;
font-size: 10px;
line-height: 1;
color: var(--ink-faint);
cursor: grab;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.navrow:hover .grip {
opacity: 1;
}
.navrow > :global(a) {
flex: 1;
min-width: 0;
}
.navrow.dragging {
opacity: 0.4;
}
/* A line where it will land, not a box around what it will land on: the first
reads as a position and the second as a target. */
.navrow.over::before {
content: '';
position: absolute;
top: -1px;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
border-radius: 1px;
}
/* A rule and a little air, which is the whole of "separate". Notes are the other
half of the program, not a fifth way of looking at the same tasks. */
.apart {
display: flex;
flex-direction: column;
gap: 1px;
margin: var(--s3) 0;
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.group-head {
display: flex;
align-items: center;
+34
View File
@@ -16,6 +16,28 @@
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Del i',
'notes.private': 'Kun mig',
'notes.shared': 'Noten er delt med projektets folk.',
'notes.unshared': 'Noten er din igen.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
'notes.bold': 'Fed',
'notes.italic': 'Kursiv',
'notes.underline': 'Understreget',
'notes.strike': 'Gennemstreget',
'notes.styleTitle': 'Titel',
'notes.styleHeading': 'Overskrift',
'notes.styleSubheading': 'Underrubrik',
'notes.styleBody': 'Brødtekst',
'notes.styleMono': 'Monotype',
'notes.styleQuote': 'Blokcitat',
'notes.styleBullet': 'Punktliste',
'notes.styleNumbered': 'Nummereret liste',
'notes.favourite': 'Gør til favorit',
'notes.unfavourite': 'Fjern som favorit',
'notes.syntaxProject': 'projekt',
'notes.syntaxNote': 'en anden note',
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
@@ -124,6 +146,16 @@ export const da = {
'account.appearance': 'Udseende',
'account.appearanceHint':
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
'account.look': 'Udseende',
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
'look.verdande': 'Verdande',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
'look.taet': 'Tæt',
'look.taetNote': 'Flere rækker på skærmen, mindre luft.',
'look.terminal': 'Terminal',
'look.terminalNote': 'Én bredde til det hele. Alt flugter.',
'account.password': 'Adgangskode',
'account.passwordHint':
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
@@ -318,6 +350,8 @@ export const da = {
'data.csvFile': 'CSV-fil',
'data.importing': 'Importerer …',
'data.openProject': 'Åbn projektet',
'data.doneHint': 'Alt du har lukket, nyeste først. Lukkede du noget ved en fejl, ligger det her — og kan åbnes igen.',
'data.openDone': 'Åbn færdige opgaver',
'data.trash': 'Papirkurv',
'data.trashEmpty': 'Papirkurven er tom.',
'data.restore': 'Hent tilbage',
+34
View File
@@ -12,6 +12,28 @@
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'notes.shareWith': 'Share in',
'notes.private': 'Only me',
'notes.shared': "The note is shared with the project's people.",
'notes.unshared': 'The note is yours again.',
'notes.save': 'Save',
'notes.format': 'Format',
'notes.bold': 'Bold',
'notes.italic': 'Italic',
'notes.underline': 'Underline',
'notes.strike': 'Strikethrough',
'notes.styleTitle': 'Title',
'notes.styleHeading': 'Heading',
'notes.styleSubheading': 'Subheading',
'notes.styleBody': 'Body',
'notes.styleMono': 'Monospaced',
'notes.styleQuote': 'Block quote',
'notes.styleBullet': 'Bulleted list',
'notes.styleNumbered': 'Numbered list',
'notes.favourite': 'Make a favourite',
'notes.unfavourite': 'Remove from favourites',
'notes.syntaxProject': 'project',
'notes.syntaxNote': 'another note',
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
@@ -120,6 +142,16 @@ export const en = {
'account.appearance': 'Appearance',
'account.appearanceHint':
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
'account.look': 'Appearance',
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
'look.verdande': 'Verdande',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
'look.taet': 'Tight',
'look.taetNote': 'More rows on screen, less air.',
'look.terminal': 'Terminal',
'look.terminalNote': 'One width for everything. It all lines up.',
'account.password': 'Password',
'account.passwordHint':
'Changing it signs every other session out — which is usually the reason to do it.',
@@ -313,6 +345,8 @@ export const en = {
'data.csvFile': 'CSV file',
'data.importing': 'Importing …',
'data.openProject': 'Open the project',
'data.doneHint': 'Everything you have closed, newest first. Closed something by mistake and it is here — and can be opened again.',
'data.openDone': 'Open finished tasks',
'data.trash': 'Trash',
'data.trashEmpty': 'The trash is empty.',
'data.restore': 'Restore',
+193
View File
@@ -0,0 +1,193 @@
/**
* Between what you see and what is stored.
*
* The editor is rich text; the note on disk is Markdown. That is not a compromise
* anybody has to live with silently — it is the reason the export is trustworthy:
* the file a note becomes is the file it already was. So these two functions are
* the seam, and they have to agree with each other exactly, or a note changes
* shape a little every time it is opened and saved.
*
* The set of styles is deliberately small and matches what Apple Notes offers,
* because that is the vocabulary somebody coming from there already has:
*
* Titel → #
* Overskrift → ##
* Underrubrik → ###
* Brødtekst → nothing
* Monotype → four spaces
* Punktliste → -
* Nummereret → 1.
* Blokcitat → >
*
* Bold, italic and strikethrough have Markdown of their own. Underline does not —
* there is no such thing in any Markdown dialect — so it is stored as inline HTML,
* which every Markdown reader passes through untouched. It is the one place where
* the file is not pure Markdown, and it is that rather than dropping a style the
* source program has.
*/
const BLOCKS = [
{ style: 'title', tag: 'h1', prefix: '# ' },
{ style: 'heading', tag: 'h2', prefix: '## ' },
{ style: 'subheading', tag: 'h3', prefix: '### ' },
{ style: 'quote', tag: 'blockquote', prefix: '> ' },
{ style: 'mono', tag: 'pre', prefix: ' ' }
];
const escapeHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
return escapeHtml(text)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
}
/**
* markdownToHtml renders a note for the editor.
*
* Every line becomes exactly one block. Markdown would normally join consecutive
* lines into one paragraph; here it must not, because the person typed a line and
* expects to get it back — an editor that silently reflows what somebody wrote is
* an editor they stop trusting.
*/
export function markdownToHtml(markdown) {
const lines = (markdown ?? '').split('\n');
const out = [];
let list = null;
const closeList = () => {
if (list) {
out.push(`</${list}>`);
list = null;
}
};
for (const line of lines) {
const bullet = /^[-*+]\s+(.*)$/.exec(line);
const numbered = /^\d+\.\s+(.*)$/.exec(line);
if (bullet || numbered) {
const want = bullet ? 'ul' : 'ol';
if (list !== want) {
closeList();
out.push(`<${want}>`);
list = want;
}
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
continue;
}
closeList();
const block = BLOCKS.find((b) =>
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
);
if (block) {
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}</${block.tag}>`);
continue;
}
// An empty line is still a line. Without the break the browser collapses the
// block to nothing and the caret has nowhere to stand.
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
}
closeList();
return out.join('');
}
/** The inline marks, read back off the DOM. */
function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
case 'EM':
case 'I':
return inner ? `*${inner}*` : '';
case 'S':
case 'STRIKE':
case 'DEL':
return inner ? `~~${inner}~~` : '';
case 'U':
return inner ? `<u>${inner}</u>` : '';
case 'CODE':
return inner ? `\`${inner}\`` : '';
case 'SPAN': {
// The browser's own way of saying bold or italic when it is writing styles
// rather than tags. Read as well as tags, because `styleWithCSS` is not
// honoured everywhere and a note must not lose a format on the way out.
const style = node.getAttribute('style') ?? '';
if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : '';
if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : '';
if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : '';
if (/text-decoration[^;]*underline/.test(style)) return inner ? `<u>${inner}</u>` : '';
return inner;
}
case 'BR':
return '';
default:
return inner;
}
}
/**
* htmlToMarkdown reads the editor back.
*
* Written against the DOM rather than against a string of HTML, because that is
* what contenteditable actually produces — and what it produces is not tidy. The
* browser will nest, split and re-wrap as somebody types, and a regular expression
* over that is a guess.
*/
export function htmlToMarkdown(root) {
const lines = [];
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (child.textContent.trim()) lines.push(child.textContent);
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
switch (child.tagName) {
case 'UL':
case 'OL': {
let n = 1;
for (const li of child.children) {
const text = inlineToMarkdown(li).trim();
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
}
break;
}
case 'DIV':
// The browser's own container when nothing else applies. Read through
// it rather than treating it as a block, or every paste adds a level.
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
walk(child);
} else {
lines.push(inlineToMarkdown(child));
}
break;
default: {
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
const text = inlineToMarkdown(child);
lines.push(block ? block.prefix + text : text);
}
}
}
};
walk(root);
// Trailing blank lines are what an editor accumulates from people pressing
// return at the end; they are not content and they grow every time.
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
return lines.join('\n');
}
+64
View File
@@ -562,6 +562,31 @@ class AppState {
}
}
/**
* The order of the fixed views, as keys.
*
* Unknown keys are dropped and missing ones appended, so the stored order never
* has to be migrated when a view is added or taken away — which has already
* happened once, when Færdige left the sidebar.
*/
navOrder(keys) {
const saved = this.user?.nav_order ?? [];
const known = new Set(keys);
const ordered = saved.filter((k) => known.has(k));
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
}
async setNavOrder(order) {
const before = this.user?.nav_order ?? [];
this.user = { ...this.user, nav_order: order };
try {
await api.setNavOrder(order);
} catch (e) {
this.user = { ...this.user, nav_order: before };
this.toast(humanMessage(e));
}
}
sectionCollapsed(key) {
return (this.user?.sidebar_collapsed ?? []).includes(key);
}
@@ -844,6 +869,45 @@ export const THEMES = [
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
];
/**
* The looks: how it reads, as opposed to how bright it is.
*
* Separate from the themes because they are separate questions. Somebody who
* wants a serif face at night should not have to give up the dark palette to get
* one, and the two multiply out to fifteen appearances from eight small blocks of
* CSS rather than needing one block each.
*/
export const LOOKS = [
{ id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' },
{ id: 'rolig', name: 'look.rolig', note: 'look.roligNote' },
{ id: 'taet', name: 'look.taet', note: 'look.taetNote' },
{ id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' }
];
class Look {
current = $state(
typeof document === 'undefined'
? 'verdande'
: (document.documentElement.dataset.look ?? 'verdande')
);
set(id) {
if (!LOOKS.some((l) => l.id === id)) return;
this.current = id;
// The default carries no attribute, so the plain tokens are the plain tokens
// and there is nothing to override them back to.
if (id === 'verdande') delete document.documentElement.dataset.look;
else document.documentElement.dataset.look = id;
try {
localStorage.setItem('verdande:look', id);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const look = new Look();
/** Theme, kept in localStorage and applied to the document element. */
class Theme {
/**
+64 -1
View File
@@ -1,7 +1,7 @@
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES } from '$lib/stores.svelte.js';
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
import { ago } from '$lib/when.js';
@@ -330,6 +330,29 @@
</button>
{/each}
</div>
<!-- The second axis. A theme says how bright, a look says how it reads — and
they are separate settings because they are separate questions. -->
<div class="looks">
<h3>{t('account.look')}</h3>
<p class="hint">{t('account.lookHint')}</p>
<div class="look-row">
{#each LOOKS as option (option.id)}
<button
class="look-card"
class:chosen={look.current === option.id}
onclick={() => look.set(option.id)}
aria-pressed={look.current === option.id}
>
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
</div>
</div>
</section>
<section class="panel">
@@ -528,6 +551,46 @@
</section>
<style>
.looks {
margin-top: var(--s4);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.looks h3 {
font-size: var(--text-sm);
margin-bottom: var(--s1);
}
.look-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--s2);
margin-top: var(--s3);
}
.look-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s1);
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
text-align: left;
}
.look-card.chosen {
border-color: var(--accent);
}
.look-sample {
font-family: var(--font);
font-size: 1.5rem;
line-height: 1;
color: var(--ink);
}
form {
display: flex;
flex-direction: column;
@@ -225,6 +225,19 @@
{/if}
</section>
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
to the four that are read every day, and it belongs with the other "where did
that go" questions rather than above them. -->
<section class="panel">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
</header>
<div class="row">
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
</div>
</section>
<section class="panel">
<header>
<h2>{t('data.trash')}</h2>
+250 -24
View File
@@ -14,6 +14,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import NoteEditor from '$lib/components/NoteEditor.svelte';
let notes = $state([]);
let selected = $state(null);
@@ -47,6 +48,29 @@
draft = note?.body ?? '';
}
/**
* The first line or so of a note, without its marks.
*
* The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
* which is exactly the thing the editor was built to stop showing. What is
* wanted here is the sentence, not the notation.
*/
function plain(body) {
return (body ?? '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/~~([^~]+)~~/g, '$1')
.replace(/<\/?u>/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 90);
}
async function create() {
try {
const note = await api.createNote({ body: '' });
@@ -88,6 +112,43 @@
}
}
/**
* Favourites are the pinned ones. The list already puts them first, so marking
* one is the same act as saying "keep this where I can see it" — one idea, not
* two that have to be explained apart.
*/
async function favourite(note) {
const pinned = !note.pinned;
// Moved before the answer comes back: a star that waits for a round trip
// feels broken on a slow connection, and the cost of being wrong is a star.
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned } : n));
try {
await api.updateNote(note.id, { pinned });
await load(query);
} catch (e) {
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned: !pinned } : n));
app.toast(humanMessage(e));
}
}
/**
* Moves the note into a project, which is what sharing it means: everybody who
* can read the project can read the note, and an editor can change it. Choosing
* the empty option takes it back out and makes it the author's alone again.
*/
async function share(projectId) {
if (!selected) return;
const id = selected.id;
try {
const saved = await api.updateNote(id, { project_id: projectId });
notes = notes.map((n) => (n.id === id ? saved : n));
if (selected?.id === id) selected = saved;
app.toast(projectId ? t('notes.shared') : t('notes.unshared'));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function remove(note) {
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
try {
@@ -101,6 +162,8 @@
// What the note points at, for the panel under the editor.
let links = $derived(selected?.links ?? []);
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
@@ -128,10 +191,22 @@
<ul>
{#each notes as note (note.id)}
<li>
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{note.body.slice(0, 80)}</span>
</button>
<div class="rowline">
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{plain(note.body)}</span>
</button>
<button
class="star"
class:on={note.pinned}
onclick={() => favourite(note)}
aria-pressed={note.pinned}
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
>
{note.pinned ? '★' : '☆'}
</button>
</div>
</li>
{/each}
</ul>
@@ -140,14 +215,14 @@
<section class="editor">
{#if selected}
<textarea
bind:value={draft}
oninput={typed}
onblur={save}
spellcheck="true"
aria-label={t('notes.body')}
placeholder={t('notes.placeholder')}
></textarea>
<NoteEditor
note={selected}
onchange={(body) => {
draft = body;
typed();
}}
onsave={save}
/>
<footer>
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
@@ -158,7 +233,26 @@
{/each}
</span>
{/if}
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
<!-- Sharing a note is filing it in a project.
Not an access list of its own: projects already have members, roles
and invitations, all of them tested and all of them things people
have already learnt here. A second way to give somebody access is a
second place to get it wrong, and the two would disagree on the day
one of them was changed. -->
<label class="share">
<span class="hint">{t('notes.shareWith')}</span>
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
<div class="actions">
<button class="button" onclick={save}>{t('notes.save')}</button>
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
</div>
</footer>
{:else}
<p class="empty">{t('notes.pickOne')}</p>
@@ -168,6 +262,10 @@
<style>
.notes {
/* Air on all sides. The heading, the search box and the list all sat flush
against the sidebar's rule, which reads as a rendering fault rather than
as a layout. */
padding: var(--s3) 0 0 var(--s4);
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: var(--s4);
@@ -192,6 +290,7 @@
h1 {
font-size: var(--text-xl);
line-height: 1.2;
}
.new {
@@ -216,9 +315,34 @@
min-height: 0;
}
.rowline {
display: flex;
align-items: center;
gap: var(--s1);
}
.star {
flex: none;
width: 24px;
color: var(--ink-faint);
opacity: 0;
font-size: var(--text-sm);
}
.rowline:hover .star,
.star.on,
.star:focus-visible {
opacity: 1;
}
.star.on {
color: var(--accent);
}
.row {
display: block;
width: 100%;
flex: 1;
min-width: 0;
text-align: left;
padding: var(--s2);
border-radius: var(--radius);
@@ -258,23 +382,94 @@
min-height: 0;
}
textarea {
/* Everything below exists to keep two layers in lockstep. The shared rules are
set on both at once on purpose: a font-size on one and not the other is a
whole line of drift by the bottom of a long note. */
.paper {
position: relative;
flex: 1;
width: 100%;
min-height: 50vh;
resize: none;
border: 0;
background: none;
color: var(--ink);
}
.paper textarea,
.paper .mirror {
font: inherit;
font-size: var(--text-base, 1rem);
line-height: 1.6;
padding: var(--s2) 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
tab-size: 4;
}
textarea {
position: relative;
width: 100%;
height: 100%;
resize: none;
background: none;
/* Transparent, so what is read is the mirror underneath. The caret keeps its
own colour or there would be nothing to type against. */
color: transparent;
caret-color: var(--ink);
}
textarea::selection {
background: var(--accent);
color: var(--accent-ink);
}
textarea:focus {
outline: none;
}
.mirror {
position: absolute;
inset: 0;
pointer-events: none;
color: var(--ink);
overflow: hidden;
}
/* Headings change weight and colour, never size: a larger font would take more
lines than the textarea does and everything below it would slide. */
.mirror .h1,
.mirror .h2,
.mirror .h3,
.mirror .h4,
.mirror .h5,
.mirror .h6 {
font-weight: 620;
}
.mirror .h1 {
color: var(--ink);
}
.mirror .quote {
color: var(--ink-muted);
}
.mirror .bold {
font-weight: 620;
}
.mirror .italic {
font-style: italic;
}
.mirror .code {
font-family: var(--mono, ui-monospace, monospace);
color: var(--accent);
}
.mirror .tag,
.mirror .wikilink {
color: var(--accent);
}
footer {
display: flex;
align-items: center;
@@ -298,15 +493,46 @@
font-family: var(--mono, ui-monospace, monospace);
}
.remove {
margin-left: auto;
color: var(--ink-faint);
.share {
display: flex;
align-items: center;
gap: var(--s1);
}
.remove:hover {
.share select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}
/* Real buttons. They were bare words, which on a Mac reads as a web page rather
than as something you can press. */
.actions {
margin-left: auto;
display: flex;
gap: var(--s2);
}
.button {
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: var(--s1) var(--s3);
background: var(--surface);
color: var(--ink);
font-size: var(--text-xs);
}
.button:hover {
background: var(--surface-raised);
}
.button.danger {
color: var(--danger);
}
.button.danger:hover {
border-color: var(--danger);
}
.empty,
.hint {
color: var(--ink-faint);
+20 -8
View File
@@ -43,21 +43,33 @@
load(id);
});
// Notes that mention this project. Looked up by name rather than by id, because
// a #tag is written by hand and a person types the name — the same string the
// quick-add parser reads out of a task.
// Two ways a note belongs here, and both count.
//
// Filed in the project — which is what sharing a note means — and mentioning it
// by #tag, which is what somebody does while writing without stopping to file
// anything. Looked up by name for the second, because a #tag is written by hand
// and a person types the name: the same string the quick-add parser reads out of
// a task.
let notes = $state([]);
$effect(() => {
const id = project?.id;
const name = project?.name;
if (!name) {
if (!id || !name) {
notes = [];
return;
}
api
.notesLinking('project', name)
.then((r) => (notes = r.notes ?? []))
.catch(() => (notes = []));
Promise.all([
api.notes({ project: id }).catch(() => ({ notes: [] })),
api.notesLinking('project', name).catch(() => ({ notes: [] }))
]).then(([filed, mentioned]) => {
const seen = new Set();
notes = [...(filed.notes ?? []), ...(mentioned.notes ?? [])].filter((n) => {
if (seen.has(n.id)) return false;
seen.add(n.id);
return true;
});
});
});
async function load(projectId) {