Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 438095e252 fix(noter): ⌘B gør teksten fed i stedet for at folde sidebjælken
Meldt ind: "command+b viser og skjuler menuen - ikke bold".

Fladen tog ⌘B til sidebjælken, og begrundelsen stod i koden: "works while
typing as well — it moves nothing in the field". Det passer for et
`<input>`, hvor ⌘B ikke gør noget. I en contenteditable ER ⌘B fed tekst,
og `preventDefault()` stoppede browseren i at gøre den.

Sætningen var sand, da den blev skrevet. Så kom en rig editor til, og
begrundelsen holdt op med at passe uden at nogen rørte linjen — samme form
som flere af fælderne i den her kodebase.

Feltet, markøren står i, har førsteret til en tast, det selv bruger. Uden
for et skrivefelt folder genvejen stadig sidebjælken, og prøven måler
begge halvdele: en rettelse, der bare holdt op med at folde, ville være
halvt færdig.

Efterprøvet mod den gamle kode — ordet blev aldrig fedt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:28:23 +02:00
kristianwindandClaude Opus 5 3132bba7e5 feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
To ønsker, meldt sammen.

--- eksport af det hele ---

Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip,
og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun
kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten
downloads for at flytte sine egne opgaver, hvilket er en eksport, der
teknisk set findes, og som ingen bruger.

`GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred
fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så
det er den form, der importeres i den anden ende — og den form, man kan
hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som
noteeksporten, der er en mappe med Markdown og ikke én lang fil.

`GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og
ikke en zip, fordi en kalender er lavet til at rumme mange ting, og
projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato
er en linje, de fleste læsere taber på gulvet.

Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender
uden VTODO'er er begge 200 med en troværdig Content-Type, og det er
præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en
løkke der kun eksporterer det sidste ikke består.

--- en indsat liste bliver til opgaver ---

Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk
ved indsætning i et `<input>` — så en kopieret liste blev til én opgave
med punkterne mast sammen.

Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem
for at være gættet til to eller fire: en liste fra Apple Noter rykker ind
med ét antal mellemrum, en fra et Markdown-dokument med et andet, og
begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den
notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste,
der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den
for.

Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses
af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét
sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add`
nu et `parent_id`: en indrykket linje skal parses som enhver anden.

En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket
linje honoreres derfor ikke, og det siges højt som `unknown_project` frem
for at ordet bare forsvinder fra titlen.

Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks
dér. Det er "enten opgaver eller undertasks" afgjort af hvor man
indsætter, uden at nogen skal vælge noget i en menu.

Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke
noget, man skal opdage bagefter — og visningen er også svaret på "forstod
den indrykningen", stillet mens der stadig er en Fortryd-knap.

--- rettet undervejs ---

Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra
ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på
forælderen og regnes ud ved hentning, og serveren sender ikke forælderen
igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun
den nye.

Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 12:27:46 +02:00
Kristian Wind 44b28f1cd9 Merge pull request #92 from kristianwind/monotype-over-hele-markeringen
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 6m4s
CI / Docker image builds (push) Successful in 3m54s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 17m31s
fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
2026-10-02 23:07:05 +02:00
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

Monotype går uden om execCommand, fordi formatBlock pakker et <p> ind i
<pre>'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den <br>-fejl, der
blev rettet i #91: `textContent` på et <br> er den tomme streng.

Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i
blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet
sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i
filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode:
"echo enecho to".

Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét
sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev
skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted,
eksporteret og brugt begge steder.

Begge prøver er efterprøvet mod den gamle kode.

Fuld chromium-suite kørt to gange: 94 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:58:08 +02:00
Kristian Wind dba99b7ecc Merge pull request #91 from kristianwind/noter-lister-blokke-og-soegning
Release / Build and publish image (push) Failing after 6s
fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
2026-09-22 14:10:13 +02:00
kristianwindandClaude Opus 5 f34690f41a fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
Tre ting meldt ind sammen. De to første manglede; den tredje var i stykker.

--- man kunne søge og ikke vælge ---

Regression fra #84. Siden holdt op med at SKRIVE adressen, men blev ved med
at læse den ved hver ændring:

    let asked = $derived($page.url.searchParams.get('note'));
    $effect(() => { if (!asked || selectedId === asked) return; … });

Kom man fra ⌘K med `?note=<id1>`, stod `asked` fast på id1 for altid.
Hvert klik på en anden række satte `selectedId`, effekten så uenigheden og
åbnede id1 igen. Man blev trukket tilbage til den første træffer.

En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
Den læses nu ved ankomsten — og der er TO ankomster, hvilket den første
udgave af rettelsen ikke tog højde for: layoutet tegner ingenting, mens
kontoen hentes, så siden monteres efter den første navigation er forbi, og
en lytter registreret der hører den aldrig. `/noter?note=<id>` tastet eller
fulgt udefra åbnede så ingenting. `onMount` og `afterNavigate`, samme
funktion, med `id === selectedId` som værn mod at begge fyrer.

--- en blok kunne ikke gemme flere linjer ---

Shift+retur bliver i blokken, men det browseren lægger ind er et <br>, og
`textContent` på et <br> er den tomme streng. Blokken blev skrevet til
Markdown som én linje: "first linesecond line". Usynligt indtil en
genindlæsning, for på skærmen står linjerne rigtigt hele tiden.

`codeText()` læser blokken rekursivt og gør <br> til et linjeskift.
Rekursivt, fordi indholdet ligger i et <code> inde i <pre>: en filtrering
over <pre>'ens egne børn spørger det ene element om en textContent, der
allerede har tabt linjeskiftet.

--- lister havde ingen vej til niveau to ---

Tab var ikke bundet til noget uden for forslagslisten, så den gjorde det,
Tab gør: flyttede fokus ud af noten. Den rykker nu ind og shift+Tab ud, og
kun inde i en liste — ellers er noten en boks, man ikke kan tabbe ud af.

Og skriveren ville have tabt resultatet: `list()` læste kun en underliste,
der ligger INDE i sit punkt. Lægger browseren den som søskende, og de gør
det uens, blev grenen sprunget over uden lyd. At binde Tab uden den her
halvdel ville have lavet en fejl, hvor indrykkede punkter forsvandt ved
gemningen.

Begge nye prøver måler EFTER en genindlæsning. Før den ser begge dele
rigtige ud på skærmen; det er turen gennem Markdown, der afgør, om det
blev skrevet ned.

Fuld chromium-suite kørt to gange: 92 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:01:15 +02:00
Kristian Wind 1bb2c9cda6 Merge pull request #90 from kristianwind/klokken-paa-telefon
fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
2026-09-17 19:06:03 +02:00
kristianwindandClaude Opus 5 cac6c14a23 fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
Panelet hang under klokken med `right: 0` og en fast bredde på 340 px.
Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en
kantafstand til højre for den — så der er kun omkring 330 px tilbage, og
resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved
en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder"
klæbede til kanten uden luft, hvilket er det synlige tegn.

På en telefon hører panelet nu til skærmen i stedet for til klokken: det
spænder fra kant til kant med en margen i hver side, under toplinjen. Samme
knæk ved 820 px som toplinjens egen menuknap, så de to skifter form
samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i
listen ikke trækker siden bagved med.

Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE
trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og
den næste knap i toplinjen ville lave fejlen om igen uden at sige noget.

--- og fire ustabile prøver mere ---

Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man
kan se, er altså sandt, før serveren har hørt om det, og en prøve, der
genindlæser eller klikker videre i det hul, måler den gamle tilstand.

`en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning.
Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil
CI er travl — begge fejlede i en fuld kørsel her.

`en gruppe er en side med sine projekter` er den mest lærerige: trækket
virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket
videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på
et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem.

Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den
første var lukket: den klikkede "Ny note" og skrev med det samme. `create()`
venter på serverens svar, og imens står den forrige notes editor stadig på
skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny
hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen
har samme mønster; de er ikke skrevet om, fordi de består, og en samlet
omskrivning ville bytte kendte prøver for ukendte.

Det hul findes også for et menneske: klikker man Ny note og skriver straks,
lander de første anslag i den forrige note. Det er millisekunder lokalt og
sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR
svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her.

`noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og
projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem
lige før en genindlæsning eller en navigation.

Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:55:45 +02:00
Kristian Wind f632fb8643 Merge pull request #89 from kristianwind/to-vaern-mere
test(e2e): to genindlæsninger mere venter på gemningen
2026-09-17 13:12:07 +02:00
kristianwindandClaude Opus 5 eabb00ecc2 test(e2e): to genindlæsninger mere venter på gemningen
Samme form som de fire "Gemt"-ventetider, to andre steder — begge lige før
en `page.reload()`, hvilket er der, hvor det koster.

`en note kan skrives fed, og den kommer hel tilbage` havde `blur()` direkte
efterfulgt af en genindlæsning uden noget værn overhovedet. Nåede PATCH'en
ikke frem først, målte prøven en note, der aldrig blev skrevet — og den
prøve findes netop for at fange en omformer, der er uenig med sig selv.

`[[henvisning]]`-prøven havde `waitForTimeout(900)`. Tallet var gættet til
at overleve tastepausen på 700 ms; et gæt om tid måler maskinen frem for
programmet. Det er den konstruktion, den her fil allerede advarer imod.

Begge venter nu på PATCH-svaret gennem noteGemt().

Der står 24 faste ventetider tilbage i filen. De er ikke rørt: flere af dem
venter med vilje (at skrive i et menneskeligt tempo er en rigtig grund), og
at skrive dem om samlet ville bytte kendte prøver for ukendte.

Fuld chromium-suite: 89 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:02:31 +02:00
Kristian Wind d2b5821e57 Merge pull request #88 from kristianwind/vaern-der-venter-paa-noget
test(e2e): værn, der kan vente på noget
2026-09-17 12:59:26 +02:00
20 changed files with 1524 additions and 68 deletions
+48
View File
@@ -848,6 +848,20 @@ paths:
project_id:
type: string
description: Where it goes when the text names no project.
section_id:
type: string
description: >
The section the box was opened in. A "/section" in the text
wins, because typing it is a choice and standing there is a
circumstance.
parent_id:
type: string
description: >
Makes it a sub-task. A sub-task lives in its parent's
project, so a "#project" on the same line is not honoured —
it comes back as `unknown_project` rather than being
silently dropped. Dates, labels and priority are read the
same way as for any other line.
responses:
"201":
description: Created
@@ -3143,6 +3157,40 @@ paths:
to: { type: string, format: date }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/export/projects.zip:
get:
tags: [projects]
summary: Every project's tasks as a CSV, in a zip.
description: >
One CSV per project, in the column layout Todoist writes — so the files
import into Todoist and into anything else that reads that shape. A zip
rather than one wide file because that is the shape on the other side,
and because a single project can be lifted out of it without opening a
spreadsheet. The per-project link does the same for one project; this is
the whole account in one download.
responses:
"200":
description: A zip archive.
content:
application/zip:
schema: { type: string, format: binary }
/api/v1/export/tasks.ics:
get:
tags: [projects]
summary: Every dated task as a calendar of VTODOs.
description: >
One file, not a zip: a calendar is made to hold many things, and each
task carries its project with it, so nothing is lost by putting them
together. Tasks without a due date are left out — a VTODO with no date
is a line most readers drop on the floor.
responses:
"200":
description: An iCalendar file.
content:
text/calendar:
schema: { type: string }
/api/v1/export/notes.zip:
get:
tags: [notes]
+106
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"archive/zip"
"encoding/json"
"fmt"
"net/http"
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
}
}
// handleExportAllCSV is every project at once, one CSV per project in a zip.
//
// A zip rather than one wide CSV with a project column: Todoist's own export is a
// file per project, so that is the shape an importer on the other side expects —
// and the shape somebody can pick a single project out of without opening a
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
// rather than one long file.
//
// It exists because the per-project links are per project: fifteen projects meant
// fifteen downloads to move your own tasks somewhere else, which is an export that
// technically works and nobody uses.
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
s.internal(w, r, "export projects", err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
// Two projects can share a name — they are still two projects, and a zip with
// one entry written twice loses one of them. Same guard as the notes export.
used := map[string]int{}
for _, project := range projects {
exported, err := s.buildExport(r, user, &project)
if err != nil {
// The response is already streaming, so there is no status left to send.
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
// better than a complete-looking file with a project missing from it.
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
used[name]++
if used[name] > 1 {
name = fmt.Sprintf("%s (%d)", name, used[name])
}
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
if err != nil {
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
s.log.Error("export projects", "err", err, "project", project.ID)
return
}
}
}
// handleExportAllICS is every dated task in one calendar.
//
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
// many things and every reader takes a file with hundreds of VTODOs. The project
// travels with each task as its own field, so nothing is lost by merging them.
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
ctx := r.Context()
projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil {
s.internal(w, r, "export ics", err)
return
}
names := map[string]string{}
for _, p := range projects {
names[p.ID] = p.Name
}
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, r, "export ics", err)
return
}
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
for _, t := range tasks {
if t.DueDate == "" {
continue
}
cal.Tasks = append(cal.Tasks, ics.Task{
ID: t.ID, Content: t.Content, Description: t.Description,
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
Completed: t.Completed(), CompletedAt: t.CompletedAt,
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
})
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
w.Write([]byte(ics.Render(cal)))
}
// buildExport turns a stored project back into the neutral shape the CSV writer
// takes, rebuilding the task tree from parent ids.
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
+2
View File
@@ -456,6 +456,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
+35
View File
@@ -501,6 +501,18 @@ type quickAddRequest struct {
// the foot of one. Same rule as the project: a "/section" in the text wins,
// because typing it is a choice and standing there is a circumstance.
SectionID string `json:"section_id,omitempty"`
// ParentID makes the new task a sub-task of an existing one. It is what a
// pasted list uses for its indented lines, and what the sub-task field at the
// foot of a task uses — both want the same parsing of dates, #project and
// @labels that a top-level task gets, so they come through here rather than
// through a second, dumber path.
//
// A sub-task lives in its parent's project. Saying otherwise would describe a
// task whose parent is somewhere else, which the list views cannot draw and
// nobody means; a "#project" on an indented line is therefore not honoured,
// and the caller is told so the word does not just vanish.
ParentID string `json:"parent_id,omitempty"`
}
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
@@ -517,6 +529,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
return
}
// A parent decides the project before anything else is read: a sub-task
// somewhere other than its parent is not a shape this program has.
parent := (*store.Task)(nil)
if req.ParentID != "" {
found, err := s.db.GetTask(r.Context(), req.ParentID, user.ID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
parent = found
}
projectID := req.ProjectID
// Set when the text named a project that does not exist. The task is still
// created — see below — but the caller is told, because the alternative is
@@ -532,6 +556,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
unknownProject = parsed.Project
}
}
// The parent has the last word, and the word it overrules is reported the same
// way an unknown project is — the task is created either way.
if parent != nil {
if parsed.Project != "" && projectID != parent.ProjectID {
unknownProject = parsed.Project
}
projectID = parent.ProjectID
}
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
@@ -570,6 +602,9 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence,
}
if parent != nil {
t.ParentID = parent.ID
}
if parsed.DueDate != "" {
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
if err == nil {
+173
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
}
// The whole point of import and export together: what goes in comes back out.
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
//
// The line still goes through the same parser as a top-level one — a sub-task
// with a date is an ordinary thing to paste, and a second, dumber creation path
// for indented lines would be a second place for "i morgen" to mean something
// different.
func TestQuickAddSubTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Flytte garagen",
})
parentID, _ := parent["id"].(string)
if parentID == "" {
t.Fatalf("no parent: %v", parent)
}
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Rydde hylderne p1", "parent_id": parentID,
})
if got := child["parent_id"]; got != parentID {
t.Errorf("parent_id = %v, want %v", got, parentID)
}
// The line was parsed, not stored raw: the priority marker is gone from the
// text and present as a priority.
if got := child["content"]; got != "Rydde hylderne" {
t.Errorf("content = %v, want the line without its marker", got)
}
if got := child["priority"]; got != float64(1) {
t.Errorf("priority = %v, want 1", got)
}
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
otherID, _ := other["id"].(string)
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
})
if got := moved["project_id"]; got == otherID {
t.Errorf("the sub-task left its parent's project")
}
// And it is said out loud rather than the word just disappearing from the
// title — the failure this reports is exactly the one that used to be silent.
if got := moved["unknown_project"]; got != "Andetsteds" {
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
}
})
}
// raw fetches a download — a zip or a calendar — as bytes.
//
// `do` decodes JSON, which these two are not: it would report an empty body and a
// parse error where the interesting thing is the bytes themselves.
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
t.Helper()
req, _ := http.NewRequest("GET", ts.URL+path, nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: status %d", path, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return body, resp.Header.Get("Content-Type")
}
// TestExportEverythingAtOnce covers the two whole-account exports.
//
// The per-project links worked and were unusable at any size: fifteen projects
// meant fifteen downloads to move your own tasks somewhere else. These two are
// the same data without the fifteen clicks.
//
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
// exactly the shape an export bug takes.
func TestExportEverythingAtOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Two projects, so the zip has to hold more than the one that happens to be
// first — a loop that exports only the last project passes a one-project test.
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
firstID, _ := first["id"].(string)
secondID, _ := second["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Skifte daek", "project_id": secondID,
})
t.Run("csv zip holds one file per project", func(t *testing.T) {
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
if ct != "application/zip" {
t.Fatalf("content-type %q", ct)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("not a zip: %v", err)
}
found := map[string]string{}
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
text, _ := io.ReadAll(rc)
rc.Close()
found[f.Name] = string(text)
}
if len(found) < 2 {
t.Fatalf("want a file per project, got %d: %v", len(found), found)
}
// The task has to be IN the file named after its own project. A zip with the
// right filenames and every task in the wrong one is the failure this is for.
var sawMoms, sawDaek bool
for name, text := range found {
// Filenames are lower-cased by ics.Filename, so the comparison is too —
// asserting on the stored capitalisation would be asserting on a detail
// the exporter is free to change.
lower := strings.ToLower(name)
if strings.Contains(text, "Betal moms") {
sawMoms = true
if !strings.Contains(lower, "firma") {
t.Errorf("Betal moms landed in %q, not in Firma", name)
}
}
if strings.Contains(text, "Skifte daek") {
sawDaek = true
if !strings.Contains(lower, "hjem") {
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
}
}
}
if !sawMoms || !sawDaek {
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
}
})
t.Run("ics holds every dated task across projects", func(t *testing.T) {
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
if !strings.HasPrefix(ct, "text/calendar") {
t.Fatalf("content-type %q", ct)
}
text := string(body)
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
}
if !strings.Contains(text, "Betal moms") {
t.Errorf("the dated task is missing:\n%s", text)
}
// Undated tasks are deliberately left out — a VTODO with no date is a line
// most readers drop. Asserted so the omission stays a decision.
if strings.Contains(text, "Skifte daek") {
t.Errorf("an undated task was exported:\n%s", text)
}
})
}
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import { readList } from '../src/lib/pastelist.js';
/**
* Læsningen af en indsat liste, prøvet for sig.
*
* Reglerne er små og mange, og de er billigere at stille her end gennem en
* browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
* gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
* sammen, ikke at hvert tilfælde er rigtigt.
*/
test.describe('en indsat liste læses som opgaver og underopgaver', () => {
test('mærker fjernes, teksten bliver', () => {
expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
{ depth: 0, text: 'køb mælk', done: false },
{ depth: 0, text: 'ring til Per', done: false },
{ depth: 0, text: 'hent pakken', done: false }
]);
});
test('nummererede lister er også lister', () => {
expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
{ depth: 0, text: 'først', done: false },
{ depth: 0, text: 'så', done: false },
{ depth: 0, text: 'til sidst', done: false }
]);
});
test('en afkrydsning er indhold, ikke notation', () => {
// Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
// kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
{ depth: 0, text: 'betalt', done: true },
{ depth: 0, text: 'ikke betalt', done: false },
{ depth: 0, text: 'også betalt', done: true }
]);
});
test('indrykning bærer niveauet, uanset hvor bred den er', () => {
// To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
// alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
const to = readList('a\n b\nc');
const fire = readList('a\n b\nc');
const tab = readList('a\n\tb\nc');
const want = [
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 0, text: 'c', done: false }
];
expect(to).toEqual(want);
expect(fire).toEqual(want);
expect(tab).toEqual(want);
});
test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
// Kopierer man fra et dokument, følger der ofte indrykning med på alle
// linjer. Den er ikke et niveau — der er ikke noget at være under.
expect(readList(' a\n b')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false }
]);
});
test('tomme linjer er luft i det kopierede, ikke punkter', () => {
// Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
// liste.
expect(readList('a\n\n b\n\n c')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 1, text: 'c', done: false }
]);
});
test('teksten røres ikke ud over mærket', () => {
// Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
// linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
// mange.
expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
{ depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
{ depth: 0, text: 'andet', done: false }
]);
});
});
+487 -19
View File
@@ -39,26 +39,60 @@ async function projectAction(page, name) {
}
/**
* Venter på, at en notes gemning faktisk er nået frem til serveren.
* Venter på, at en gemning faktisk er nået frem til serveren.
*
* Ikke på teksten "Gemt" i foden: den er hvilestanden. Feltet siger "Gemmer" mens
* en gemning kører og "Gemt" ellers — altså også før den første overhovedet er
* begyndt. En ventetid på den er grøn med det samme og venter på ingenting, og det
* har gjort tre prøver i den her fil ustabile. Svaret på PATCH'en kan der ventes
* på, og det er det eneste, der beviser, at serveren har hørt om teksten.
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
* og fejler på noget, der ikke er i stykker.
*
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
*
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
*
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
* allerede kommet og gået.
*/
function noteGemt(side) {
function gemt(side, sti) {
return side.waitForResponse(
(r) =>
r.request().method() === 'PATCH' &&
/\/notes\/[^/]+$/.test(new URL(r.url()).pathname) &&
r.ok()
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
);
}
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
/**
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
*
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
* findes den titel, prøven leder efter, slet ikke.
*
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
*
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
*/
async function nyNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
@@ -1704,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
const flyttet = projektGemt(page);
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
@@ -1711,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await flyttet;
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
@@ -2758,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
const rundtur = noteGemt(page);
await page_.blur();
await rundtur;
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
@@ -3196,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
const linketGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(900);
await linketGemt;
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
@@ -3734,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
const ed = await nyNote(page);
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
@@ -3772,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
@@ -3781,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(1200);
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
@@ -4084,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await page.waitForTimeout(400);
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
@@ -4753,6 +4797,24 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
// tilbage til den første. Man kunne søge og ikke vælge.
await list.getByText('Fyrreskov syd').click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
// kunne så ikke komme tilbage til den note, man kom fra.
await page.getByRole('button', { name: /Søg/ }).click();
await palette.getByRole('textbox').fill('Fyrreskov nord');
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
@@ -4841,6 +4903,412 @@ test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
});
/**
* Klokkens panel bliver inden for skærmen på en telefon.
*
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
* kant end det — der er en temaknap og en kantafstand til højre for den — så
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
* den.
*
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
* det stikke ud til højre, er ikke en rettelse.
*/
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByRole('button', { name: /^Beskeder/ }).click();
const panel = page.getByRole('dialog', { name: 'Beskeder' });
await expect(panel).toBeVisible();
const kasse = await panel.boundingBox();
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
// ud på telefonen.
const overskrift = panel.getByText('Beskeder', { exact: true });
const tekst = await overskrift.boundingBox();
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
expect(trouble).toEqual([]);
});
/**
* En punktliste kan have niveauer, og de overlever gemningen.
*
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
*
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
*
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
*/
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Indkøb');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
await page.keyboard.type('Frugt');
await page.keyboard.press('Enter');
await page.keyboard.type('Aebler');
await page.keyboard.press('Tab');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Niveauet er der med det samme: en liste inde i en liste.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Indkøb/ }).click();
// Og efter turen gennem Markdown står det der stadig.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
await ed.locator('ul ul li').click();
await page.keyboard.press('Shift+Tab');
await expect(ed.locator('ul ul li')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt.
*
* Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i
* den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien
* brugte `textContent` — hvor et <br> er den tomme streng. Så en blok skrevet med
* shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På
* skærmen, ikke kun i filen, og uden at man havde rørt teksten.
*
* Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme
* svar". Der var to, og de var uenige om netop det tegn.
*/
test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Farvelagt');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('echo en');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('echo to');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Ud af blokken, så farvelægningen tager fat i den.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('End');
await ed.getByText('Farvelagt', { exact: true }).click();
// Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så
// den skal ud, før teksten læses — præcis det, koden selv gør.
const tekst = await ed.locator('pre').evaluate((el) => {
const kopi = el.cloneNode(true);
kopi.querySelectorAll('.copy').forEach((b) => b.remove());
return kopi.innerText.trim();
});
expect(tekst).toBe('echo en\necho to');
expect(trouble).toEqual([]);
});
/**
* ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
*
* Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
* feltet". Det passer for et <input>, hvor ⌘B ikke gør noget. I en contenteditable
* ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
* noterne foldede menuen sig, hver gang man ville fremhæve et ord.
*
* Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
* halvt færdig: ordet skal også blive fedt.
*/
test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const foldet = () => page.locator('.shell.sidebar-collapsed').count();
expect(await foldet()).toBe(0);
await nyNote(page);
await page.keyboard.type('Fremhævet');
await page.keyboard.press('Enter');
await page.keyboard.type('ordet');
await page.keyboard.press('ControlOrMeta+a');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await page.keyboard.press('ControlOrMeta+b');
// Enten <b> eller <strong>: browserens egen fed er <b>, omformeren skriver
// <strong>, og begge læses som fed på vejen tilbage.
await expect(ed.locator('b, strong')).toContainText('ordet');
expect(await foldet()).toBe(0);
// Og uden for et skrivefelt gør den stadig det, den er sat til.
await page.getByRole('heading', { name: 'Noter' }).click();
await page.keyboard.press('ControlOrMeta+b');
expect(await foldet()).toBe(1);
await page.keyboard.press('ControlOrMeta+b');
expect(trouble).toEqual([]);
});
/**
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
*
* Monotype går uden om `execCommand`, fordi formatBlock pakker et <p> ind i
* <pre>'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
* altså det ene blok markøren står i — så af en markering over ti linjers
* terminaludskrift blev den første linje til kode og resten stod tilbage som
* brødtekst.
*
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
* hegn i filen.
*/
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Udskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('linje et');
await page.keyboard.press('Enter');
await page.keyboard.type('linje to');
await page.keyboard.press('Enter');
await page.keyboard.type('linje tre');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('p')).toHaveCount(3);
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
// af den første til enden af den sidste.
await ed.getByText('linje et', { exact: true }).click();
await page.keyboard.press('Home');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+End');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
await expect(ed.locator('p')).toHaveCount(0);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Udskrift/ }).click();
await expect(ed.locator('pre')).toHaveCount(1);
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
'linje et\nlinje to\nlinje tre'
);
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
*
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
* det er præcis den form, en fejl i en eksport har.
*/
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
await page.goto('/indstillinger/data');
const hent = async (navn) => {
const venter = page.waitForEvent('download');
await page.getByRole('link', { name: navn }).click();
const fil = await venter;
const sti = await fil.path();
return { sti, navn: fil.suggestedFilename() };
};
const zip = await hent('Alle opgaver (CSV)');
expect(zip.navn).toMatch(/\.zip$/);
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
expect(size).toBeGreaterThan(100);
const kal = await hent('Alle opgaver (kalender)');
expect(kal.navn).toMatch(/\.ics$/);
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
expect(tekst).toContain('BEGIN:VTODO');
expect(tekst).toContain('Betal moms');
expect(trouble).toEqual([]);
});
/**
* En indsat liste bliver til opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen.
*
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
* billigere at stille.
*/
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
'\n'
);
await page.getByLabel('Ny opgave').click();
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('[data-quickadd]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, liste);
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
// man skal opdage bagefter.
const visning = page.getByRole('group', { name: 'Indsat liste' });
await expect(visning.locator('li')).toHaveCount(4);
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
await expect(visning).toHaveCount(0);
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
// Og de indrykkede hænger under den linje, de stod under — ikke under den
// første, den bedste. Det ses i opgavens egen rude.
await page.getByText('Flytte garagen', { exact: true }).click();
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
// er det, serveren har gemt, stregen er, hvordan den tegnes.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
// undertasks under den — uden at nogen skal vælge noget.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('input[aria-label="Ny undertask"]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, '- Købe maling\n- Låne stige');
const flere = page.getByRole('group', { name: 'Indsat liste' });
await expect(flere.locator('li')).toHaveCount(2);
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
// To mere under den samme opgave: fire undertasks, én af dem lukket.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
+9 -2
View File
@@ -365,8 +365,13 @@ export const api = {
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAdd: (text, projectId, sectionId, parentId) =>
post('/tasks/quick-add', {
text,
project_id: projectId,
section_id: sectionId,
parent_id: parentId
}),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
@@ -511,6 +516,8 @@ export const api = {
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
exportAllCSVURL: () => '/api/v1/export/projects.zip',
exportAllICSURL: () => '/api/v1/export/tasks.ics',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
+106 -21
View File
@@ -13,7 +13,7 @@
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
@@ -133,20 +133,78 @@
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
// out of the block from the menu. Do any conversion that touches a <pre> by
// hand, replacing the element rather than reformatting inside it.
const block = currentBlock();
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
replaceBlock(block, style.tag);
//
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
const blocks = selectedBlocks();
if (!blocks.length) return apply('formatBlock', style.tag);
if (style.tag === 'pre') {
mergeToMono(blocks);
return;
}
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
const pres = blocks.filter((b) => b.tagName === 'PRE');
for (const b of pres) swapTag(b, style.tag);
if (pres.length === blocks.length) {
readState();
changed();
return;
}
apply('formatBlock', style.tag);
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
*
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
* som før.
*/
function replaceBlock(block, tag) {
function selectedBlocks() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount || !editor) return [];
const range = selection.getRangeAt(0);
return [...editor.children].filter((b) => range.intersectsNode(b));
}
/**
* Lægger flere blokke sammen til én monotypeblok.
*
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
* de er.
*/
function mergeToMono(blocks) {
const pre = document.createElement('pre');
// blockText og ikke textContent: et <br> har den tomme streng som
// textContent, og det er netop <br>, der står for et linjeskift inde i en
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
if (!pre.textContent) pre.appendChild(document.createElement('br'));
blocks[0].replaceWith(pre);
for (const b of blocks.slice(1)) b.remove();
const range = document.createRange();
range.selectNodeContents(pre);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
function swapTag(block, tag) {
// The copy button lives inside the <pre> so it can sit in its corner; it is
// not text and must not travel into a paragraph. addCopyButtons puts it back
// on the next render for any <pre> that still needs one.
@@ -156,6 +214,16 @@
while (block.firstChild) el.appendChild(block.firstChild);
if (!el.firstChild) el.appendChild(document.createElement('br'));
block.replaceWith(el);
return el;
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
*/
function replaceBlock(block, tag) {
const el = swapTag(block, tag);
const range = document.createRange();
range.selectNodeContents(el);
@@ -380,7 +448,7 @@
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeTextOf(pre);
const text = blockText(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
@@ -394,19 +462,16 @@
}
/**
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
*
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
* væk fra den. På skærmen, ikke kun i filen.
*/
function codeTextOf(pre) {
const from = pre.querySelector('code') ?? pre;
return [...from.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
}
function colourCode() {
if (!editor) return;
@@ -419,7 +484,7 @@
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
// spiste den kode, den var sat der for at kopiere.
const code = codeTextOf(pre);
const code = blockText(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -788,6 +853,17 @@
});
}
/**
* Står markøren i en punktliste eller en nummereret liste?
*
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
* ikke blive uenige om, hvad der er en liste.
*/
const inList = () =>
document.queryCommandState('insertUnorderedList') ||
document.queryCommandState('insertOrderedList');
function onkeydown(event) {
// The suggestion list owns the arrows and return while it is open, the way
// every other completion does. Escape closes it without choosing.
@@ -816,6 +892,15 @@
}
}
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
if (event.key === 'Tab' && inList()) {
event.preventDefault();
apply(event.shiftKey ? 'outdent' : 'indent');
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
// A code block is the one place the browser keeps the caret inside on
// Enter rather than starting a new block. Step out of it ourselves, or a
+43 -2
View File
@@ -20,6 +20,31 @@
let open = $state(false);
let wrap = $state(null);
/**
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
*
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
* klippet. Der stod tre en halv linje tekst uden for skærmen.
*
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
* kantafstand til højre for klokken — men så ville den her fil kende sine
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
* skærmen med en kant i hver side.
*/
let below = $state(0);
function place() {
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
}
function toggle() {
open = !open;
if (open) place();
}
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
@@ -72,6 +97,7 @@
<svelte:window
onclick={outside}
onresize={() => open && place()}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
@@ -81,7 +107,7 @@
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
onclick={toggle}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
@@ -101,7 +127,7 @@
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
@@ -193,6 +219,21 @@
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
toplinjens egen menuknap, så de to skifter form samtidig. */
@media (max-width: 820px) {
.panel {
position: fixed;
top: calc(var(--below) + 6px);
left: var(--s3);
right: var(--s3);
width: auto;
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
overscroll-behavior: contain;
}
}
.panel header {
display: flex;
align-items: center;
+126 -1
View File
@@ -15,6 +15,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -97,6 +98,60 @@
return out;
});
/**
* En indsat liste bliver til opgaver frem for til én opgave med det hele i.
*
* Browseren smider linjeskiftene væk, når man indsætter i et `<input>`, så uden
* det her blev en kopieret liste til én opgave med punkterne mast sammen. En
* enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
* som altid.
*
* Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
* opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
* stillet mens der stadig er en Fortryd-knap.
*/
let pending = $state([]);
function onpaste(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pending = rows;
}
async function createPending() {
if (submitting) return;
submitting = true;
// Den seneste opgave på hvert niveau, så en indrykket linje havner under den
// linje, den stod under — og ikke under den første, den bedste.
const parents = [];
let made = 0;
for (const row of pending) {
const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
const task = await app.quickAdd(row.text, projectId, undefined, parentId);
// Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
// resten af listen ind uden den, de hang under, og det er værre end at
// standse et sted, man kan se.
if (!task) break;
parents[row.depth] = task.id;
parents.length = row.depth + 1;
if (row.done) await app.complete(task.id);
onadded?.(task);
made++;
}
submitting = false;
// Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
// den kom og prøve resten igen.
pending = pending.slice(made);
input?.focus();
}
async function submit(event) {
event?.preventDefault();
const value = text.trim();
@@ -164,13 +219,32 @@
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
{onpaste}
/>
</div>
{#if text.trim()}
{#if text.trim() && !pending.length}
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
{#if pending.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pending as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="actions">
<button type="button" class="submit" disabled={submitting} onclick={createPending}>
{t('task.pastedAdd', { n: String(pending.length) })}
</button>
<button type="button" class="cancel" onclick={() => (pending = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
<!-- What the parser understands, spelled out while somebody is typing into it.
It was in the placeholder before, which is the one place a hint cannot be:
the placeholder disappears at the first keystroke, so the help was gone
@@ -190,6 +264,57 @@
</form>
<style>
/* Den indsatte liste, før den bliver til opgaver. Ligger i flowet og ikke
svævende: den skal kunne klikkes på, og den er noget, man læser færdig frem
for et vink, der forsvinder. */
.pasted {
grid-column: 1 / -1;
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.pasted li {
/* Indrykningen er hele pointen med visningen: den viser, hvad der bliver en
underopgave, mens der stadig er en vej tilbage. */
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
color: var(--ink);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted .actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.cancel {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.cancel:hover {
text-decoration: underline;
}
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
.syntax {
/* Nothing to click, and it floats over the first task in the list — which it
+130 -1
View File
@@ -15,6 +15,7 @@
import { focusOnMount } from '$lib/focus.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
import { readList } from '$lib/pastelist.js';
let { task, onclose } = $props();
@@ -282,6 +283,73 @@
// --- sub-tasks ----------------------------------------------------------------
/**
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
*
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
* punkter under punkter kommer ind som den stod.
*
* Som i quick-add vises den, før den oprettes — og af samme grund.
*/
let pastedSubtasks = $state([]);
function onPasteSubtasks(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pastedSubtasks = rows;
}
async function createPastedSubtasks() {
if (addingPasted) return;
addingPasted = true;
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
// seneste linje ovenfor, som i quick-add.
const parents = [task.id];
let made = 0;
for (const row of pastedSubtasks) {
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
if (!created) break;
parents[row.depth + 1] = created.id;
parents.length = row.depth + 2;
if (row.done) await app.complete(created.id);
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
// resten hører under deres egen.
if (row.depth === 0) subtasks = [...subtasks, created];
made++;
}
addingPasted = false;
pastedSubtasks = pastedSubtasks.slice(made);
countSubtasks();
}
let addingPasted = $state(false);
/**
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
*
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
*
* Samme regel som resten af butikken: en lokal handling slår igennem med det
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
*/
function countSubtasks() {
app.upsert({
...task,
subtask_count: subtasks.length,
subtask_done: subtasks.filter((s) => s.completed).length
});
}
async function addSubtask(event) {
event.preventDefault();
const text = newSubtask.trim();
@@ -294,6 +362,7 @@
parent_id: task.id
});
subtasks = [...subtasks, created];
countSubtasks();
} catch (e) {
app.toast(humanMessage(e));
}
@@ -635,8 +704,31 @@
{/each}
<form onsubmit={addSubtask}>
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
<input
bind:value={newSubtask}
onpaste={onPasteSubtasks}
placeholder={t('detail.addSubtask')}
aria-label={t('detail.newSubtask')}
/>
</form>
{#if pastedSubtasks.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pastedSubtasks as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="pasted-actions">
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
</button>
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
</section>
<section>
@@ -741,6 +833,43 @@
</aside>
<style>
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
.pasted {
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 30vh;
overflow-y: auto;
}
.pasted li {
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted-actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.pasted-actions .plain {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.linked-note {
display: block;
padding: var(--s2);
+5
View File
@@ -263,6 +263,9 @@ export const da = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.pastedTitle': 'Indsat liste',
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
@@ -611,6 +614,8 @@ export const da = {
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
'data.notesImported': '{n} noter og {files} filer hentet ind.',
'data.exportNotes': 'Alle noter som Markdown-filer',
'data.exportAllCSV': 'Alle opgaver (CSV)',
'data.exportAllICS': 'Alle opgaver (kalender)',
'data.exportAccount': 'Hele kontoen som JSON',
'data.exportOne': 'Ét projekt',
'data.pickProject': 'Vælg et projekt',
+5
View File
@@ -260,6 +260,9 @@ export const en = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.pastedTitle': 'Pasted list',
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
@@ -607,6 +610,8 @@ export const en = {
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
'data.notesImported': '{n} notes and {files} files brought in.',
'data.exportNotes': 'Every note as Markdown files',
'data.exportAllCSV': 'All tasks (CSV)',
'data.exportAllICS': 'All tasks (calendar)',
'data.exportAccount': 'The whole account as JSON',
'data.exportOne': 'One project',
'data.pickProject': 'Pick a project',
+64
View File
@@ -0,0 +1,64 @@
/**
* En indsat liste, læst som opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
*
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
* faktisk er set, klarer begge uden at kende tallet på forhånd.
*
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
* uløste opgaver, har tabt det, man indsatte den for.
*
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
* forstå en opgavelinje, ikke to.
*/
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
const BOX = /^\[([ xX])\]\s*/;
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
function width(indent) {
return indent.replace(/\t/g, ' ').length;
}
export function readList(text) {
const out = [];
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
const levels = [];
for (const raw of (text ?? '').split(/\r?\n/)) {
const indent = /^[ \t]*/.exec(raw)[0];
let rest = raw.slice(indent.length).trim();
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
if (!rest) continue;
rest = rest.replace(MARKER, '');
let done = false;
const box = BOX.exec(rest);
if (box) {
done = box[1] !== ' ';
rest = rest.slice(box[0].length);
}
rest = rest.trim();
if (!rest) continue;
const w = width(indent);
while (levels.length && w < levels[levels.length - 1]) levels.pop();
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
out.push({ depth: levels.length - 1, text: rest, done });
}
return out;
}
+44 -8
View File
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i et blok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
* tiden.
*
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
export function blockText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += blockText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
const code = blockText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
+2 -2
View File
@@ -524,9 +524,9 @@ class AppState {
* request is fast and the box clears immediately, which is where the
* responsiveness actually needs to be.
*/
async quickAdd(text, projectId) {
async quickAdd(text, projectId, sectionId, parentId) {
try {
const task = await api.quickAdd(text, projectId);
const task = await api.quickAdd(text, projectId, sectionId, parentId);
this.upsert(task);
return task;
} catch (e) {
+14 -2
View File
@@ -130,8 +130,20 @@
// Folding the sidebar had a button and nothing else, and a rectangle with a
// line down it is not a word: somebody who wanted the menu out of the way
// asked for the feature that was already there. ⌘B is where an editor puts
// it. Works while typing as well — it moves nothing in the field.
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
// it.
//
// Men ikke i en rig editor. Her stod der "works while typing as well — it
// moves nothing in the field", og det passer for et <input>, hvor ⌘B ikke
// gør noget. I en contenteditable ER ⌘B fed tekst, og `preventDefault()`
// nedenfor stoppede browseren i at gøre den — så i noterne foldede
// sidebjælken sig i stedet for at gøre ordet fedt. Feltet, markøren står i,
// har førsteret til en tast, det selv bruger.
const writing = el?.isContentEditable || el?.closest?.('[contenteditable="true"]');
if (
(event.metaKey || event.ctrlKey) &&
(event.key === 'b' || event.key === 'B') &&
!writing
) {
event.preventDefault();
sidebar.toggle();
return;
@@ -393,6 +393,8 @@
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
</div>
<!-- The way back in, and from anywhere else: a folder of Markdown is what
@@ -410,6 +412,8 @@
<p class="hint">{t('data.importNotesHint')}</p>
</div>
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
projekt, og det er en anden opgave end at flytte hele kontoen. -->
<div class="field">
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>
+38 -10
View File
@@ -14,8 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { afterNavigate, goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -511,15 +511,43 @@
query = inURL;
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
$effect(() => {
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
/**
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
* den igen.
*
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
* sprang tilbage til den første.
*
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
* ved hver ændring.
*/
function openFromURL(url) {
const id = url?.searchParams.get('note');
if (!id || id === selectedId) return;
const found = notes.find((n) => n.id === id);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
});
else api.note(id).then(open).catch(() => {});
}
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
// den anden.
//
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
// eller fulgt udefra åbnede så ingenting.
//
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
// programmet, monteres siden ikke om.
onMount(() => openFromURL($page.url));
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
/**
* Hvilken hentning der gælder.