Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba2dab209 | ||
|
|
438095e252 | ||
|
|
3132bba7e5 |
@@ -848,6 +848,20 @@ paths:
|
|||||||
project_id:
|
project_id:
|
||||||
type: string
|
type: string
|
||||||
description: Where it goes when the text names no project.
|
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:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Created
|
||||||
@@ -3143,6 +3157,40 @@ paths:
|
|||||||
to: { type: string, format: date }
|
to: { type: string, format: date }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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:
|
/api/v1/export/notes.zip:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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
|
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||||
// takes, rebuilding the task tree from parent ids.
|
// takes, rebuilding the task tree from parent ids.
|
||||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||||
|
|||||||
@@ -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/account", s.handleExportAccount)
|
||||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
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}.csv", s.handleExportProject)
|
||||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||||
|
|
||||||
|
|||||||
@@ -501,6 +501,18 @@ type quickAddRequest struct {
|
|||||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
// 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.
|
// because typing it is a choice and standing there is a circumstance.
|
||||||
SectionID string `json:"section_id,omitempty"`
|
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) {
|
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
|
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
|
projectID := req.ProjectID
|
||||||
// Set when the text named a project that does not exist. The task is still
|
// 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
|
// 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
|
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 == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
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,
|
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||||
RecurrenceRule: parsed.Recurrence,
|
RecurrenceRule: parsed.Recurrence,
|
||||||
}
|
}
|
||||||
|
if parent != nil {
|
||||||
|
t.ParentID = parent.ID
|
||||||
|
}
|
||||||
if parsed.DueDate != "" {
|
if parsed.DueDate != "" {
|
||||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -1562,6 +1563,178 @@ func TestImportTodoistCSV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The whole point of import and export together: what goes in comes back out.
|
// 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) {
|
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5035,6 +5035,47 @@ test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ pa
|
|||||||
expect(trouble).toEqual([]);
|
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.
|
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
|
||||||
*
|
*
|
||||||
@@ -5094,6 +5135,132 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
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.
|
* En kodeblok med flere linjer kommer tilbage med dem alle.
|
||||||
*
|
*
|
||||||
|
|||||||
+9
-2
@@ -365,8 +365,13 @@ export const api = {
|
|||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||||
|
|
||||||
quickAdd: (text, projectId, sectionId) =>
|
quickAdd: (text, projectId, sectionId, parentId) =>
|
||||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
post('/tasks/quick-add', {
|
||||||
|
text,
|
||||||
|
project_id: projectId,
|
||||||
|
section_id: sectionId,
|
||||||
|
parent_id: parentId
|
||||||
|
}),
|
||||||
quickAddPreview: (text, signal) =>
|
quickAddPreview: (text, signal) =>
|
||||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||||
|
|
||||||
@@ -511,6 +516,8 @@ export const api = {
|
|||||||
importCSV: (data) => post('/import/csv', data),
|
importCSV: (data) => post('/import/csv', data),
|
||||||
exportAccountURL: () => '/api/v1/export/account',
|
exportAccountURL: () => '/api/v1/export/account',
|
||||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
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,
|
// 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.
|
// and the browser only does that when nothing has claimed the header.
|
||||||
importNotes: (file) => upload('/notes/import', file),
|
importNotes: (file) => upload('/notes/import', file),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { readList } from '$lib/pastelist.js';
|
||||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||||
|
|
||||||
let text = $state('');
|
let text = $state('');
|
||||||
@@ -97,6 +98,60 @@
|
|||||||
return out;
|
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) {
|
async function submit(event) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const value = text.trim();
|
const value = text.trim();
|
||||||
@@ -164,13 +219,32 @@
|
|||||||
aria-label={t('task.new')}
|
aria-label={t('task.new')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
{onpaste}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if text.trim()}
|
{#if text.trim() && !pending.length}
|
||||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||||
{/if}
|
{/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.
|
<!-- 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:
|
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
|
the placeholder disappears at the first keystroke, so the help was gone
|
||||||
@@ -190,6 +264,57 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<style>
|
<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. */
|
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
|
||||||
.syntax {
|
.syntax {
|
||||||
/* Nothing to click, and it floats over the first task in the list — which it
|
/* Nothing to click, and it floats over the first task in the list — which it
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { REPEATS } from '$lib/when.js';
|
import { REPEATS } from '$lib/when.js';
|
||||||
|
import { readList } from '$lib/pastelist.js';
|
||||||
|
|
||||||
let { task, onclose } = $props();
|
let { task, onclose } = $props();
|
||||||
|
|
||||||
@@ -282,6 +283,73 @@
|
|||||||
|
|
||||||
// --- sub-tasks ----------------------------------------------------------------
|
// --- 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) {
|
async function addSubtask(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = newSubtask.trim();
|
const text = newSubtask.trim();
|
||||||
@@ -294,6 +362,7 @@
|
|||||||
parent_id: task.id
|
parent_id: task.id
|
||||||
});
|
});
|
||||||
subtasks = [...subtasks, created];
|
subtasks = [...subtasks, created];
|
||||||
|
countSubtasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
@@ -635,8 +704,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<form onsubmit={addSubtask}>
|
<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>
|
</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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -741,6 +833,43 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.linked-note {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--s2);
|
padding: var(--s2);
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ export const da = {
|
|||||||
|
|
||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Tilføj',
|
'task.add': 'Tilføj',
|
||||||
|
'task.pastedTitle': 'Indsat liste',
|
||||||
|
'task.pastedAdd': 'Opret {n} opgaver',
|
||||||
|
'task.pastedCancel': 'Fortryd',
|
||||||
'task.new': 'Ny opgave',
|
'task.new': 'Ny opgave',
|
||||||
'task.syntaxProject': 'projekt',
|
'task.syntaxProject': 'projekt',
|
||||||
'task.syntaxSection': 'sektion',
|
'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.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.notesImported': '{n} noter og {files} filer hentet ind.',
|
||||||
'data.exportNotes': 'Alle noter som Markdown-filer',
|
'data.exportNotes': 'Alle noter som Markdown-filer',
|
||||||
|
'data.exportAllCSV': 'Alle opgaver (CSV)',
|
||||||
|
'data.exportAllICS': 'Alle opgaver (kalender)',
|
||||||
'data.exportAccount': 'Hele kontoen som JSON',
|
'data.exportAccount': 'Hele kontoen som JSON',
|
||||||
'data.exportOne': 'Ét projekt',
|
'data.exportOne': 'Ét projekt',
|
||||||
'data.pickProject': 'Vælg et projekt',
|
'data.pickProject': 'Vælg et projekt',
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export const en = {
|
|||||||
|
|
||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Add',
|
'task.add': 'Add',
|
||||||
|
'task.pastedTitle': 'Pasted list',
|
||||||
|
'task.pastedAdd': 'Add {n} tasks',
|
||||||
|
'task.pastedCancel': 'Cancel',
|
||||||
'task.new': 'New task',
|
'task.new': 'New task',
|
||||||
'task.syntaxProject': 'project',
|
'task.syntaxProject': 'project',
|
||||||
'task.syntaxSection': 'section',
|
'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.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.notesImported': '{n} notes and {files} files brought in.',
|
||||||
'data.exportNotes': 'Every note as Markdown files',
|
'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.exportAccount': 'The whole account as JSON',
|
||||||
'data.exportOne': 'One project',
|
'data.exportOne': 'One project',
|
||||||
'data.pickProject': 'Pick a project',
|
'data.pickProject': 'Pick a project',
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* En indsat liste, læst som opgaver og underopgaver.
|
||||||
|
*
|
||||||
|
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
|
||||||
|
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
|
||||||
|
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
|
||||||
|
*
|
||||||
|
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
|
||||||
|
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
|
||||||
|
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
|
||||||
|
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
|
||||||
|
* faktisk er set, klarer begge uden at kende tallet på forhånd.
|
||||||
|
*
|
||||||
|
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
|
||||||
|
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
|
||||||
|
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
|
||||||
|
* uløste opgaver, har tabt det, man indsatte den for.
|
||||||
|
*
|
||||||
|
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
|
||||||
|
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
|
||||||
|
* forstå en opgavelinje, ikke to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
|
||||||
|
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
|
||||||
|
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
|
||||||
|
const BOX = /^\[([ xX])\]\s*/;
|
||||||
|
|
||||||
|
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
|
||||||
|
function width(indent) {
|
||||||
|
return indent.replace(/\t/g, ' ').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readList(text) {
|
||||||
|
const out = [];
|
||||||
|
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
|
||||||
|
const levels = [];
|
||||||
|
|
||||||
|
for (const raw of (text ?? '').split(/\r?\n/)) {
|
||||||
|
const indent = /^[ \t]*/.exec(raw)[0];
|
||||||
|
let rest = raw.slice(indent.length).trim();
|
||||||
|
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
|
||||||
|
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
|
||||||
|
if (!rest) continue;
|
||||||
|
|
||||||
|
rest = rest.replace(MARKER, '');
|
||||||
|
let done = false;
|
||||||
|
const box = BOX.exec(rest);
|
||||||
|
if (box) {
|
||||||
|
done = box[1] !== ' ';
|
||||||
|
rest = rest.slice(box[0].length);
|
||||||
|
}
|
||||||
|
rest = rest.trim();
|
||||||
|
if (!rest) continue;
|
||||||
|
|
||||||
|
const w = width(indent);
|
||||||
|
while (levels.length && w < levels[levels.length - 1]) levels.pop();
|
||||||
|
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
|
||||||
|
|
||||||
|
out.push({ depth: levels.length - 1, text: rest, done });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -524,9 +524,9 @@ class AppState {
|
|||||||
* request is fast and the box clears immediately, which is where the
|
* request is fast and the box clears immediately, which is where the
|
||||||
* responsiveness actually needs to be.
|
* responsiveness actually needs to be.
|
||||||
*/
|
*/
|
||||||
async quickAdd(text, projectId) {
|
async quickAdd(text, projectId, sectionId, parentId) {
|
||||||
try {
|
try {
|
||||||
const task = await api.quickAdd(text, projectId);
|
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||||
this.upsert(task);
|
this.upsert(task);
|
||||||
return task;
|
return task;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -130,8 +130,20 @@
|
|||||||
// Folding the sidebar had a button and nothing else, and a rectangle with a
|
// 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
|
// 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
|
// 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.
|
// it.
|
||||||
if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) {
|
//
|
||||||
|
// 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();
|
event.preventDefault();
|
||||||
sidebar.toggle();
|
sidebar.toggle();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -393,6 +393,8 @@
|
|||||||
do with a Content-Disposition, and the session cookie rides along. -->
|
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.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||||||
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</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>
|
</div>
|
||||||
|
|
||||||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
<!-- 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>
|
<p class="hint">{t('data.importNotesHint')}</p>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="export-project">{t('data.exportOne')}</label>
|
<label for="export-project">{t('data.exportOne')}</label>
|
||||||
<select id="export-project" bind:value={exportProject}>
|
<select id="export-project" bind:value={exportProject}>
|
||||||
|
|||||||
Reference in New Issue
Block a user