Compare commits
+79
-3
@@ -263,6 +263,36 @@ paths:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/nav-order:
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: The order of the fixed views in the sidebar.
|
||||
description: |
|
||||
I dag, Kommende, Venter and the inbox, in whatever order this person wants
|
||||
them. On the account rather than in the browser: which order you want them
|
||||
in is a fact about you and should be the same on the phone as on the laptop.
|
||||
|
||||
A list of keys rather than a number per view. The set of views changes with
|
||||
the program, and a key that no longer exists costs nothing — the sidebar
|
||||
draws what it recognises and appends anything new at the end, which is what
|
||||
makes adding a view later not a migration. An empty list means the order the
|
||||
program ships with.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
order:
|
||||
type: array
|
||||
maxItems: 20
|
||||
items: { type: string }
|
||||
example: ["inbox", "today", "upcoming", "delegated"]
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -2477,6 +2507,22 @@ paths:
|
||||
created: { type: integer }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Every note as a Markdown file, in a zip.
|
||||
description: >
|
||||
One file per note, named after its title, containing exactly what is
|
||||
stored. Nothing is converted on the way out — the note on disk is already
|
||||
the file you would export — so it opens in Obsidian, in a text editor, or
|
||||
in anything else that reads Markdown.
|
||||
responses:
|
||||
"200":
|
||||
description: A zip archive.
|
||||
content:
|
||||
application/zip:
|
||||
schema: { type: string, format: binary }
|
||||
|
||||
/api/v1/notes:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -2520,6 +2566,37 @@ paths:
|
||||
schema: { $ref: "#/components/schemas/Note" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/import:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: A zip of Markdown files becomes notes.
|
||||
description: >
|
||||
The mirror of the export, and the way in from anywhere else: a folder of
|
||||
Markdown is what Obsidian, Bear, iA Writer and a shell script all produce.
|
||||
Filenames are ignored — the title is the first line of the body, here as
|
||||
everywhere, and taking it from the filename would be a second rule for the
|
||||
same thing. Directories, macOS resource forks and anything that is not .md
|
||||
are passed over.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file: { type: string, format: binary }
|
||||
responses:
|
||||
"200":
|
||||
description: Read
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
created: { type: integer }
|
||||
skipped: { type: integer, description: Files that were not usable as a note. }
|
||||
"413": { description: The archive is too large. }
|
||||
|
||||
/api/v1/notes/{noteID}:
|
||||
parameters:
|
||||
- name: noteID
|
||||
@@ -3278,7 +3355,7 @@ components:
|
||||
properties:
|
||||
id: { type: string }
|
||||
project_id: { type: string }
|
||||
title: { type: string, description: "Taken from the first line when not given." }
|
||||
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
|
||||
body: { type: string, description: Markdown }
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
@@ -3296,8 +3373,7 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Empty files it under no project." }
|
||||
title: { type: string }
|
||||
body: { type: string }
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Mailbox:
|
||||
|
||||
@@ -32,6 +32,7 @@ type userJSON struct {
|
||||
// about the work and not about the screen — the sidebar's width is the
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
|
||||
if collapsed == nil {
|
||||
collapsed = []string{}
|
||||
}
|
||||
order := u.NavOrder
|
||||
if order == nil {
|
||||
order = []string{}
|
||||
}
|
||||
return userJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetNavOrder records the order of the fixed views in the sidebar.
|
||||
//
|
||||
// Its own route for the same reason as the folds: this writes on every drop, and
|
||||
// sharing the profile's path would make a reorder send a name and a timezone it
|
||||
// was never asked to change.
|
||||
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Order []string `json:"order"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.Order) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
|
||||
s.storeError(w, r, "set nav order", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
|
||||
@@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A file inside a note follows the note, which follows its project — the same
|
||||
// chain everything else here follows. Without this a picture imported with a
|
||||
// note answered 404 while sitting in the middle of the text that named it.
|
||||
if a.NoteID != "" {
|
||||
n, err := s.db.Note(r.Context(), a.NoteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == nil {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
if n.ProjectID == "" {
|
||||
if n.CreatedBy != user.ID {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -370,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -384,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// --- notes ---------------------------------------------------------------------
|
||||
|
||||
// registerNoteTools adds the note half of the MCP surface.
|
||||
//
|
||||
// Separate from the task tools for one reason worth stating: this is how a note
|
||||
// gets in from somewhere else. Apple Notes has no export worth the name, but a
|
||||
// model running on the machine can read Notes.app and write what it finds through
|
||||
// these — one note at a time, with the text as Markdown. That is a migration
|
||||
// nobody has to build a parser for.
|
||||
func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_notes",
|
||||
Description: "Find notes by text. Matches title and body, and is generous about " +
|
||||
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
|
||||
"With no query it returns the most recent notes. This is the tool for " +
|
||||
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("Words to look for in the title and body."),
|
||||
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
|
||||
}),
|
||||
}, s.mcpSearchNotes)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_note",
|
||||
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
|
||||
"also exactly what an export produces, so nothing is converted and nothing " +
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||
}),
|
||||
}, s.mcpCreateNote)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_note",
|
||||
Description: "Change a note. Only the fields given are changed. Sending `body` " +
|
||||
"replaces the whole text, so read the note first if you mean to add to it — " +
|
||||
"and the links are read again from the new text, so a #tag removed is a link " +
|
||||
"removed.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"note_id": mcp.Str("Which note."),
|
||||
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||
}),
|
||||
}, s.mcpUpdateNote)
|
||||
}
|
||||
|
||||
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Text string `json:"text"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
var notes []store.Note
|
||||
var err error
|
||||
if strings.TrimSpace(params.Text) == "" {
|
||||
notes, err = s.db.LooseNotes(ctx, userID)
|
||||
} else {
|
||||
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The same filter the HTTP surface applies, for the same reason: a search must
|
||||
// not become a way to read what you cannot open.
|
||||
out := make([]map[string]any, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if !s.mcpMayRead(ctx, &n, userID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": n.ID, "title": n.Title, "body": n.Body,
|
||||
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"notes": out, "count": len(out)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == userID
|
||||
}
|
||||
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Body string `json:"body"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n := &store.Note{
|
||||
CreatedBy: userID,
|
||||
Body: params.Body,
|
||||
Pinned: params.Pinned,
|
||||
}
|
||||
if params.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ProjectID = params.ProjectID
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The links are reported back rather than left silent. A note whose #tag was
|
||||
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
|
||||
// the row that was written.
|
||||
return map[string]any{
|
||||
"id": n.ID, "title": n.Title, "links": n.Links,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n, err := s.db.Note(ctx, params.NoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil || !s.mcpMayRead(ctx, n, userID) {
|
||||
return nil, fmt.Errorf("no such note")
|
||||
}
|
||||
if n.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Body != nil {
|
||||
n.Body = *params.Body
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
n.Pinned = *params.Pinned
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -57,7 +64,10 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
found, err := s.db.LooseNotes(r.Context(), user.ID)
|
||||
// Everything they can see, not only the loose ones. Filing a note in a project
|
||||
// is how it gets shared, and a list that dropped it at that moment made sharing
|
||||
// look like deleting.
|
||||
found, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
@@ -94,9 +104,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
// No title. It is the note's first line and is derived on every save — a field
|
||||
// here would be a promise this server cannot keep.
|
||||
type noteBody struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
@@ -116,9 +127,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Title != nil {
|
||||
n.Title = *body.Title
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -161,9 +169,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Title != nil {
|
||||
n.Title = *body.Title
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -210,3 +215,262 @@ func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
}
|
||||
|
||||
// handleExportNotes writes every note this person can see as a zip of Markdown
|
||||
// files, one per note.
|
||||
//
|
||||
// A zip of plain files rather than one bundle in a format of our own: what comes
|
||||
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
|
||||
// That is the promise the whole design was arranged around — the note on disk is
|
||||
// already the file you would export — and this is where it is either kept or not.
|
||||
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
notes, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export notes", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
|
||||
// twice is a zip that loses one of them.
|
||||
used := map[string]int{}
|
||||
for _, n := range notes {
|
||||
name := noteFilename(n.Title)
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: name + ".md",
|
||||
Method: zip.Deflate,
|
||||
Modified: n.UpdatedAt,
|
||||
})
|
||||
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 says is truncated —
|
||||
// better than a complete-looking file with a note missing from it.
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(f, n.Body); err != nil {
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noteFilename makes a title safe to be a filename on any of the three systems
|
||||
// people actually use, without turning it into something unrecognisable.
|
||||
func noteFilename(title string) string {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
name = "uden titel"
|
||||
}
|
||||
// Windows refuses these outright; a slash would make directories on the others.
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
// Long titles are the first line of a note, which can be a whole sentence.
|
||||
if len([]rune(name)) > 80 {
|
||||
name = string([]rune(name)[:80])
|
||||
}
|
||||
return strings.TrimRight(name, " .")
|
||||
}
|
||||
|
||||
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
// this program goes back into it. It is also the way in from anywhere else, since
|
||||
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
|
||||
// produce — and what the Apple Notes exporter in tools/ writes.
|
||||
//
|
||||
// Filenames are ignored. The title is the first line of the body, everywhere else
|
||||
// in this program, and making it the filename here would be a second rule for the
|
||||
// same thing that disagrees the moment somebody renames a file.
|
||||
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
created, skipped, files := 0, 0, 0
|
||||
|
||||
// Two passes. Everything that is not Markdown is stored first, so that when a
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
|
||||
rc.Close()
|
||||
if err != nil || strings.TrimSpace(string(body)) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: string(body)}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
|
||||
n.Body = rewritten
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "skipped": skipped, "files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// skipFile is the things in an archive that are not content: directories' own
|
||||
// entries, macOS resource forks, and anything trying to climb out of the archive.
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
return strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") ||
|
||||
strings.Contains(name, "..")
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
// where they now live. Returns the new body and how many were attached.
|
||||
//
|
||||
// Only the files this note refers to. An archive is one folder for many notes, and
|
||||
// hanging every picture on every note would turn a library of a hundred into ten
|
||||
// thousand attachments.
|
||||
func (s *Server) attachToNote(
|
||||
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
|
||||
) (string, int) {
|
||||
body := n.Body
|
||||
attached := 0
|
||||
|
||||
for _, f := range zr.File {
|
||||
hashed, ok := stored[f.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: base,
|
||||
MimeType: mimeOf(base),
|
||||
Size: int64(f.UncompressedSize64),
|
||||
Path: hashed,
|
||||
UploadedBy: n.CreatedBy,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
s.log.Warn("attach to note", "err", err, "file", base)
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -113,3 +119,248 @@ func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
||||
t.Errorf("%d notes still point at Firma", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The export is the promise the whole design was arranged around: the note on
|
||||
// disk is already the file you would export, so nothing is converted on the way
|
||||
// out and nothing can be lost in the conversion.
|
||||
func TestNotesExportAsMarkdownFiles(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
bodies := []string{
|
||||
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
|
||||
"Slash/i/titlen\n\nog noget tekst",
|
||||
"# Møde med Anders\n\nen anden note med samme titel",
|
||||
}
|
||||
for _, body := range bodies {
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("export: status %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("content type is %q", ct)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("the archive does not open: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
byName[f.Name] = string(text)
|
||||
}
|
||||
|
||||
// Exactly what was stored, byte for byte. A converted export is an export that
|
||||
// can be wrong.
|
||||
if got := byName["Møde med Anders.md"]; got != bodies[0] {
|
||||
t.Errorf("the note came out as %q", got)
|
||||
}
|
||||
// A slash in a title would make a directory, and on Windows it is refused
|
||||
// outright.
|
||||
if _, ok := byName["Slash-i-titlen.md"]; !ok {
|
||||
t.Errorf("the awkward title became %v", keysOf(byName))
|
||||
}
|
||||
// Two notes with the same title are two notes; one entry twice is one lost.
|
||||
if _, ok := byName["Møde med Anders (2).md"]; !ok {
|
||||
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Out and back in again. If an export cannot be imported, it is not an export —
|
||||
// it is a file somebody hopes will be readable later.
|
||||
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
files := map[string]string{
|
||||
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
|
||||
"noget andet.md": "Løse tanker\n\nog en linje til",
|
||||
// A zip made on a Mac carries these beside every file. Importing them would
|
||||
// double the whole library, with each copy full of binary rubbish.
|
||||
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
|
||||
"billede.png": "ikke en note",
|
||||
"tom.md": " \n ",
|
||||
}
|
||||
for name, body := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("import: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct{ Created, Skipped int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 {
|
||||
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
|
||||
}
|
||||
|
||||
// The title came from the first line, not from the filename — which is the same
|
||||
// rule as everywhere else, and the reason renaming a file cannot rename a note.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
|
||||
titles := map[string]bool{}
|
||||
for _, raw := range listed["notes"].([]any) {
|
||||
titles[raw.(map[string]any)["title"].(string)] = true
|
||||
}
|
||||
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
|
||||
t.Errorf("the titles came out as %v", titles)
|
||||
}
|
||||
|
||||
// And the #tag was read on the way in, so an imported note lands on its project
|
||||
// exactly as a typed one does.
|
||||
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(linked["notes"].([]any)) != 1 {
|
||||
t.Errorf("the imported note did not link to its project")
|
||||
}
|
||||
}
|
||||
|
||||
// A note from Apple Notes is full of photographs. One that arrives as words alone
|
||||
// is not that note — it is a summary of it.
|
||||
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
write := func(name, body string) {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n\n\nog resten af teksten")
|
||||
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
|
||||
// Another note in the same archive, which must not inherit the first one's
|
||||
// picture: an archive is one folder for many notes.
|
||||
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct{ Created, Skipped, Files int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 || out.Files != 1 {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
if strings.Contains(body, "vedhaeftninger/bon.png") {
|
||||
t.Errorf("the link still points into the archive: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "/api/v1/attachments/") {
|
||||
t.Errorf("the picture was not attached: %q", body)
|
||||
}
|
||||
// And it is still where it was written, in the middle of the note.
|
||||
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
|
||||
t.Errorf("the note was rearranged: %q", body)
|
||||
}
|
||||
|
||||
// The picture is really there and really readable.
|
||||
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
|
||||
id = strings.SplitN(id, ")", 2)[0]
|
||||
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
|
||||
if got.StatusCode != http.StatusOK {
|
||||
t.Errorf("the attachment answers %d", got.StatusCode)
|
||||
}
|
||||
|
||||
// The other note did not inherit it.
|
||||
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
|
||||
for _, raw := range other["notes"].([]any) {
|
||||
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
|
||||
t.Error("a note that mentioned no picture was given one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
@@ -282,6 +283,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Route("/notes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotes)
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
@@ -320,6 +322,7 @@ 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/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type Attachment struct {
|
||||
// GroupID hangs the file on a project group rather than on any one task in
|
||||
// it: the contract that governs all of "Arbejde", not a step in it.
|
||||
GroupID string
|
||||
NoteID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Size int64
|
||||
@@ -157,17 +158,18 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, group_id, note_id, filename, mime_type,
|
||||
size, path, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
|
||||
nullString(a.NoteID),
|
||||
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -176,11 +178,24 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
// ListNoteAttachments is the files inside one note — the pictures and scans that
|
||||
// came with it, and anything added since.
|
||||
func (db *DB) ListNoteAttachments(ctx context.Context, noteID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE note_id = ? ORDER BY created_at`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
// ListGroupAttachments is the documents that belong to a whole group rather than
|
||||
// to anything inside it.
|
||||
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -195,7 +210,7 @@ func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (ma
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||
ORDER BY created_at`, args...)
|
||||
if err != nil {
|
||||
@@ -218,15 +233,16 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
out := []Attachment{}
|
||||
for rows.Next() {
|
||||
var a Attachment
|
||||
var taskID, commentID, groupID sql.NullString
|
||||
var taskID, commentID, groupID, noteID sql.NullString
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, ¬eID, &a.Filename, &a.MimeType,
|
||||
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CommentID = commentID.String
|
||||
a.GroupID = groupID.String
|
||||
a.NoteID = noteID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, a)
|
||||
}
|
||||
@@ -235,7 +251,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
|
||||
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, note_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE id = ?`, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- The order of the fixed views in the sidebar.
|
||||
--
|
||||
-- On the account rather than in the browser, and for the reason the sidebar's
|
||||
-- width is the other way round: a width is a fact about the screen you are at, and
|
||||
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
|
||||
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
|
||||
-- on the laptop as on the phone.
|
||||
--
|
||||
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
|
||||
-- is: the set of views changes with the program, and a key that no longer exists
|
||||
-- costs nothing — the sidebar draws what it recognises and appends anything new at
|
||||
-- the end. That is what makes adding a view later not a migration.
|
||||
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- #garageristeriet and #GarageRisteriet are the same project.
|
||||
--
|
||||
-- They were not, because the key was stored exactly as typed and looked up the
|
||||
-- same way — so a note that named a project in lower case simply did not appear
|
||||
-- on it, silently and with nothing to suggest why.
|
||||
--
|
||||
-- Folded on the way in from now on; this brings the rows already written into
|
||||
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
|
||||
-- and "møde" are two notes.
|
||||
--
|
||||
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
|
||||
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
|
||||
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
|
||||
CREATE TABLE note_links_folded (
|
||||
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
|
||||
target_id TEXT NOT NULL,
|
||||
PRIMARY KEY (note_id, kind, target_id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
|
||||
SELECT note_id, kind,
|
||||
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
|
||||
FROM note_links;
|
||||
|
||||
DROP TABLE note_links;
|
||||
ALTER TABLE note_links_folded RENAME TO note_links;
|
||||
|
||||
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- A note can carry files, the way a task and a group already can.
|
||||
--
|
||||
-- Apple Notes is full of photographs, scans and pasted screenshots, and a note
|
||||
-- that arrives here as words alone is not that note — it is a summary of it. The
|
||||
-- import writes the files as attachments and rewrites the links in the text, so a
|
||||
-- picture that was in the middle of a paragraph is still in the middle of it.
|
||||
--
|
||||
-- Same table as everything else attaches to, rather than one of its own. The files
|
||||
-- are content-addressed and deduplicated on disk, the upload path is written and
|
||||
-- tested, and the cleanup that removes an orphan already walks this table. A
|
||||
-- second table would be a second everything.
|
||||
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
CREATE TABLE attachments_with_notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
|
||||
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
|
||||
note_id TEXT REFERENCES notes (id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
-- Counted, not spelled out as pairs: with four columns the pairwise version is
|
||||
-- six clauses, and the one that is wrong looks exactly like the one that is right.
|
||||
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL)
|
||||
+ (group_id IS NOT NULL) + (note_id IS NOT NULL) = 1)
|
||||
);
|
||||
|
||||
INSERT INTO attachments_with_notes
|
||||
(id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||
SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments;
|
||||
|
||||
DROP TABLE attachments;
|
||||
|
||||
ALTER TABLE attachments_with_notes RENAME TO attachments;
|
||||
|
||||
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_note ON attachments (note_id) WHERE note_id IS NOT NULL;
|
||||
+37
-6
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
||||
}
|
||||
n.UpdatedAt = now
|
||||
|
||||
// A note with no title takes its first line, the way Apple Notes does. A list
|
||||
// of untitled notes is a list nobody can read.
|
||||
if strings.TrimSpace(n.Title) == "" {
|
||||
n.Title = firstLine(n.Body)
|
||||
}
|
||||
// The title is always the first line, the way Apple Notes does it. Not "when it
|
||||
// is empty": derived once and then left alone, it goes stale the moment somebody
|
||||
// rewrites the opening of a note, and the list ends up calling a note by a name
|
||||
// that is nowhere in it. The column is a cache of the body, not a field beside
|
||||
// it, and every caller gets the same answer because none of them can set it.
|
||||
n.Title = firstLine(n.Body)
|
||||
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var projectID any
|
||||
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
|
||||
add("task", strings.ToLower(m[1]))
|
||||
}
|
||||
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
||||
add("project", m[1])
|
||||
// Folded, because #garageristeriet and #GarageRisteriet are the same project
|
||||
// to everybody except a database. The key is stored folded and looked up
|
||||
// folded, so the comparison stays an exact match and keeps using its index —
|
||||
// a comparison that lowered the column instead would read the whole table.
|
||||
//
|
||||
// The project's real spelling is not lost: it is on the project, which is
|
||||
// what the interface shows. This column is a key, not a label.
|
||||
add("project", strings.ToLower(m[1]))
|
||||
}
|
||||
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
||||
add("note", strings.TrimSpace(m[1]))
|
||||
@@ -184,11 +192,34 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID)
|
||||
}
|
||||
|
||||
// AllNotes is everything a person can see: their own loose ones, and the ones in
|
||||
// projects they are part of.
|
||||
//
|
||||
// This is what the Notes page lists. Filing a note in a project is how it gets
|
||||
// shared, and a list that dropped it at that moment would make sharing look like
|
||||
// deleting — which is exactly how it read before this existed.
|
||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
(project_id IS NULL AND created_by = ?)
|
||||
OR project_id IN (
|
||||
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
|
||||
UNION
|
||||
SELECT project_id FROM project_members WHERE user_id = ?
|
||||
)
|
||||
)
|
||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
||||
}
|
||||
|
||||
// NotesLinking answers the backwards question: what points at this thing.
|
||||
//
|
||||
// The reason note_links exists. Without it this would mean reading every note's
|
||||
// prose, and a panel that slow is one nobody leaves open.
|
||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||
if kind == "project" {
|
||||
targetID = strings.ToLower(targetID)
|
||||
}
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
||||
|
||||
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
|
||||
if len(projects) != 2 {
|
||||
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
||||
}
|
||||
if projects[0] != "Firma" || projects[1] != "Regnskab" {
|
||||
// Folded: the key is a key, not a label. #firma and #Firma are the same project
|
||||
// to everybody except a database, and the project's own spelling is on the
|
||||
// project, which is what the interface shows.
|
||||
if projects[0] != "firma" || projects[1] != "regnskab" {
|
||||
t.Errorf("got %v", projects)
|
||||
}
|
||||
}
|
||||
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The title is the first line now, so that is where it goes.
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Title: "Grønt regnskab",
|
||||
Body: "Målt på årsbasis for Århus.",
|
||||
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
|
||||
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
||||
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
||||
{"the same tag twice is one link", "#Firma og #Firma igen",
|
||||
[]NoteLink{{"project", "Firma"}}},
|
||||
[]NoteLink{{"project", "firma"}}},
|
||||
{"two spellings are one tag", "#Firma og #firma",
|
||||
[]NoteLink{{"project", "firma"}}},
|
||||
{"a heading is not a tag", "# Overskrift", nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
|
||||
t.Errorf("after deleting one, the count is %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The title follows the first line, always — not only the first time.
|
||||
//
|
||||
// Derived once and then left alone, it goes stale the moment somebody rewrites
|
||||
// the opening, and the list ends up calling a note by a name that is nowhere in
|
||||
// it. That is exactly what happened: a note headed "Ny note" was listed as
|
||||
// "fdgfgfgh", which was what its first line had been some edits ago.
|
||||
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Første udgave" {
|
||||
t.Fatalf("title started as %q", n.Title)
|
||||
}
|
||||
|
||||
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Ny note" {
|
||||
t.Errorf("after rewriting the first line the title is %q", n.Title)
|
||||
}
|
||||
|
||||
back, err := db.Note(ctx, n.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Title != "Ny note" {
|
||||
t.Errorf("the stored title is %q", back.Title)
|
||||
}
|
||||
|
||||
// And a title cannot be set beside the text, because then the two can disagree.
|
||||
n.Title = "noget andet"
|
||||
if err := db.SaveNote(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Title != "Ny note" {
|
||||
t.Errorf("a title set by hand survived as %q", n.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// A note that names a project in the other case must still turn up on it.
|
||||
//
|
||||
// It did not, and nothing said why: the key was stored exactly as typed and
|
||||
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
|
||||
// different things that looked identical to a person.
|
||||
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.SaveNote(ctx, &Note{
|
||||
CreatedBy: userID,
|
||||
Body: "Bønnerne kommer fredag #garageristeriet",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
|
||||
found, err := db.NotesLinking(ctx, "project", spelling)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
|
||||
}
|
||||
}
|
||||
|
||||
// A note title is a title, not a key: two notes called "Møde" and "møde" are
|
||||
// two notes, and folding them together would merge things somebody kept apart.
|
||||
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
|
||||
t.Error("a note title was folded")
|
||||
}
|
||||
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
|
||||
t.Error("a note title did not match itself")
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -36,8 +36,13 @@ type User struct {
|
||||
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||
// opposite case and is stored per browser.
|
||||
SidebarCollapsed []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
||||
// means the order the program ships with. Unknown keys are ignored and missing
|
||||
// ones are appended, so adding a view later is not a migration.
|
||||
NavOrder []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
||||
|
||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||
var u User
|
||||
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
var totpEnabled, isAdmin int
|
||||
var created, updated int64
|
||||
|
||||
var collapsed string
|
||||
var collapsed, navOrder string
|
||||
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||
u.SidebarCollapsed = []string{}
|
||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||
u.NavOrder = []string{}
|
||||
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// SetNavOrder records the order of the fixed views.
|
||||
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
|
||||
if order == nil {
|
||||
order = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
|
||||
string(raw), time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||
//
|
||||
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||
|
||||
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apple Noter → Markdown, med formateringen i behold.
|
||||
|
||||
Den første udgave af det her brugte `textutil -convert txt`, som er hurtigt at
|
||||
skrive og smider alt væk: overskrifter, fed, monospace, lister. Ti prøvenoter
|
||||
viste hvad det koster — en note er ikke kun sine ord, den er også hvordan de er
|
||||
sat op, og et referat med overskrifter er ikke det samme referat som en klump.
|
||||
|
||||
Apple Noter er HTML indeni, og HTML kan læses. Kortet er lille, fordi Noter selv
|
||||
bruger et lille sæt:
|
||||
|
||||
<h1> <h2> <h3> overskrifter → # ## ###
|
||||
<b> <i> <u> fremhævning → ** * <u>
|
||||
<tt> monospace → ```-blok
|
||||
<ul><li> punktliste → -
|
||||
<ol><li> nummereret → 1.
|
||||
<div> en linje → linjeskift
|
||||
<br> en tom linje → tom linje
|
||||
|
||||
Kører på din egen maskine og sender ingenting nogen steder.
|
||||
|
||||
./apple-notes-til-markdown.py ~/Desktop/noter # alle
|
||||
./apple-notes-til-markdown.py ~/Desktop/noter 10 # de første ti
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
|
||||
FORBIDDEN = set('/\\:*?"<>|')
|
||||
|
||||
|
||||
def osascript(script: str) -> str:
|
||||
"""Ét kald til Noter. Fejl er tomme frem for fatale: en enkelt note, der ikke
|
||||
kan læses, skal ikke stoppe en kørsel på tolv hundrede."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
return out.stdout.rstrip("\n")
|
||||
except subprocess.SubprocessError:
|
||||
return ""
|
||||
|
||||
|
||||
class NoteParser(HTMLParser):
|
||||
"""Apple Noters HTML til Markdown.
|
||||
|
||||
Linjebaseret, fordi Noter er linjebaseret: hver <div> er en linje, og det er
|
||||
dét, personen skrev. En rigtig Markdown-gengiver ville slå linjer sammen til
|
||||
afsnit, hvilket er korrekt Markdown og forkert i forhold til noten.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.lines: list[str] = []
|
||||
self.line: list[str] = []
|
||||
self.block: str | None = None # h1, h2 …
|
||||
self.mono = False # den aktuelle linje er monospace
|
||||
self.mono_lines: list[str] = [] # samlet op til én kodeblok
|
||||
self.list_kind: list[str] = [] # ul/ol i lag
|
||||
self.list_no: list[int] = []
|
||||
self.marks: list[str] = [] # b, i, u i lag
|
||||
|
||||
# --- linjer ---------------------------------------------------------------
|
||||
|
||||
def _flush_line(self):
|
||||
text = "".join(self.line).rstrip()
|
||||
self.line = []
|
||||
mono, self.mono = self.mono, False
|
||||
|
||||
if mono:
|
||||
# Monospace samles op og skrives som én blok, ikke som en række
|
||||
# enkeltlinjer: en terminaludskrift er én ting.
|
||||
self.mono_lines.append(text)
|
||||
return
|
||||
|
||||
# En tom linje lukker ikke en kodeblok. Hver linje i Noter er pakket ind i
|
||||
# sin egen <div>, og dens starttag tømmer den forrige linje — så mellem to
|
||||
# <tt>-linjer kommer der altid en tom flush forbi. Lod man den lukke
|
||||
# blokken, blev hver eneste linje i en terminaludskrift sin egen blok.
|
||||
if not text and self.mono_lines:
|
||||
return
|
||||
|
||||
self._close_mono()
|
||||
|
||||
if self.block:
|
||||
self.lines.append(BLOCK_PREFIX[self.block] + text if text else "")
|
||||
return
|
||||
if self.list_kind:
|
||||
if not text:
|
||||
return
|
||||
if self.list_kind[-1] == "ol":
|
||||
self.list_no[-1] += 1
|
||||
self.lines.append(f"{self.list_no[-1]}. {text}")
|
||||
else:
|
||||
self.lines.append(f"- {text}")
|
||||
return
|
||||
self.lines.append(text)
|
||||
|
||||
def _close_mono(self):
|
||||
if not self.mono_lines:
|
||||
return
|
||||
# Tomme linjer i enderne er afstand omkring blokken, ikke en del af koden.
|
||||
block = self.mono_lines
|
||||
while block and not block[0].strip():
|
||||
block.pop(0)
|
||||
while block and not block[-1].strip():
|
||||
block.pop()
|
||||
if block:
|
||||
self.lines.append("```")
|
||||
self.lines.extend(block)
|
||||
self.lines.append("```")
|
||||
# Luft efter blokken. Uden den klæber den næste sætning til den
|
||||
# afsluttende hegnslinje, og en Markdown-læser er i sin gode ret til
|
||||
# at læse dem som ét.
|
||||
self.lines.append("")
|
||||
self.mono_lines = []
|
||||
|
||||
# --- tags -----------------------------------------------------------------
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in BLOCK_PREFIX:
|
||||
self._flush_line()
|
||||
self.block = tag
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
self.list_kind.append(tag)
|
||||
self.list_no.append(0)
|
||||
elif tag == "li":
|
||||
self._flush_line()
|
||||
elif tag in ("tt", "code", "pre"):
|
||||
# Ikke et linjeskift: <tt> står inde i den <div>, der ER linjen.
|
||||
# Mærket sættes på linjen og aflæses, når den skrives ud.
|
||||
self.mono = True
|
||||
elif tag in ("b", "strong"):
|
||||
self.marks.append("**")
|
||||
self.line.append("**")
|
||||
elif tag in ("i", "em"):
|
||||
self.marks.append("*")
|
||||
self.line.append("*")
|
||||
elif tag == "u":
|
||||
self.marks.append("</u>")
|
||||
self.line.append("<u>")
|
||||
elif tag == "br":
|
||||
self._flush_line()
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in BLOCK_PREFIX:
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
if self.list_kind:
|
||||
self.list_kind.pop()
|
||||
self.list_no.pop()
|
||||
elif tag == "li":
|
||||
self._flush_line()
|
||||
elif tag in ("tt", "code", "pre"):
|
||||
# Blokken lukkes IKKE her. To <tt>-linjer efter hinanden er én
|
||||
# terminaludskrift, ikke to; den lukkes, når en linje uden monospace
|
||||
# skrives ud, hvilket _flush_line sørger for.
|
||||
pass
|
||||
elif tag in ("b", "strong", "i", "em", "u"):
|
||||
if self.marks:
|
||||
self.line.append(self.marks.pop())
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
|
||||
def handle_data(self, data):
|
||||
self.line.append(data)
|
||||
|
||||
def result(self) -> str:
|
||||
self._flush_line()
|
||||
self._close_mono()
|
||||
# Tomme linjer i stimer bliver til én: Noter er fuld af <div><br></div>,
|
||||
# og fem tomme linjer i træk er ikke afstand, det er rod.
|
||||
out, blanks = [], 0
|
||||
for line in self.lines:
|
||||
if line.strip():
|
||||
blanks = 0
|
||||
out.append(line)
|
||||
else:
|
||||
blanks += 1
|
||||
if blanks == 1:
|
||||
out.append("")
|
||||
while out and not out[-1].strip():
|
||||
out.pop()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def to_markdown(body: str) -> str:
|
||||
p = NoteParser()
|
||||
p.feed(body)
|
||||
p.close()
|
||||
return p.result()
|
||||
|
||||
|
||||
def safe_name(title: str, fallback: str) -> str:
|
||||
name = "".join("-" if c in FORBIDDEN or ord(c) < 32 else c for c in title).strip()
|
||||
name = name.rstrip(" .")
|
||||
return name[:80] or fallback
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Id'erne først, i ét kald.
|
||||
#
|
||||
# Ikke "note 1", "note 2" …: Noter sorterer listen efter ændringsdato, så et
|
||||
# indeks peger på noget andet, hvis noget flytter sig imellem to kald — og
|
||||
# navn og krop blev hentet hver for sig. Resultatet var en note med én titel
|
||||
# og en andens indhold, hvilket først ses, når man læser dem igennem. Et id
|
||||
# peger på den samme note hver gang.
|
||||
ids = osascript(
|
||||
'tell application "Notes" to return id of every note'
|
||||
)
|
||||
if not ids:
|
||||
print("Noter svarer ikke. Første gang skal macOS have lov — se efter dialogen.")
|
||||
return 1
|
||||
ids = [x.strip() for x in ids.split(",") if x.strip()]
|
||||
|
||||
total = len(ids)
|
||||
print(f"Fandt {total} noter.")
|
||||
if limit and limit < total:
|
||||
ids = ids[:limit]
|
||||
total = limit
|
||||
print(f"Tager de første {total}.")
|
||||
|
||||
used: dict[str, int] = {}
|
||||
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
|
||||
|
||||
for i, note_id in enumerate(ids, start=1):
|
||||
# Navn og krop i ét kald, adskilt af noget, der ikke står i en note.
|
||||
# Hentet hver for sig kunne de nå at komme fra hver sin note.
|
||||
both = osascript(
|
||||
f'tell application "Notes" to tell note id "{note_id}" '
|
||||
f'to return name & "\u241E" & body'
|
||||
)
|
||||
title, _, body = both.partition("\u241E")
|
||||
title = title.strip() or f"uden titel {i}"
|
||||
text = to_markdown(body)
|
||||
|
||||
# Kroppen begynder med notens egen titel. Skrives der en overskrift
|
||||
# ovenover, står titlen to gange i hver eneste note.
|
||||
#
|
||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
||||
# starttag tømmer linjen før den — så første linje er tom, og
|
||||
# sammenligningen så på ingenting.
|
||||
# Titlen kan være delt over flere overskrifter.
|
||||
#
|
||||
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||
#
|
||||
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||
lines = text.split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
|
||||
taken, joined = 0, ""
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
taken += 1
|
||||
continue
|
||||
if not line.lstrip().startswith("#"):
|
||||
break
|
||||
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||
if not title.strip().startswith(candidate):
|
||||
break
|
||||
joined, taken = candidate, taken + 1
|
||||
if joined == title.strip():
|
||||
break
|
||||
|
||||
if joined == title.strip():
|
||||
lines = lines[taken:]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
text = "\n".join(lines)
|
||||
|
||||
name = safe_name(title, f"note-{i}")
|
||||
used[name] = used.get(name, 0) + 1
|
||||
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(f"# {title}\n\n{text}\n")
|
||||
|
||||
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
|
||||
# note, der kommer over som ord alene, er ikke den note.
|
||||
count = osascript(
|
||||
f'tell application "Notes" to return count of attachments of note id "{note_id}"'
|
||||
)
|
||||
if count.isdigit() and int(count) > 0:
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
for j in range(1, int(count) + 1):
|
||||
a_name = osascript(
|
||||
f'tell application "Notes" to return name of attachment {j} '
|
||||
f'of note id "{note_id}"'
|
||||
) or f"bilag-{i}-{j}"
|
||||
a_file = f"{i}-{j}-" + safe_name(a_name, f"bilag-{i}-{j}")
|
||||
target = os.path.join(attachments_dir, a_file)
|
||||
# Nogle vedhæftninger — links til websider, kort — har ingen
|
||||
# fil bag sig og fejler her. De springes over.
|
||||
osascript(
|
||||
f'tell application "Notes" to save attachment {j} '
|
||||
f'of note id "{note_id}" in POSIX file "{target}"'
|
||||
)
|
||||
if os.path.exists(target):
|
||||
f.write(f"\n})\n")
|
||||
|
||||
if i % 25 == 0:
|
||||
print(f" {i} / {total}")
|
||||
|
||||
print(f"\nFærdig. {total} noter ligger i:\n {out_dir}\n")
|
||||
print("Pak dem og importér zip'en under Indstillinger → Data:")
|
||||
print(f' cd "{os.path.dirname(out_dir)}" && zip -r "{os.path.basename(out_dir)}.zip" "{os.path.basename(out_dir)}"')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||
|
||||
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||
|
||||
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||
portion, ville pege på ingenting.
|
||||
|
||||
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||
# portion mere end en, der ryger på målstregen.
|
||||
DEFAULT_LIMIT_MB = 40
|
||||
|
||||
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||
out_dir = src.rstrip("/") + "-portioner"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||
if not notes:
|
||||
print(f"Ingen .md-filer i {src}")
|
||||
return 1
|
||||
|
||||
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||
weighed = []
|
||||
for name in notes:
|
||||
path = os.path.join(src, name)
|
||||
text = open(path, encoding="utf-8", errors="replace").read()
|
||||
files = []
|
||||
size = os.path.getsize(path)
|
||||
for rel in LINK.findall(text):
|
||||
f = os.path.join(src, rel)
|
||||
if os.path.exists(f):
|
||||
files.append(rel)
|
||||
size += os.path.getsize(f)
|
||||
weighed.append((name, files, size))
|
||||
|
||||
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||
# kan sendes.
|
||||
batches, current, current_size = [], [], 0
|
||||
for item in weighed:
|
||||
if current and current_size + item[2] > limit:
|
||||
batches.append(current)
|
||||
current, current_size = [], 0
|
||||
current.append(item)
|
||||
current_size += item[2]
|
||||
if current:
|
||||
batches.append(current)
|
||||
|
||||
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||
for i, batch in enumerate(batches, start=1):
|
||||
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||
seen = set()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, files, _ in batch:
|
||||
z.write(os.path.join(src, name), name)
|
||||
for rel in files:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
z.write(os.path.join(src, rel), rel)
|
||||
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||
|
||||
print(f"\nLigger i:\n {out_dir}")
|
||||
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+438
-1
@@ -1464,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
||||
// Færdige left the sidebar: it is a rare errand next to four entries read every
|
||||
// day, and it now sits with the other "where did that go" answers.
|
||||
await page.goto('/indstillinger/data');
|
||||
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
||||
|
||||
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
@@ -1872,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
||||
await box.fill('første i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
// In the project itself before looking at the sidebar. Without this the count
|
||||
// can be read while the task is still on its way, or — worse — after it landed
|
||||
// in the inbox because the box that took the text belonged to the previous
|
||||
// view. Then the assertion below fails for a reason that has nothing to do with
|
||||
// what it is testing.
|
||||
await expect(page.getByText('første', { exact: true })).toBeVisible();
|
||||
|
||||
// Reloaded before looking. What is asserted is that the number is open tasks
|
||||
// and comes from the server, which holds on any load.
|
||||
//
|
||||
@@ -1951,3 +1961,430 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await page_.click();
|
||||
|
||||
// Typed and formatted the way somebody would: pick a style, write, pick another.
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Titel' }).click();
|
||||
await page.keyboard.type('Møde med Anders');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
|
||||
|
||||
// The styles are real elements, not asterisks on screen. This is the whole of
|
||||
// "it should feel like Apple Notes".
|
||||
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
|
||||
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
|
||||
|
||||
// Bold through the toolbar, on a selection made with the keyboard. Not
|
||||
// Shift+Home — in a contenteditable that is the start of the document, so it
|
||||
// took the heading with it and the button toggled bold off instead of on. Not a
|
||||
// double-click either: it leaves the selection empty here.
|
||||
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
|
||||
await page.getByRole('button', { name: 'Fed' }).click();
|
||||
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
|
||||
// both are read as bold on the way back. Asserting one of them would be
|
||||
// asserting which browser this is.
|
||||
await expect(page_.locator('b, strong')).toBeVisible();
|
||||
|
||||
// And the round trip, which is the thing that can quietly ruin a note: what is
|
||||
// stored is Markdown, and reopening it has to give back the same document. A
|
||||
// converter that disagrees with itself reshapes a note a little on every save.
|
||||
await page_.blur();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
||||
|
||||
const back = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(back.locator('h1')).toHaveText('Møde med Anders');
|
||||
await expect(back.locator('b, strong')).toBeVisible();
|
||||
await expect(back).toContainText('kaffe hver uge');
|
||||
// No stray Markdown on screen: if the marks are showing, the conversion failed
|
||||
// in the direction nobody notices until a note looks wrong.
|
||||
await expect(back).not.toContainText('**');
|
||||
await expect(back).not.toContainText('# ');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Delenoter');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Fælles aftale om leveringen');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Shared by being filed in a project. Not an access list of its own.
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
|
||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||
|
||||
// And it is really there, not merely labelled: the project's own page shows it.
|
||||
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
|
||||
|
||||
// Taken back out, it is the author's alone again.
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
||||
await expect(page.getByText('din igen')).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
const face = () =>
|
||||
page.evaluate(() => getComputedStyle(document.body).fontFamily);
|
||||
const ground = () =>
|
||||
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
|
||||
const startFace = await face();
|
||||
await page.getByRole('button', { name: /^Rolig/ }).click();
|
||||
const serif = await face();
|
||||
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
|
||||
expect(serif.toLowerCase()).toContain('serif');
|
||||
|
||||
// The other axis is untouched: choosing a face must not drag the palette with
|
||||
// it. That is the whole reason they are two settings and not one list.
|
||||
const darkGround = await ground();
|
||||
await page.getByRole('button', { name: /^Papir/ }).click();
|
||||
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
|
||||
expect(await face(), 'temaet ændrede skriften').toBe(serif);
|
||||
|
||||
// And it is on before the first paint, not applied afterwards — otherwise the
|
||||
// page is laid out once in one face and again in another.
|
||||
await page.reload();
|
||||
expect(await face()).toBe(serif);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
|
||||
|
||||
// Back to the default, which carries no attribute at all.
|
||||
await page.getByRole('button', { name: /^Verdande/ }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
// Straight in, without choosing a style. The first line is the title because
|
||||
// that is what the list calls the note by.
|
||||
await page.keyboard.type('Titelprøven');
|
||||
await expect(ed.locator('h1')).toHaveText('Titelprøven');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
|
||||
|
||||
// And the line after is body, not a second title. A note where every line is a
|
||||
// heading is a note with no heading at all.
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
|
||||
|
||||
// A third line stays body too.
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Og han ringer selv.');
|
||||
await expect(ed.locator('h1')).toHaveCount(1);
|
||||
|
||||
// It survives the trip through Markdown: the title is a heading again on the
|
||||
// way back, and the body is not.
|
||||
await ed.blur();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Titelprøven/ }).click();
|
||||
|
||||
const back = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(back.locator('h1')).toHaveText('Titelprøven');
|
||||
await expect(back.locator('h1')).toHaveCount(1);
|
||||
await expect(back).toContainText('ringer selv');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
await page.keyboard.type('Første udkast');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Half a tag is enough. The name is long and nobody should have to spell it.
|
||||
await page.keyboard.type('Handler om #Forslags');
|
||||
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
|
||||
await expect(option).toBeVisible();
|
||||
|
||||
// Return takes the highlighted one — the list owns the key while it is open.
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(ed).toContainText('#Forslagsprojektet');
|
||||
await expect(option).toBeHidden();
|
||||
|
||||
// And the syntax is spelled out under the field, where it is being typed.
|
||||
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// The title is the first line. It used to be derived once and then left alone,
|
||||
// so rewriting the opening left the list calling the note by an old name.
|
||||
await page.keyboard.press('Enter');
|
||||
await ed.blur();
|
||||
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
|
||||
|
||||
// Rewrite the first line; the list has to follow.
|
||||
await ed.click();
|
||||
await page.keyboard.press('ControlOrMeta+Home');
|
||||
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.keyboard.type('Andet udkast');
|
||||
await ed.blur();
|
||||
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
|
||||
// Written in lower case, the way somebody types when they are not thinking
|
||||
// about it. It used to mean the note simply never appeared on the project, with
|
||||
// nothing anywhere to say why.
|
||||
await page.keyboard.type('Aftale om levering');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Handler om #storebogstaver');
|
||||
await page.keyboard.press('Escape');
|
||||
await ed.blur();
|
||||
|
||||
// The panel shows the project's own spelling, not the folded key: a label
|
||||
// nobody wrote and that is nowhere in the note would be worse than none.
|
||||
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
|
||||
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en opgave viser de noter, der nævner den, og linket åbner noten', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('ring til leverandøren i dag');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('ring til leverandøren', { exact: true })).toBeVisible();
|
||||
|
||||
// Asked for rather than read off the address bar: opening a task shows a drawer
|
||||
// without navigating, so the URL is still the list's.
|
||||
const taskId = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/tasks?limit=100', {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
const j = await r.json();
|
||||
return (j.tasks ?? []).find((t) => t.content === 'ring til leverandøren')?.id;
|
||||
});
|
||||
expect(taskId, 'opgaven blev ikke oprettet').toBeTruthy();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Referat fra tirsdag');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Aftalt at han ringer, se /opgave/' + taskId);
|
||||
await ed.blur();
|
||||
|
||||
// Back on the task: the other direction of the same link. Without it the
|
||||
// connection only works if you happen to be reading the note, which is the half
|
||||
// nobody needs help with.
|
||||
await page.goto('/opgave/' + taskId);
|
||||
const linked = page.locator('.linked-notes').getByRole('link', { name: /Referat fra tirsdag/ });
|
||||
await expect(linked).toBeVisible();
|
||||
|
||||
// And it opens the note rather than dropping you on a list to find it again.
|
||||
await linked.click();
|
||||
await expect(page.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Aftalt at han ringer'
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const pre = ed.locator('pre');
|
||||
await expect(pre).toBeVisible();
|
||||
|
||||
// Mørk flade uanset tema: en terminal er sort, også midt på dagen.
|
||||
const look = await pre.evaluate((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return { bg: s.backgroundColor, font: s.fontFamily };
|
||||
});
|
||||
expect(look.bg).toBe('rgb(20, 24, 27)');
|
||||
expect(look.font.toLowerCase()).toMatch(/mono/);
|
||||
|
||||
// Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
|
||||
await expect(pre.locator('.tok-prompt')).toBeVisible();
|
||||
await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
|
||||
await expect(pre.locator('.tok-string')).toContainText('hej');
|
||||
|
||||
// Teksten omkring er ikke blevet til kode.
|
||||
await expect(ed.locator('h1')).toHaveText('Terminalprøven');
|
||||
await expect(ed).toContainText('og resten.');
|
||||
|
||||
// Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
|
||||
// hegnet blok med sit sprog, ikke som en klump linjer.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
expect(body).toContain('```bash');
|
||||
expect(body).toContain('kw@shell:~$ ls -la');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Formerne, der hver især gik tabt: en punktliste, en nummereret, en der
|
||||
// begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et
|
||||
// citat. Den sidste er den, en note fra Apple Noter er fuld af.
|
||||
const markdown = [
|
||||
'# Former',
|
||||
'',
|
||||
'- Punkt',
|
||||
'- Punkt 2',
|
||||
'',
|
||||
'1. Nummer',
|
||||
'2. Nummer 2',
|
||||
'',
|
||||
'10. Ti',
|
||||
'11. Elleve',
|
||||
'',
|
||||
'- Ydre',
|
||||
' - Indre',
|
||||
'',
|
||||
'> Et citat',
|
||||
'> - med en liste i'
|
||||
].join('\n');
|
||||
|
||||
const id = await page.evaluate(async (body) => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}, markdown);
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('ul > li').first()).toBeVisible();
|
||||
|
||||
// Vist som lister, ikke som linjer med bindestreger i.
|
||||
await expect(ed.locator('ol')).toHaveCount(2);
|
||||
await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i');
|
||||
// Den indlejrede ligger inde i sit punkt, ikke ved siden af.
|
||||
await expect(ed.locator('ul li ul li')).toHaveText('Indre');
|
||||
// Og den, der begyndte ved ti, gør det stadig.
|
||||
await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti');
|
||||
|
||||
// Rør noten, sådan som en tastning gør, og lad den gemme.
|
||||
await ed.click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' ');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Det, der står i filen bagefter, er det, der stod i den før.
|
||||
//
|
||||
// Det var her, det gik galt: alt fra det første punkt blev til én linje —
|
||||
// "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som
|
||||
// tekst. Ingen havde rørt de linjer.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
|
||||
expect(body).toContain('- Punkt\n- Punkt 2');
|
||||
expect(body).toContain('1. Nummer\n2. Nummer 2');
|
||||
expect(body).toContain('10. Ti\n11. Elleve');
|
||||
expect(body).toContain('- Ydre\n - Indre');
|
||||
expect(body).toContain('> Et citat\n> - med en liste i');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
+73
-1
@@ -363,7 +363,10 @@ button {
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
/* Ingen radius her. En omrids følger elementets egen afrunding af sig selv, og
|
||||
at tvinge en anden på tegner en firkantet bue uden om en rund kant — to
|
||||
streger, hvor der skulle være én. Det ses tydeligst om et pilleformet
|
||||
søgefelt. */
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -444,3 +447,72 @@ button {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- looks -----------------------------------------------------------------
|
||||
*
|
||||
* A second axis, beside the colour themes.
|
||||
*
|
||||
* A theme answers "how bright"; a look answers "what does it feel like to read".
|
||||
* Typeface, how tight the rows sit, how round the corners are. They are separate
|
||||
* because they are separate questions: somebody who wants a serif face at night
|
||||
* should not have to give up the dark palette to get it.
|
||||
*
|
||||
* It is also the honest answer to looking like something else. The default is
|
||||
* deliberately familiar — that is what was asked for — but a program with one
|
||||
* possible appearance has borrowed a shape it cannot give back. With looks, the
|
||||
* resemblance is one setting among several rather than the only thing it can be.
|
||||
*/
|
||||
|
||||
/* Serif, roomier, softer. For reading and writing rather than scanning — which is
|
||||
what a program with notes in it is half the time. */
|
||||
[data-look='rolig'] {
|
||||
--font: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
|
||||
--text-base: 1.0625rem;
|
||||
--text-sm: 0.9375rem;
|
||||
--radius: 10px;
|
||||
--radius-lg: 16px;
|
||||
--s2: 0.5625rem;
|
||||
--s3: 0.875rem;
|
||||
--s4: 1.25rem;
|
||||
}
|
||||
|
||||
[data-look='rolig'] body {
|
||||
line-height: 1.65;
|
||||
letter-spacing: 0.006em;
|
||||
}
|
||||
|
||||
/* Tighter. More rows on a screen, squarer corners, less air — for a long list
|
||||
read at a glance rather than a page read a paragraph at a time. */
|
||||
[data-look='taet'] {
|
||||
--text-base: 0.875rem;
|
||||
--text-sm: 0.78125rem;
|
||||
--text-xs: 0.6875rem;
|
||||
--radius: 5px;
|
||||
--radius-lg: 8px;
|
||||
--radius-sm: 3px;
|
||||
--s1: 0.1875rem;
|
||||
--s2: 0.375rem;
|
||||
--s3: 0.5625rem;
|
||||
--s4: 0.75rem;
|
||||
}
|
||||
|
||||
[data-look='taet'] body {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* One face for everything, including the prose. Not a joke setting: a monospaced
|
||||
list of tasks lines up in a way a proportional one never does, and some people
|
||||
read code all day and want the same shapes here. */
|
||||
[data-look='terminal'] {
|
||||
--font: var(--font-mono);
|
||||
--text-base: 0.875rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--radius: 3px;
|
||||
--radius-lg: 4px;
|
||||
--radius-sm: 2px;
|
||||
}
|
||||
|
||||
[data-look='terminal'] body {
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
stored ??
|
||||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
// The look changes the typeface and the spacing, so it has to be on
|
||||
// before first paint too — otherwise the page is laid out once in one
|
||||
// face and again in another, which is a visible jump.
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {
|
||||
// Private browsing can refuse localStorage; the dark default stands.
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export const api = {
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
@@ -390,6 +391,10 @@ export const api = {
|
||||
importTodoist: (file) => upload('/import/todoist', file),
|
||||
importCSV: (data) => post('/import/csv', data),
|
||||
exportAccountURL: () => '/api/v1/export/account',
|
||||
exportNotesURL: () => '/api/v1/export/notes.zip',
|
||||
// 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),
|
||||
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
|
||||
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
let query = $state('');
|
||||
let tasks = $state([]);
|
||||
let projects = $state([]);
|
||||
let notes = $state([]);
|
||||
let selected = $state(0);
|
||||
let input;
|
||||
let controller = null;
|
||||
@@ -32,13 +33,21 @@
|
||||
if (!value) {
|
||||
tasks = [];
|
||||
projects = [];
|
||||
notes = [];
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.search(value);
|
||||
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
|
||||
// søger ⌘K kun i halvdelen af programmet, hvilket er svært at gætte
|
||||
// som bruger: feltet ser ud til at kunne finde alt.
|
||||
const [result, found] = await Promise.all([
|
||||
api.search(value),
|
||||
api.notes({ q: value }).catch(() => ({ notes: [] }))
|
||||
]);
|
||||
tasks = result.tasks ?? [];
|
||||
projects = result.projects ?? [];
|
||||
notes = found.notes ?? [];
|
||||
selected = 0;
|
||||
} catch {
|
||||
// A failed search shows nothing rather than an error inside a
|
||||
@@ -49,6 +58,7 @@
|
||||
|
||||
let results = $derived([
|
||||
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
||||
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||
...tasks.map((t) => ({
|
||||
kind: 'task',
|
||||
id: t.id,
|
||||
@@ -60,6 +70,7 @@
|
||||
|
||||
function choose(item) {
|
||||
open = false;
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||
}
|
||||
|
||||
@@ -109,7 +120,7 @@
|
||||
onclick={() => choose(item)}
|
||||
onmouseenter={() => (selected = i)}
|
||||
>
|
||||
<span class="kind">{item.kind === 'project' ? 'Projekt' : 'Opgave'}</span>
|
||||
<span class="kind">{t('palette.' + item.kind)}</span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
<script>
|
||||
/**
|
||||
* The note itself.
|
||||
*
|
||||
* Rich text, not Markdown source: somebody who writes in Apple Notes presses
|
||||
* ⌘B and expects the word to go bold, not to grow asterisks. The file underneath
|
||||
* is still Markdown — see $lib/richtext.js for the seam — so nothing about the
|
||||
* export changes.
|
||||
*
|
||||
* contenteditable with execCommand. It is deprecated and it is also the only
|
||||
* thing every browser agrees on; writing a selection model from scratch is weeks
|
||||
* of work and a new class of bug in the one place where losing text is
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
let list;
|
||||
let stylesOpen = $state(false);
|
||||
// Which marks are on where the caret is, so the toolbar shows state rather than
|
||||
// only offering actions.
|
||||
let active = $state({});
|
||||
|
||||
// Loaded when the note changes, never while it is being typed in: writing to
|
||||
// innerHTML puts the caret back at the start, which mid-sentence is unusable.
|
||||
let loadedId = $state(null);
|
||||
$effect(() => {
|
||||
if (!editor || !note || note.id === loadedId) return;
|
||||
// An empty note opens on its title. That is what Apple Notes does, and it is
|
||||
// also what the list needs: the first line becomes the name of the note, so a
|
||||
// note whose first line is body text is a note called by its first sentence.
|
||||
//
|
||||
// Tested on the text and not on the rendered result: an empty body renders as
|
||||
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||
loadedId = note.id;
|
||||
active = {};
|
||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||
// uden at nogen har bedt om det.
|
||||
asSource = false;
|
||||
colourCode();
|
||||
});
|
||||
|
||||
const SYNTAX = [
|
||||
{ mark: '#', what: 'notes.syntaxProject' },
|
||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||
{ mark: '⌘B', what: 'notes.bold' },
|
||||
{ mark: '⌘U', what: 'notes.underline' }
|
||||
];
|
||||
|
||||
const STYLES = [
|
||||
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
||||
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
||||
{ key: 'subheading', tag: 'h3', label: 'notes.styleSubheading' },
|
||||
{ key: 'body', tag: 'p', label: 'notes.styleBody' },
|
||||
{ key: 'mono', tag: 'pre', label: 'notes.styleMono' },
|
||||
{ key: 'quote', tag: 'blockquote', label: 'notes.styleQuote' }
|
||||
];
|
||||
|
||||
function apply(command, value) {
|
||||
editor?.focus();
|
||||
// Tags, not inline styles. Left to itself the browser writes
|
||||
// <span style="font-weight: normal"> for an un-bold, which is invisible to a
|
||||
// converter that reads tags — so the change is lost on the way to Markdown
|
||||
// and comes back bold. Asking for tags keeps the document in a shape that
|
||||
// can be written down.
|
||||
try {
|
||||
document.execCommand('styleWithCSS', false, false);
|
||||
} catch {
|
||||
// Not everywhere. The formats below still work; some of them just produce
|
||||
// spans, which is why the converter reads styles as well as tags.
|
||||
}
|
||||
document.execCommand(command, false, value);
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
function setStyle(style) {
|
||||
stylesOpen = false;
|
||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||
apply('formatBlock', style.tag);
|
||||
}
|
||||
|
||||
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||
function readState() {
|
||||
if (!editor) return;
|
||||
try {
|
||||
active = {
|
||||
bold: document.queryCommandState('bold'),
|
||||
italic: document.queryCommandState('italic'),
|
||||
underline: document.queryCommandState('underline'),
|
||||
strike: document.queryCommandState('strikeThrough'),
|
||||
bullet: document.queryCommandState('insertUnorderedList'),
|
||||
numbered: document.queryCommandState('insertOrderedList')
|
||||
};
|
||||
} catch {
|
||||
// Some browsers throw on queryCommandState with no selection. The toolbar
|
||||
// showing nothing is a smaller problem than the editor throwing.
|
||||
active = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Farver kodeblokkene.
|
||||
*
|
||||
* Kun dem, markøren ikke står i. At farve blokken, mens nogen skriver i den,
|
||||
* betyder at bygge dens indhold om ved hvert tastetryk og sætte markøren
|
||||
* tilbage bagefter — det er den slags, der virker i ni tilfælde ud af ti og
|
||||
* flytter markøren midt i en sætning i det tiende. Blokken er mørk og
|
||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||
* man læser den.
|
||||
*/
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
const inside = currentBlock();
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre === inside || pre.contains(inside)) continue;
|
||||
const code = pre.textContent ?? '';
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
// Kun hvis der er noget at ændre: at skrive innerHTML uændret ville
|
||||
// stadig rive noden op og lægge den tilbage, og det ses som et blink.
|
||||
const target = pre.querySelector('code') ?? pre;
|
||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
if (!editor) return;
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
readSuggestions();
|
||||
}
|
||||
|
||||
// --- kilden ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Noten som den Markdown, den er.
|
||||
*
|
||||
* Fladen er rig tekst, fordi det er den, folk kan. Men filen under er Markdown,
|
||||
* og der er to ting, den rige flade ikke kan: tage imod Markdown, nogen har
|
||||
* skrevet et andet sted — indsat i den rige flade er `## Overskrift` fem tegn og
|
||||
* ikke en overskrift — og vise, hvad der faktisk bliver gemt. Begge dele er det
|
||||
* samme svar: lad folk se kilden.
|
||||
*
|
||||
* Ikke en tredje tilstand at holde styr på: teksten går gennem de samme to
|
||||
* funktioner som altid, kun den anden vej. Det, man ser i kildefeltet, er ord for
|
||||
* ord det, der ligger i databasen.
|
||||
*/
|
||||
let asSource = $state(false);
|
||||
let source = $state('');
|
||||
|
||||
function toggleSource() {
|
||||
if (!asSource) {
|
||||
source = htmlToMarkdown(editor);
|
||||
asSource = true;
|
||||
return;
|
||||
}
|
||||
asSource = false;
|
||||
onchange?.(source);
|
||||
// Fyldt fra kilden, ikke fra `note.body`.
|
||||
//
|
||||
// Noten i lageret er den sidst gemte, og gemningen venter på sin pause — så
|
||||
// den vej ville kaste det, der lige er skrevet i kildefeltet, væk. `loadedId`
|
||||
// røres ikke: $effect ser stadig en note, den har indlæst, og skriver ikke
|
||||
// hen over det her.
|
||||
queueMicrotask(() => {
|
||||
if (!editor) return;
|
||||
editor.innerHTML = source.trim() ? markdownToHtml(source) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
});
|
||||
}
|
||||
|
||||
/** Kilden skrives direkte igennem: det er filen, man redigerer. */
|
||||
function sourceChanged(event) {
|
||||
source = event.currentTarget.value;
|
||||
onchange?.(source);
|
||||
}
|
||||
|
||||
// --- suggesting a project ------------------------------------------------------
|
||||
|
||||
let suggestions = $state([]);
|
||||
let chosen = $state(0);
|
||||
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||
let menuAt = $state(null);
|
||||
let wrap;
|
||||
|
||||
/**
|
||||
* The word being typed after a #, if that is what is happening.
|
||||
*
|
||||
* Read from the text node the caret is in rather than from the whole document:
|
||||
* a note can mention twenty projects, and only the one under the cursor is being
|
||||
* written.
|
||||
*/
|
||||
function partialTag() {
|
||||
const selection = window.getSelection();
|
||||
const node = selection?.anchorNode;
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvor `#` står, målt i wrappens koordinater.
|
||||
*
|
||||
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||
*
|
||||
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||
*/
|
||||
function tagPosition(partial) {
|
||||
if (!wrap) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const at = range.getBoundingClientRect();
|
||||
const box = wrap.getBoundingClientRect();
|
||||
if (!at.width && !at.height) return null;
|
||||
|
||||
const MENU = { w: 210, h: 240 };
|
||||
const below = at.bottom - box.top + 6;
|
||||
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||
return {
|
||||
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||
};
|
||||
}
|
||||
|
||||
function readSuggestions() {
|
||||
const partial = partialTag();
|
||||
if (!partial) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
const term = partial.term.toLowerCase();
|
||||
|
||||
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
|
||||
/** The block the caret is standing in. */
|
||||
function currentBlock() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.anchorNode || !editor) return null;
|
||||
let node = selection.anchorNode;
|
||||
while (node && node.parentNode !== editor) node = node.parentNode;
|
||||
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title for the first line, body for everything after it.
|
||||
*
|
||||
* Run after the browser has split the block on Enter, because until then there
|
||||
* is no second block to change. Pressing return at the end of a heading leaves
|
||||
* the caret in another heading in most browsers, and a note where every line is
|
||||
* a title is a note with no title at all.
|
||||
*/
|
||||
function keepTitleFirst() {
|
||||
const block = currentBlock();
|
||||
if (!block) return;
|
||||
|
||||
// A heading that is not the first line becomes body.
|
||||
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
return;
|
||||
}
|
||||
|
||||
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||
// return rather than choosing Brødtekst from the menu.
|
||||
if (block.tagName === 'DIV') {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruller det valgte forslag frem, når listen er længere end sin egen kasse.
|
||||
*
|
||||
* Efter opdateringen, ikke før: `chosen` er netop sat, og rækken bærer klassen
|
||||
* først når Svelte har skrevet den ud. Uden ventetiden ruller den til den
|
||||
* forrige.
|
||||
*/
|
||||
function keepChosenInView() {
|
||||
requestAnimationFrame(() => {
|
||||
list?.querySelector('button.on')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
if (suggestions.length) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen + 1) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||
keepChosenInView();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
accept(suggestions[chosen]);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// After the split, not before it.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
changed();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// The shortcuts people already have in their fingers. execCommand handles
|
||||
// bold and italic itself; underline and strikethrough are claimed here so
|
||||
// they behave the same way.
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'u') {
|
||||
event.preventDefault();
|
||||
apply('underline');
|
||||
} else if (key === 'x' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
apply('strikeThrough');
|
||||
}
|
||||
}
|
||||
|
||||
// Pasted text arrives as whatever the source was — a web page brings its fonts,
|
||||
// its colours and its layout with it. Only the words are wanted.
|
||||
function onpaste(event) {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
document.execCommand('insertText', false, text);
|
||||
changed();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap" bind:this={wrap}>
|
||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||
collapses the selection first, so the format lands on nothing — the button
|
||||
appears to do nothing at all, which is how it read the first time. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="toolbar" onmousedown={(e) => e.preventDefault()}>
|
||||
<div class="styles">
|
||||
<button
|
||||
class="aa"
|
||||
onclick={() => (stylesOpen = !stylesOpen)}
|
||||
aria-expanded={stylesOpen}
|
||||
aria-label={t('notes.format')}
|
||||
title={t('notes.format')}>Aa</button
|
||||
>
|
||||
{#if stylesOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="menu" role="menu">
|
||||
{#each STYLES as style}
|
||||
<button class={style.key} role="menuitem" onclick={() => setStyle(style)}>
|
||||
{t(style.label)}
|
||||
</button>
|
||||
{/each}
|
||||
<hr />
|
||||
<button
|
||||
class:on={active.bullet}
|
||||
role="menuitem"
|
||||
onclick={() => setStyle({ key: 'bullet' })}>• {t('notes.styleBullet')}</button
|
||||
>
|
||||
<button
|
||||
class:on={active.numbered}
|
||||
role="menuitem"
|
||||
onclick={() => setStyle({ key: 'numbered' })}>1. {t('notes.styleNumbered')}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<button class:on={active.bold} onclick={() => apply('bold')} title="⌘B" aria-label={t('notes.bold')}>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button class:on={active.italic} onclick={() => apply('italic')} title="⌘I" aria-label={t('notes.italic')}>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button class:on={active.underline} onclick={() => apply('underline')} title="⌘U" aria-label={t('notes.underline')}>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
||||
anden måde at se det hele på. -->
|
||||
<button
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
aria-label={t('notes.showSource')}
|
||||
title={t('notes.showSource')}></></button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if asSource}
|
||||
<!-- Et almindeligt tekstfelt, fordi det er almindelig tekst. Ingen
|
||||
contenteditable, ingen execCommand: her er der intet at formatere. -->
|
||||
<textarea
|
||||
class="page source"
|
||||
aria-label={t('notes.source')}
|
||||
spellcheck="false"
|
||||
value={source}
|
||||
oninput={sourceChanged}
|
||||
onblur={() => onsave?.()}
|
||||
></textarea>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="page"
|
||||
class:hidden={asSource}
|
||||
bind:this={editor}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={t('notes.body')}
|
||||
spellcheck="true"
|
||||
oninput={changed}
|
||||
onblur={() => {
|
||||
colourCode();
|
||||
onsave?.();
|
||||
}}
|
||||
onkeyup={() => {
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||
står stille, mens listen snævres ind. -->
|
||||
<ul
|
||||
class="suggestions"
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
role="option"
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- What the text understands, said where it is being typed. The same idea as
|
||||
the line under the quick-add box: a note is the other place in this program
|
||||
where what you type is read for meaning. -->
|
||||
<p class="legend">
|
||||
{#each SYNTAX as item}
|
||||
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
padding: 0 0 var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.toolbar button.on {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--line);
|
||||
margin: 0 var(--s1);
|
||||
}
|
||||
|
||||
.styles {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.aa {
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--s1));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 210px;
|
||||
padding: var(--s1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
height: auto;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.menu hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--s1) 0;
|
||||
}
|
||||
|
||||
/* The menu shows each style in the style it applies, the way Apple Notes does.
|
||||
Reading the word "Overskrift" set as one is faster than reading a label. */
|
||||
.menu .title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 640;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .subheading {
|
||||
font-weight: 620;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .mono {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.menu .quote {
|
||||
border-left: 2px solid var(--line-strong);
|
||||
padding-left: var(--s2);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s1);
|
||||
min-width: 200px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
/* Nu hvor alle der passer står på listen, skal den kunne rulle. Otte rækker
|
||||
er nok til at det ses, at der er flere, uden at menuen dækker noten. */
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1) var(--s3);
|
||||
padding-top: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.legend kbd {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page {
|
||||
/* Et token eller en URL er ét ord, der er bredere end skærmen. Uden det her
|
||||
får siden en vandret rullebjælke, og teksten forsvinder ud til højre. */
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
min-height: 50vh;
|
||||
padding: var(--s3) 0;
|
||||
outline: none;
|
||||
line-height: 1.55;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Linjelængden har en grænse, som skærmen ikke har.
|
||||
*
|
||||
* En note i fuld bredde på en stor skærm er linjer på hundrede og fyrre tegn,
|
||||
* og øjet mister hvilken linje det var på, når det springer tilbage. Alle,
|
||||
* der laver noget, man læser i, sætter det her — Apple Noter, Bear, Notion —
|
||||
* og de sætter det omkring det samme sted.
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
|
||||
liste er indhold her og ikke pynt: den skal kunne tælles. */
|
||||
.source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
a title that is a title, and body text that is comfortable to read for a page
|
||||
at a time. */
|
||||
.page :global(h1) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--s2);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.page :global(h2) {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 640;
|
||||
margin: var(--s3) 0 var(--s1);
|
||||
}
|
||||
|
||||
.page :global(h3) {
|
||||
font-size: 1rem;
|
||||
font-weight: 620;
|
||||
margin: var(--s3) 0 var(--s1);
|
||||
}
|
||||
|
||||
.page :global(p) {
|
||||
margin: 0 0 var(--s2);
|
||||
}
|
||||
|
||||
/* Renden skal kunne rumme det bredeste mærke.
|
||||
*
|
||||
* Mærket sættes uden for tekstkanten, inde i listens egen indrykning, og en fast
|
||||
* indrykning på 16px rakte til et punkttegn og ikke til "10." — resten hang ud
|
||||
* over kanten, hvor `.page`, som ruller lodret og derfor beskærer vandret,
|
||||
* klippede det af. Man så et punktum uden sit tal.
|
||||
*
|
||||
* Samme rende til begge slags, frem for en bredere til den nummererede: mærket
|
||||
* lægger sig op ad teksten uanset hvor bred renden er, så deler de den, flugter
|
||||
* punkter og tal — og gør de ikke, står to lister under hinanden med hver sin
|
||||
* venstrekant.
|
||||
*
|
||||
* Målt i em, så den følger skriftstørrelsen, og bred nok til tre cifre. */
|
||||
.page :global(ul),
|
||||
.page :global(ol) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: 2.2em;
|
||||
}
|
||||
|
||||
.page :global(li) {
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
||||
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
|
||||
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||
uden at man skal læse et ord. Farverne er de samme i lys og mørk tilstand:
|
||||
en terminal er sort, også midt på dagen. */
|
||||
.page :global(pre) {
|
||||
margin: 0 0 var(--s3);
|
||||
padding: var(--s3);
|
||||
border-radius: var(--radius);
|
||||
background: #14181b;
|
||||
color: #d7dde3;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sproget i hjørnet, når blokken siger hvad den er. */
|
||||
.page :global(pre[data-lang]:not([data-lang=''])::after) {
|
||||
content: attr(data-lang);
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #5a656e;
|
||||
}
|
||||
|
||||
.page :global(pre code) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page :global(.tok-comment) {
|
||||
color: #6b7a85;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.page :global(.tok-string) {
|
||||
color: #9ecb8a;
|
||||
}
|
||||
|
||||
.page :global(.tok-number) {
|
||||
color: #d9a25f;
|
||||
}
|
||||
|
||||
.page :global(.tok-keyword) {
|
||||
color: #7fb3e8;
|
||||
}
|
||||
|
||||
.page :global(.tok-key) {
|
||||
color: #7fb3e8;
|
||||
}
|
||||
|
||||
.page :global(.tok-bool) {
|
||||
color: #c48fd6;
|
||||
}
|
||||
|
||||
.page :global(.tok-variable) {
|
||||
color: #d9a25f;
|
||||
}
|
||||
|
||||
.page :global(.tok-flag) {
|
||||
color: #c48fd6;
|
||||
}
|
||||
|
||||
.page :global(.tok-punct) {
|
||||
color: #8a949c;
|
||||
}
|
||||
|
||||
/* Prompten i en terminaludskrift: det første øjet finder, når man leder efter
|
||||
hvor en kommando begynder. */
|
||||
.page :global(.tok-prompt) {
|
||||
color: #6fbf8f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page :global(code) {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -117,6 +117,84 @@
|
||||
}
|
||||
|
||||
|
||||
// --- the fixed views ---------------------------------------------------------
|
||||
|
||||
// Delegated is only offered where delegating is possible at all: on an instance
|
||||
// with one person that view can never have anything in it, and a permanent empty
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' },
|
||||
// Noter hører til her, og bærer et ark frem for en prik.
|
||||
//
|
||||
// Den stod før for sig selv over en streg, hvilket sagde det rigtige og
|
||||
// kostede halvfjerds pixels: en streg, luft over og luft under, og
|
||||
// projekterne skubbet så langt ned, at de to første var alt, man så uden
|
||||
// at rulle. Grunden til at skille den ud var, at den ellers læste som
|
||||
// endnu et filter — men det, der gør den til et filter for øjet, er
|
||||
// prikken, ikke pladsen. Et andet mærke siger "en anden slags ting" og
|
||||
// koster ingenting.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
let orderedNav = $derived(
|
||||
app
|
||||
.navOrder(navItems.map((i) => i.key))
|
||||
.map((key) => navItems.find((i) => i.key === key))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let draggingNav = $state(null);
|
||||
let overNav = $state(null);
|
||||
|
||||
function onNavDragStart(event, key) {
|
||||
draggingNav = key;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// A payload, because Firefox refuses to start a drag without one.
|
||||
event.dataTransfer.setData('text/plain', key);
|
||||
}
|
||||
|
||||
function onNavDragOver(event, key) {
|
||||
if (!draggingNav || key === draggingNav) return;
|
||||
event.preventDefault();
|
||||
overNav = key;
|
||||
}
|
||||
|
||||
function clearNavDrag() {
|
||||
draggingNav = null;
|
||||
overNav = null;
|
||||
}
|
||||
|
||||
async function onNavDrop(event, key) {
|
||||
const moved = draggingNav;
|
||||
// Checked before anything is swallowed. Stopping the event first meant a task
|
||||
// dragged onto I dag never reached the handler that gives it a date — the row
|
||||
// took the drop and then discovered it was not for it.
|
||||
if (!moved || moved === key) {
|
||||
clearNavDrag();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearNavDrag();
|
||||
|
||||
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
|
||||
keys.splice(keys.indexOf(key), 0, moved);
|
||||
await app.setNavOrder(keys);
|
||||
}
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -271,16 +349,27 @@
|
||||
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
|
||||
|
||||
function onTaskDragOver(event, project) {
|
||||
if (!carries(event, TASK) || !canReceive(project)) return;
|
||||
if (!(carries(event, TASK) || carries(event, NOTE)) || !canReceive(project)) return;
|
||||
accept(event);
|
||||
overProject = project.id;
|
||||
}
|
||||
|
||||
async function onTaskDrop(event, project) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
overProject = null;
|
||||
if (!id || !canReceive(project)) return;
|
||||
if (!canReceive(project)) return;
|
||||
|
||||
// The same target takes both. A note dropped on a project is filed there,
|
||||
// which is also how it gets shared — one gesture, one meaning, and the
|
||||
// sidebar already reads as "put this here".
|
||||
const noteID = dragged(event, NOTE);
|
||||
if (noteID) {
|
||||
await app.moveNoteToProject(noteID, project.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = dragged(event, TASK);
|
||||
if (!id) return;
|
||||
await app.moveToProject(id, project.id);
|
||||
}
|
||||
|
||||
@@ -340,56 +429,73 @@
|
||||
|
||||
<div class="views">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
{t('nav.today')}
|
||||
</a>
|
||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.upcoming')}
|
||||
</a>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
person this view can never have anything in it, and a permanent empty
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
<!-- Notes sit with the fixed places rather than among the projects: it is a
|
||||
|
||||
place you go, not a thing filed under one. -->
|
||||
<a href="/noter" class="fixed" onclick={onnavigate}>
|
||||
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
|
||||
{t('notes.title')}
|
||||
|
||||
</a>
|
||||
{/if}
|
||||
<!-- The fixed views, in the order this person put them.
|
||||
Drawn from a list rather than written out, because they can be reordered
|
||||
now and a hand-written run cannot be. Unknown keys are dropped and new
|
||||
ones appended, so adding a view later does not strand anybody's order. -->
|
||||
{#each orderedNav as item (item.key)}
|
||||
{#if item.key === 'inbox'}
|
||||
{#if app.inbox}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
<!-- I dag also takes a task dropped on it — the most-made rescheduling
|
||||
there is, and the sidebar is where the pointer already is. The
|
||||
handlers were lost when this run became a list; the test caught
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (item.key !== 'today' || !carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
{#if item.key === 'notes'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M4 1.5h5.2L12.5 5v9.5H4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
@@ -397,6 +503,21 @@
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
Only your own reorder: `sort_order` is a column on the project, so a shared
|
||||
one stays where its owner put it. -->
|
||||
<!-- A handle rather than the whole row.
|
||||
Making the row draggable made everything inside it draggable too, and the
|
||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||
stopped landing, because the row had claimed the gesture. A grip claims
|
||||
nothing until it is grabbed. -->
|
||||
{#snippet grip(key)}
|
||||
<span
|
||||
class="grip"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onNavDragStart(e, key)}
|
||||
ondragend={clearNavDrag}
|
||||
aria-hidden="true">⠿</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
@@ -873,6 +994,69 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.navrow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Takes no room until it is wanted: it sits in the padding the row already has,
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navrow.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line where it will land, not a box around what it will land on: the first
|
||||
reads as a position and the second as a target. */
|
||||
.navrow.over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
|
||||
— mærket skifter, kolonnen gør ikke. */
|
||||
.mark {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
|
||||
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
|
||||
margin: 0 -3.5px;
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
let attachments = $state([]);
|
||||
let notes = $state([]);
|
||||
let reminders = $state([]);
|
||||
|
||||
let newSubtask = $state('');
|
||||
@@ -80,6 +81,7 @@
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
attachments = [];
|
||||
notes = [];
|
||||
reminders = [];
|
||||
|
||||
const id = task.id;
|
||||
@@ -96,6 +98,11 @@
|
||||
api.listReminders(id)
|
||||
.then((r) => id === seededId && (reminders = r.reminders))
|
||||
.catch(() => {});
|
||||
// Quietly, because a task with no notes is the ordinary case and a failure
|
||||
// here must not put an error in front of somebody reading a task.
|
||||
api.notesLinking('task', id)
|
||||
.then((r) => id === seededId && (notes = r.notes ?? []))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -540,6 +547,22 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- What has been written about this task.
|
||||
The other direction of the same link: a note that mentions a task shows
|
||||
it, and this is the task showing the note. Without it the connection only
|
||||
works if you happen to be reading the note — which is the half nobody
|
||||
needs help with. -->
|
||||
{#if notes.length}
|
||||
<section class="linked-notes">
|
||||
<h3>{t('notes.title')}</h3>
|
||||
{#each notes as note (note.id)}
|
||||
<a class="linked-note" href="/noter?note={note.id}">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>{t('detail.comments')}</h3>
|
||||
|
||||
@@ -582,6 +605,24 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.linked-note {
|
||||
display: block;
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linked-note:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.linked-note strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export const TASK = 'application/x-verdande-task';
|
||||
export const PROJECT = 'application/x-verdande-project';
|
||||
export const GROUP = 'application/x-verdande-group';
|
||||
export const NOTE = 'application/x-verdande-note';
|
||||
|
||||
/** Starts a drag carrying one id of the given kind. */
|
||||
export function startDrag(event, type, id) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Farver til en kodeblok.
|
||||
*
|
||||
* Ikke et bibliotek: et fuldt syntaksbibliotek er hundredvis af kilobytes for at
|
||||
* gøre nogle ord blå i en note. Det her er et lille sæt regler pr. sprog, der
|
||||
* rammer det, øjet leder efter — strenge, tal, kommentarer, nøgleord — og som er
|
||||
* ligeglad med resten. En farve for lidt er ingenting; en forkert er heller ikke
|
||||
* meget, når teksten stadig står der, som den blev skrevet.
|
||||
*
|
||||
* Reglerne prøves i rækkefølge, og den første, der rammer, vinder. Strenge og
|
||||
* kommentarer står derfor først: et nøgleord inde i en streng er ikke et nøgleord.
|
||||
*/
|
||||
|
||||
const COMMON = {
|
||||
string: /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/,
|
||||
number: /^\b\d+(\.\d+)?\b/
|
||||
};
|
||||
|
||||
const LANGUAGES = {
|
||||
yaml: [
|
||||
['comment', /^#[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
// Nøglen, som er det, man scanner efter i en YAML-fil.
|
||||
['key', /^[ \t]*[-\w.]+(?=\s*:)/],
|
||||
['punct', /^[:\-|>]/],
|
||||
['number', COMMON.number],
|
||||
['bool', /^\b(true|false|null|yes|no|on|off)\b/i]
|
||||
],
|
||||
json: [
|
||||
['string', COMMON.string],
|
||||
['punct', /^[{}[\],:]/],
|
||||
['number', COMMON.number],
|
||||
['bool', /^\b(true|false|null)\b/]
|
||||
],
|
||||
bash: [
|
||||
['comment', /^#[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
// Prompten er ikke kode, men den er det første øjet finder i en udskrift.
|
||||
['prompt', /^\S+@\S+:[^$#]*[$#]/],
|
||||
['variable', /^\$\{?\w+\}?/],
|
||||
['keyword', /^\b(if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|sudo)\b/],
|
||||
['flag', /^\s-{1,2}[\w-]+/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
go: [
|
||||
['comment', /^\/\/[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(func|package|import|var|const|type|struct|interface|if|else|for|range|return|defer|go|chan|select|switch|case|default|map|nil|true|false)\b/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
js: [
|
||||
['comment', /^\/\/[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(const|let|var|function|return|if|else|for|while|import|export|from|class|new|await|async|try|catch|throw|typeof|null|undefined|true|false)\b/],
|
||||
['number', COMMON.number]
|
||||
],
|
||||
sql: [
|
||||
['comment', /^--[^\n]*/],
|
||||
['string', COMMON.string],
|
||||
['keyword', /^\b(select|from|where|insert|into|values|update|set|delete|create|table|index|join|left|inner|on|and|or|not|null|primary|key|references|order|by|group|limit)\b/i],
|
||||
['number', COMMON.number]
|
||||
]
|
||||
};
|
||||
|
||||
// Det, folk skriver, mod det, filen hedder.
|
||||
const ALIASES = {
|
||||
sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash', terminal: 'bash',
|
||||
yml: 'yaml', javascript: 'js', ts: 'js', typescript: 'js', golang: 'go'
|
||||
};
|
||||
|
||||
const escape = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* Gætter sproget, når blokken ikke siger det.
|
||||
*
|
||||
* Med vilje forsigtigt: at gætte forkert farver de forkerte ord, og en
|
||||
* terminaludskrift uden farver er stadig læselig. Kun mønstre, der er svære at
|
||||
* forveksle, tæller med.
|
||||
*/
|
||||
export function guessLanguage(code) {
|
||||
if (/^\s*[{[]/.test(code) && /["}\]]\s*$/.test(code.trim())) return 'json';
|
||||
if (/^\s*\S+@\S+:.*[$#]/m.test(code)) return 'bash';
|
||||
if (/^\s*(package|func)\s+\w/m.test(code)) return 'go';
|
||||
if (/^\s*[-\w.]+:\s*(\S|$)/m.test(code) && !/[;{}]/.test(code)) return 'yaml';
|
||||
if (/^\s*(SELECT|INSERT|UPDATE|CREATE TABLE)\b/im.test(code)) return 'sql';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Kode til HTML med spans. Ukendt sprog giver teksten uændret, blot undsluppet. */
|
||||
export function highlight(code, language) {
|
||||
const rules = LANGUAGES[ALIASES[language] ?? language];
|
||||
if (!rules) return escape(code);
|
||||
|
||||
let out = '';
|
||||
let rest = code;
|
||||
let guard = 0;
|
||||
|
||||
while (rest && guard++ < 20000) {
|
||||
let matched = false;
|
||||
for (const [kind, re] of rules) {
|
||||
const m = re.exec(rest);
|
||||
if (!m || !m[0]) continue;
|
||||
out += `<span class="tok-${kind}">${escape(m[0])}</span>`;
|
||||
rest = rest.slice(m[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (!matched) {
|
||||
// Ét tegn frem. Langsommere end at springe til næste interessante sted og
|
||||
// umuligt at få til at gå i stå, hvilket er den rigtige byttehandel i noget,
|
||||
// der kører på hvert tastetryk i en note, nogen sidder og skriver i.
|
||||
out += escape(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
}
|
||||
return out + escape(rest);
|
||||
}
|
||||
@@ -16,6 +16,31 @@
|
||||
*/
|
||||
export const da = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Del i',
|
||||
'notes.private': 'Kun mig',
|
||||
'notes.shared': 'Noten er delt med projektets folk.',
|
||||
'notes.unshared': 'Noten er din igen.',
|
||||
'notes.save': 'Gem',
|
||||
'notes.format': 'Formatér',
|
||||
'notes.bold': 'Fed',
|
||||
'notes.italic': 'Kursiv',
|
||||
'notes.underline': 'Understreget',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
'notes.styleSubheading': 'Underrubrik',
|
||||
'notes.styleBody': 'Brødtekst',
|
||||
'notes.styleMono': 'Monotype',
|
||||
'notes.styleQuote': 'Blokcitat',
|
||||
'notes.styleBullet': 'Punktliste',
|
||||
'notes.styleNumbered': 'Nummereret liste',
|
||||
'notes.favourite': 'Gør til favorit',
|
||||
'notes.unfavourite': 'Fjern som favorit',
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'notes.yesterday': 'i går',
|
||||
'notes.title': 'Noter',
|
||||
'notes.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
@@ -29,6 +54,9 @@ export const da = {
|
||||
'notes.saved': 'Gemt',
|
||||
'notes.delete': 'Slet',
|
||||
'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
|
||||
'palette.project': 'Projekt',
|
||||
'palette.task': 'Opgave',
|
||||
'palette.note': 'Note',
|
||||
'nav.today': 'I dag',
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
@@ -124,6 +152,16 @@ export const da = {
|
||||
'account.appearance': 'Udseende',
|
||||
'account.appearanceHint':
|
||||
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
||||
'account.look': 'Udseende',
|
||||
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdandeNote': 'Som du kender den.',
|
||||
'look.rolig': 'Rolig',
|
||||
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||
'look.taet': 'Tæt',
|
||||
'look.taetNote': 'Flere rækker på skærmen, mindre luft.',
|
||||
'look.terminal': 'Terminal',
|
||||
'look.terminalNote': 'Én bredde til det hele. Alt flugter.',
|
||||
'account.password': 'Adgangskode',
|
||||
'account.passwordHint':
|
||||
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
|
||||
@@ -318,6 +356,8 @@ export const da = {
|
||||
'data.csvFile': 'CSV-fil',
|
||||
'data.importing': 'Importerer …',
|
||||
'data.openProject': 'Åbn projektet',
|
||||
'data.doneHint': 'Alt du har lukket, nyeste først. Lukkede du noget ved en fejl, ligger det her — og kan åbnes igen.',
|
||||
'data.openDone': 'Åbn færdige opgaver',
|
||||
'data.trash': 'Papirkurv',
|
||||
'data.trashEmpty': 'Papirkurven er tom.',
|
||||
'data.restore': 'Hent tilbage',
|
||||
@@ -331,6 +371,10 @@ export const da = {
|
||||
'data.swept': 'ryddet væk',
|
||||
'data.empty': 'tom',
|
||||
'data.export': 'Eksport',
|
||||
'data.importNotes': 'Importér noter (zip med Markdown-filer)',
|
||||
'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.exportAccount': 'Hele kontoen som JSON',
|
||||
'data.exportOne': 'Ét projekt',
|
||||
'data.pickProject': 'Vælg et projekt',
|
||||
@@ -435,6 +479,7 @@ export const da = {
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.notFound': 'Opgaven findes ikke, eller den er ikke din.',
|
||||
'detail.task': 'Opgave',
|
||||
'detail.close': 'Luk',
|
||||
'detail.text': 'Opgavens tekst',
|
||||
|
||||
@@ -12,6 +12,31 @@
|
||||
*/
|
||||
export const en = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Share in',
|
||||
'notes.private': 'Only me',
|
||||
'notes.shared': "The note is shared with the project's people.",
|
||||
'notes.unshared': 'The note is yours again.',
|
||||
'notes.save': 'Save',
|
||||
'notes.format': 'Format',
|
||||
'notes.bold': 'Bold',
|
||||
'notes.italic': 'Italic',
|
||||
'notes.underline': 'Underline',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
'notes.styleSubheading': 'Subheading',
|
||||
'notes.styleBody': 'Body',
|
||||
'notes.styleMono': 'Monospaced',
|
||||
'notes.styleQuote': 'Block quote',
|
||||
'notes.styleBullet': 'Bulleted list',
|
||||
'notes.styleNumbered': 'Numbered list',
|
||||
'notes.favourite': 'Make a favourite',
|
||||
'notes.unfavourite': 'Remove from favourites',
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.yesterday': 'yesterday',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
@@ -25,6 +50,9 @@ export const en = {
|
||||
'notes.saved': 'Saved',
|
||||
'notes.delete': 'Delete',
|
||||
'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
|
||||
'palette.project': 'Project',
|
||||
'palette.task': 'Task',
|
||||
'palette.note': 'Note',
|
||||
'nav.today': 'Today',
|
||||
'nav.upcoming': 'Upcoming',
|
||||
'nav.delegated': 'Waiting on others',
|
||||
@@ -120,6 +148,16 @@ export const en = {
|
||||
'account.appearance': 'Appearance',
|
||||
'account.appearanceHint':
|
||||
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
||||
'account.look': 'Appearance',
|
||||
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdandeNote': 'As you know it.',
|
||||
'look.rolig': 'Calm',
|
||||
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||
'look.taet': 'Tight',
|
||||
'look.taetNote': 'More rows on screen, less air.',
|
||||
'look.terminal': 'Terminal',
|
||||
'look.terminalNote': 'One width for everything. It all lines up.',
|
||||
'account.password': 'Password',
|
||||
'account.passwordHint':
|
||||
'Changing it signs every other session out — which is usually the reason to do it.',
|
||||
@@ -313,6 +351,8 @@ export const en = {
|
||||
'data.csvFile': 'CSV file',
|
||||
'data.importing': 'Importing …',
|
||||
'data.openProject': 'Open the project',
|
||||
'data.doneHint': 'Everything you have closed, newest first. Closed something by mistake and it is here — and can be opened again.',
|
||||
'data.openDone': 'Open finished tasks',
|
||||
'data.trash': 'Trash',
|
||||
'data.trashEmpty': 'The trash is empty.',
|
||||
'data.restore': 'Restore',
|
||||
@@ -326,6 +366,10 @@ export const en = {
|
||||
'data.swept': 'swept away',
|
||||
'data.empty': 'empty',
|
||||
'data.export': 'Export',
|
||||
'data.importNotes': 'Import notes (a zip of Markdown files)',
|
||||
'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.exportAccount': 'The whole account as JSON',
|
||||
'data.exportOne': 'One project',
|
||||
'data.pickProject': 'Pick a project',
|
||||
@@ -431,6 +475,7 @@ export const en = {
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.notFound': 'That task does not exist, or it is not yours.',
|
||||
'detail.task': 'Task',
|
||||
'detail.close': 'Close',
|
||||
'detail.text': 'The task’s text',
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Between what you see and what is stored.
|
||||
*
|
||||
* The editor is rich text; the note on disk is Markdown. That is not a compromise
|
||||
* anybody has to live with silently — it is the reason the export is trustworthy:
|
||||
* the file a note becomes is the file it already was. So these two functions are
|
||||
* the seam, and they have to agree with each other exactly, or a note changes
|
||||
* shape a little every time it is opened and saved.
|
||||
*
|
||||
* The set of styles is deliberately small and matches what Apple Notes offers,
|
||||
* because that is the vocabulary somebody coming from there already has:
|
||||
*
|
||||
* Titel → #
|
||||
* Overskrift → ##
|
||||
* Underrubrik → ###
|
||||
* Brødtekst → nothing
|
||||
* Monotype → four spaces
|
||||
* Punktliste → -
|
||||
* Nummereret → 1.
|
||||
* Blokcitat → >
|
||||
*
|
||||
* Bold, italic and strikethrough have Markdown of their own. Underline does not —
|
||||
* there is no such thing in any Markdown dialect — so it is stored as inline HTML,
|
||||
* which every Markdown reader passes through untouched. It is the one place where
|
||||
* the file is not pure Markdown, and it is that rather than dropping a style the
|
||||
* source program has.
|
||||
*/
|
||||
|
||||
const BLOCKS = [
|
||||
{ style: 'title', tag: 'h1', prefix: '# ' },
|
||||
{ style: 'heading', tag: 'h2', prefix: '## ' },
|
||||
{ style: 'subheading', tag: 'h3', prefix: '### ' },
|
||||
{ style: 'quote', tag: 'blockquote', prefix: '> ' },
|
||||
{ style: 'mono', tag: 'pre', prefix: ' ' }
|
||||
];
|
||||
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
return escapeHtml(text)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
/**
|
||||
* markdownToHtml renders a note for the editor.
|
||||
*
|
||||
* Every line becomes exactly one block. Markdown would normally join consecutive
|
||||
* lines into one paragraph; here it must not, because the person typed a line and
|
||||
* expects to get it back — an editor that silently reflows what somebody wrote is
|
||||
* an editor they stop trusting.
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = (markdown ?? '').split('\n');
|
||||
const out = [];
|
||||
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||
|
||||
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
|
||||
//
|
||||
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
|
||||
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
|
||||
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
|
||||
// var væk efter én gemning.
|
||||
//
|
||||
// Underlisten lægges *inde i* sit `<li>`, ikke ved siden af. Browsere viser
|
||||
// begge dele ens, men kun den ene er en liste i en liste, når den læses tilbage
|
||||
// — og det er læsningen tilbage, der skriver filen.
|
||||
const stack = [];
|
||||
let liOpen = false;
|
||||
|
||||
const closeItem = () => {
|
||||
if (liOpen) {
|
||||
out.push('</li>');
|
||||
liOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeList = (toDepth = 0) => {
|
||||
while (stack.length > toDepth) {
|
||||
closeItem();
|
||||
out.push(`</${stack.pop()}>`);
|
||||
// Efter et niveau lukkes, står vi igen inde i ophavets `<li>`.
|
||||
liOpen = stack.length > 0;
|
||||
}
|
||||
if (stack.length === 0) liOpen = false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
|
||||
// begynder med - inde i et shell-script er ikke et punkt i en liste.
|
||||
const fenced = /^```(\w*)\s*$/.exec(line);
|
||||
if (fence !== null) {
|
||||
if (fenced) {
|
||||
out.push('</code></pre>');
|
||||
fence = null;
|
||||
} else {
|
||||
out.push(escapeHtml(line) + '\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fenced) {
|
||||
closeList();
|
||||
fence = fenced[1] ?? '';
|
||||
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
|
||||
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
|
||||
|
||||
if (item) {
|
||||
const indent = item[1].replace(/\t/g, ' ').length;
|
||||
const depth = Math.floor(indent / 2);
|
||||
const want = item[2] ? 'ul' : 'ol';
|
||||
|
||||
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
|
||||
// `</ul>` lander inde i det `<li>`, det hører til.
|
||||
closeList(depth + 1);
|
||||
|
||||
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
|
||||
// nummereret er to lister, ikke én med blandede mærker.
|
||||
if (stack.length === depth + 1 && stack[depth] !== want) {
|
||||
closeList(depth);
|
||||
}
|
||||
|
||||
while (stack.length < depth + 1) {
|
||||
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
|
||||
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
|
||||
// gør en gemning uden ændringer noten om.
|
||||
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
|
||||
out.push(`<${want}${want === 'ol' ? start : ''}>`);
|
||||
stack.push(want);
|
||||
liOpen = false;
|
||||
}
|
||||
|
||||
closeItem();
|
||||
out.push(`<li>${inlineToHtml(item[4])}`);
|
||||
liOpen = true;
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
|
||||
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
|
||||
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
|
||||
// det blev en liste i et citat til seks citater med et bindestreg i hver.
|
||||
if (/^>(?: |$)/.test(line)) {
|
||||
const inner = [];
|
||||
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^> ?/, ''));
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
out.push(`<blockquote>${markdownToHtml(inner.join('\n'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = BLOCKS.find((b) =>
|
||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||
);
|
||||
if (block) {
|
||||
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}</${block.tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// An empty line is still a line. Without the break the browser collapses the
|
||||
// block to nothing and the caret has nowhere to stand.
|
||||
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
if (fence !== null) out.push('</code></pre>');
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** The inline marks, read back off the DOM. */
|
||||
function inlineToMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||
switch (node.tagName) {
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return inner ? `*${inner}*` : '';
|
||||
case 'S':
|
||||
case 'STRIKE':
|
||||
case 'DEL':
|
||||
return inner ? `~~${inner}~~` : '';
|
||||
case 'U':
|
||||
return inner ? `<u>${inner}</u>` : '';
|
||||
case 'CODE':
|
||||
return inner ? `\`${inner}\`` : '';
|
||||
case 'SPAN': {
|
||||
// The browser's own way of saying bold or italic when it is writing styles
|
||||
// rather than tags. Read as well as tags, because `styleWithCSS` is not
|
||||
// honoured everywhere and a note must not lose a format on the way out.
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : '';
|
||||
if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : '';
|
||||
if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : '';
|
||||
if (/text-decoration[^;]*underline/.test(style)) return inner ? `<u>${inner}</u>` : '';
|
||||
return inner;
|
||||
}
|
||||
case 'BR':
|
||||
return '';
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlToMarkdown reads the editor back.
|
||||
*
|
||||
* Written against the DOM rather than against a string of HTML, because that is
|
||||
* what contenteditable actually produces — and what it produces is not tidy. The
|
||||
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||
* over that is a guess.
|
||||
*/
|
||||
/** The tags that are a block of their own, and must never be read as inline text. */
|
||||
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));
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
/**
|
||||
* @param node hvad der læses
|
||||
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
|
||||
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
|
||||
* indeholde afsnit og lister uden at de bliver til én linje.
|
||||
*/
|
||||
const walk = (node, emit) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.textContent.trim()) emit(child.textContent);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
switch (child.tagName) {
|
||||
case 'UL':
|
||||
case 'OL': {
|
||||
list(child, 0, emit);
|
||||
break;
|
||||
}
|
||||
|
||||
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
|
||||
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
|
||||
// — det var sådan en note kom tilbage som én lang linje efter en
|
||||
// gemning, uden at nogen havde rørt teksten.
|
||||
case 'BLOCKQUOTE': {
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
|
||||
} else {
|
||||
emit('> ' + inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DIV':
|
||||
// The browser's own container when nothing else applies. Read through
|
||||
// it rather than treating it as a block, or every paste adds a level.
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
} else {
|
||||
emit(inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
case 'PRE': {
|
||||
// Back out as a fence, with the language it came in with. The old
|
||||
// four-space form is still read on the way in; it is not written
|
||||
// out any more, because a fence survives being pasted somewhere
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
emit('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
emit(l);
|
||||
}
|
||||
emit('```');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
|
||||
// citatet: læst som én linje forsvandt hvert punktmærke.
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
break;
|
||||
}
|
||||
const text = inlineToMarkdown(child);
|
||||
emit(block ? block.prefix + text : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Én liste, og de lister der ligger i den.
|
||||
*
|
||||
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
|
||||
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
|
||||
* fejl, fordi den sker uden at nogen har rørt noget.
|
||||
*/
|
||||
const list = (el, depth, emit) => {
|
||||
let n = Number(el.getAttribute('start') || 1);
|
||||
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
|
||||
let text;
|
||||
if (nested.length) {
|
||||
const clone = li.cloneNode(true);
|
||||
for (const sub of [...clone.children]) {
|
||||
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
|
||||
}
|
||||
text = inlineToMarkdown(clone).trim();
|
||||
} else {
|
||||
text = inlineToMarkdown(li).trim();
|
||||
}
|
||||
|
||||
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
|
||||
for (const sub of nested) list(sub, depth + 1, emit);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, (l) => lines.push(l));
|
||||
// Trailing blank lines are what an editor accumulates from people pressing
|
||||
// return at the end; they are not content and they grow every time.
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -49,6 +49,8 @@ class AppState {
|
||||
* small request against modelling a count in two places.
|
||||
*/
|
||||
labelsChanged = $state(0);
|
||||
/** Bumped when a note moves, so a list showing them knows to look again. */
|
||||
notesChanged = $state(0);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
@@ -421,6 +423,17 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Files a note under a project, which is what sharing it means. */
|
||||
async moveNoteToProject(noteID, projectID) {
|
||||
try {
|
||||
await api.updateNote(noteID, { project_id: projectID });
|
||||
this.notesChanged++;
|
||||
this.toast(t('notes.filed'));
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProjects() {
|
||||
const { projects } = await api.listProjects();
|
||||
this.projects = projects;
|
||||
@@ -562,6 +575,31 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of the fixed views, as keys.
|
||||
*
|
||||
* Unknown keys are dropped and missing ones appended, so the stored order never
|
||||
* has to be migrated when a view is added or taken away — which has already
|
||||
* happened once, when Færdige left the sidebar.
|
||||
*/
|
||||
navOrder(keys) {
|
||||
const saved = this.user?.nav_order ?? [];
|
||||
const known = new Set(keys);
|
||||
const ordered = saved.filter((k) => known.has(k));
|
||||
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
|
||||
}
|
||||
|
||||
async setNavOrder(order) {
|
||||
const before = this.user?.nav_order ?? [];
|
||||
this.user = { ...this.user, nav_order: order };
|
||||
try {
|
||||
await api.setNavOrder(order);
|
||||
} catch (e) {
|
||||
this.user = { ...this.user, nav_order: before };
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
sectionCollapsed(key) {
|
||||
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
||||
}
|
||||
@@ -844,6 +882,45 @@ export const THEMES = [
|
||||
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
|
||||
];
|
||||
|
||||
/**
|
||||
* The looks: how it reads, as opposed to how bright it is.
|
||||
*
|
||||
* Separate from the themes because they are separate questions. Somebody who
|
||||
* wants a serif face at night should not have to give up the dark palette to get
|
||||
* one, and the two multiply out to fifteen appearances from eight small blocks of
|
||||
* CSS rather than needing one block each.
|
||||
*/
|
||||
export const LOOKS = [
|
||||
{ id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' },
|
||||
{ id: 'rolig', name: 'look.rolig', note: 'look.roligNote' },
|
||||
{ id: 'taet', name: 'look.taet', note: 'look.taetNote' },
|
||||
{ id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' }
|
||||
];
|
||||
|
||||
class Look {
|
||||
current = $state(
|
||||
typeof document === 'undefined'
|
||||
? 'verdande'
|
||||
: (document.documentElement.dataset.look ?? 'verdande')
|
||||
);
|
||||
|
||||
set(id) {
|
||||
if (!LOOKS.some((l) => l.id === id)) return;
|
||||
this.current = id;
|
||||
// The default carries no attribute, so the plain tokens are the plain tokens
|
||||
// and there is nothing to override them back to.
|
||||
if (id === 'verdande') delete document.documentElement.dataset.look;
|
||||
else document.documentElement.dataset.look = id;
|
||||
try {
|
||||
localStorage.setItem('verdande:look', id);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const look = new Look();
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
class Theme {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
|
||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||
import { supported, register } from '$lib/passkey.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
@@ -330,6 +330,29 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||
they are separate settings because they are separate questions. -->
|
||||
<div class="looks">
|
||||
<h3>{t('account.look')}</h3>
|
||||
<p class="hint">{t('account.lookHint')}</p>
|
||||
<div class="look-row">
|
||||
{#each LOOKS as option (option.id)}
|
||||
<button
|
||||
class="look-card"
|
||||
class:chosen={look.current === option.id}
|
||||
onclick={() => look.set(option.id)}
|
||||
aria-pressed={look.current === option.id}
|
||||
>
|
||||
<!-- Each card is written in the face it selects, because that is the
|
||||
only part of the choice a name cannot carry. -->
|
||||
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
||||
<span class="theme-name">{t(option.name)}</span>
|
||||
<span class="theme-note">{t(option.note)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -528,6 +551,46 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.looks {
|
||||
margin-top: var(--s4);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.looks h3 {
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.look-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--s2);
|
||||
margin-top: var(--s3);
|
||||
}
|
||||
|
||||
.look-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.look-card.chosen {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.look-sample {
|
||||
font-family: var(--font);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
|
||||
/** Import, export and project templates. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
@@ -57,6 +58,34 @@
|
||||
// --- Todoist import ------------------------------------------------------------
|
||||
|
||||
let importing = $state(false);
|
||||
let importingNotes = $state(false);
|
||||
|
||||
/**
|
||||
* A zip of Markdown files becomes notes.
|
||||
*
|
||||
* The whole archive in one request rather than a file at a time: the pictures
|
||||
* have to be in the same request as the notes that name them, or the links
|
||||
* cannot be rewritten.
|
||||
*/
|
||||
async function importNotes(event) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
importingNotes = true;
|
||||
try {
|
||||
const result = await api.importNotes(file);
|
||||
app.toast(
|
||||
t('data.notesImported', {
|
||||
n: result.created ?? 0,
|
||||
files: result.files ?? 0
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
importingNotes = false;
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
let result = $state(null);
|
||||
|
||||
async function importTodoist(event) {
|
||||
@@ -225,6 +254,19 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||||
to the four that are read every day, and it belongs with the other "where did
|
||||
that go" questions rather than above them. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('done.title')}</h2>
|
||||
<p class="hint">{t('data.doneHint')}</p>
|
||||
</header>
|
||||
<div class="row">
|
||||
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('data.trash')}</h2>
|
||||
@@ -328,6 +370,22 @@
|
||||
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- The way back in, and from anywhere else: a folder of Markdown is what
|
||||
Obsidian, Bear and iA Writer all produce, and what the Apple Notes script in
|
||||
tools/ writes. Pictures beside the files come with them. -->
|
||||
<div class="field">
|
||||
<label for="import-notes">{t('data.importNotes')}</label>
|
||||
<input
|
||||
id="import-notes"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onchange={importNotes}
|
||||
disabled={importingNotes}
|
||||
/>
|
||||
<p class="hint">{t('data.importNotesHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
|
||||
let notes = $state([]);
|
||||
let selected = $state(null);
|
||||
@@ -30,6 +33,16 @@
|
||||
load(query);
|
||||
});
|
||||
|
||||
// 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 || selected?.id === asked) return;
|
||||
const found = notes.find((n) => n.id === asked);
|
||||
if (found) open(found);
|
||||
else api.note(asked).then(open).catch(() => {});
|
||||
});
|
||||
|
||||
async function load(q) {
|
||||
try {
|
||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
||||
@@ -47,6 +60,62 @@
|
||||
draft = note?.body ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The first line or so of a note, without its marks.
|
||||
*
|
||||
* The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
|
||||
* which is exactly the thing the editor was built to stop showing. What is
|
||||
* wanted here is the sentence, not the notation.
|
||||
*/
|
||||
function plain(body) {
|
||||
return (body ?? '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*>\s?/gm, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/~~([^~]+)~~/g, '$1')
|
||||
.replace(/<\/?u>/g, '')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvornår, skrevet som man ville sige det.
|
||||
*
|
||||
* "i går" og et klokkeslæt i dag; en dato, når det er længere siden. En note
|
||||
* fra i formiddags og en fra i fjor skal ikke se ens ud i en liste, man skanner.
|
||||
*/
|
||||
function when(iso) {
|
||||
if (!iso) return '';
|
||||
const at = new Date(iso);
|
||||
const now = new Date();
|
||||
const sameDay = at.toDateString() === now.toDateString();
|
||||
if (sameDay) return at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (at.toDateString() === yesterday.toDateString()) return t('notes.yesterday');
|
||||
|
||||
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function projectName(id) {
|
||||
return app.projects.find((p) => p.id === id)?.name ?? '';
|
||||
}
|
||||
|
||||
// Trukket ind på et projekt i sidebjælken. Nyttelasten er notens id; sidebjælken
|
||||
// kender allerede formen fra opgaver.
|
||||
let dragging = $state(null);
|
||||
|
||||
function onDragStart(event, note) {
|
||||
dragging = note.id;
|
||||
startDrag(event, NOTE, note.id);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
try {
|
||||
const note = await api.createNote({ body: '' });
|
||||
@@ -88,6 +157,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites are the pinned ones. The list already puts them first, so marking
|
||||
* one is the same act as saying "keep this where I can see it" — one idea, not
|
||||
* two that have to be explained apart.
|
||||
*/
|
||||
async function favourite(note) {
|
||||
const pinned = !note.pinned;
|
||||
// Moved before the answer comes back: a star that waits for a round trip
|
||||
// feels broken on a slow connection, and the cost of being wrong is a star.
|
||||
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned } : n));
|
||||
try {
|
||||
await api.updateNote(note.id, { pinned });
|
||||
await load(query);
|
||||
} catch (e) {
|
||||
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned: !pinned } : n));
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the note into a project, which is what sharing it means: everybody who
|
||||
* can read the project can read the note, and an editor can change it. Choosing
|
||||
* the empty option takes it back out and makes it the author's alone again.
|
||||
*/
|
||||
async function share(projectId) {
|
||||
if (!selected) return;
|
||||
const id = selected.id;
|
||||
try {
|
||||
const saved = await api.updateNote(id, { project_id: projectId });
|
||||
notes = notes.map((n) => (n.id === id ? saved : n));
|
||||
if (selected?.id === id) selected = saved;
|
||||
app.toast(projectId ? t('notes.shared') : t('notes.unshared'));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(note) {
|
||||
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
||||
try {
|
||||
@@ -99,8 +205,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a link is called on screen.
|
||||
*
|
||||
* A project tag is stored folded, because it is a key — but showing the folded
|
||||
* key would mean a note that says #garageristeriet is labelled with a spelling
|
||||
* that appears nowhere, neither in the note nor on the project. The project's
|
||||
* own name is the right answer, and the fold is what makes it findable.
|
||||
*/
|
||||
function label(link) {
|
||||
if (link.kind !== 'project') return link.target_id;
|
||||
const project = app.projects.find(
|
||||
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||
);
|
||||
return '#' + (project?.name ?? link.target_id);
|
||||
}
|
||||
|
||||
// What the note points at, for the panel under the editor.
|
||||
let links = $derived(selected?.links ?? []);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
@@ -128,10 +252,38 @@
|
||||
<ul>
|
||||
{#each notes as note (note.id)}
|
||||
<li>
|
||||
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<span class="preview">{note.body.slice(0, 80)}</span>
|
||||
</button>
|
||||
<div class="rowline">
|
||||
<button
|
||||
class="row"
|
||||
class:on={selected?.id === note.id}
|
||||
onclick={() => open(note)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
|
||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
||||
<span class="under">
|
||||
<span class="when">{when(note.updated_at)}</span>
|
||||
<span class="preview">{plain(note.body)}</span>
|
||||
</span>
|
||||
{#if note.project_id}
|
||||
<span class="filed">{projectName(note.project_id)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="star"
|
||||
class:on={note.pinned}
|
||||
onclick={() => favourite(note)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
>
|
||||
{note.pinned ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -140,25 +292,44 @@
|
||||
|
||||
<section class="editor">
|
||||
{#if selected}
|
||||
<textarea
|
||||
bind:value={draft}
|
||||
oninput={typed}
|
||||
onblur={save}
|
||||
spellcheck="true"
|
||||
aria-label={t('notes.body')}
|
||||
placeholder={t('notes.placeholder')}
|
||||
></textarea>
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
onchange={(body) => {
|
||||
draft = body;
|
||||
typed();
|
||||
}}
|
||||
onsave={save}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||
{#if links.length}
|
||||
<span class="links">
|
||||
{#each links as link}
|
||||
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
||||
<span class="link">{label(link)}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||
<!-- Sharing a note is filing it in a project.
|
||||
Not an access list of its own: projects already have members, roles
|
||||
and invitations, all of them tested and all of them things people
|
||||
have already learnt here. A second way to give somebody access is a
|
||||
second place to get it wrong, and the two would disagree on the day
|
||||
one of them was changed. -->
|
||||
<label class="share">
|
||||
<span class="hint">{t('notes.shareWith')}</span>
|
||||
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
||||
<option value="">{t('notes.private')}</option>
|
||||
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||
</div>
|
||||
</footer>
|
||||
{:else}
|
||||
<p class="empty">{t('notes.pickOne')}</p>
|
||||
@@ -168,6 +339,18 @@
|
||||
|
||||
<style>
|
||||
.notes {
|
||||
/* En grid-kolonne er som udgangspunkt mindst så bred som sit bredeste
|
||||
indhold, og et API-token uden et mellemrum i er ét meget bredt ord. Uden
|
||||
min-width: 0 skubber sådan en note hele listen bredere, og editoren
|
||||
klemmes sammen — på en note man ikke engang har åben. */
|
||||
min-width: 0;
|
||||
|
||||
/* Air on all sides. The heading, the search box and the list all sat flush
|
||||
against the sidebar's rule, which reads as a rendering fault rather than
|
||||
as a layout. */
|
||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||
ingen kant havde og ingenting kunne støde imod. */
|
||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -180,6 +363,7 @@
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: var(--s3);
|
||||
}
|
||||
@@ -192,6 +376,7 @@
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.new {
|
||||
@@ -204,8 +389,27 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Tegnet af os, ikke af browseren.
|
||||
Safari giver type="search" sit eget udseende — pilleform og forstørrelsesglas
|
||||
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
||||
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
||||
udgave, og den ser ens ud hele tiden. */
|
||||
.search {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-left: 30px;
|
||||
border-radius: 999px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b918d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-3.5-3.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 9px center;
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
|
||||
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
|
||||
.search::-webkit-search-decoration,
|
||||
.search::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
@@ -216,13 +420,43 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rowline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.star {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rowline:hover .star,
|
||||
.star.on,
|
||||
.star:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.star.on {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: var(--s2);
|
||||
padding: var(--s2) var(--s2) var(--s3);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
li:last-child .row {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
@@ -234,47 +468,169 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
||||
slags — noget, der står på en liste og skal læses på et blik — og at give dem
|
||||
hver sin størrelse får den ene til at se mindre vigtig ud end den anden. */
|
||||
.row strong {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
||||
.under {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.when {
|
||||
flex: none;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
|
||||
og den eneste linje her, der ikke er notens eget indhold. */
|
||||
.filed {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.filed::before {
|
||||
content: '▸ ';
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Noten er et ark, ikke en flade i appen.
|
||||
*
|
||||
* Apple Notes skriver på hvidt, og det er ikke pynt: et ark, der holder op et
|
||||
* sted, fortæller hvor teksten hører til, og skiller det, man skriver, fra det
|
||||
* program, man skriver det i. Uden det flød noten ud i siden og lignede endnu
|
||||
* en rude.
|
||||
*
|
||||
* `--surface` frem for `#fff`, fordi der er fem temaer. Rollen er "arket løftet
|
||||
* fra grunden" — hvid i lyst, cremet i paper, en anelse lysere end grunden i de
|
||||
* mørke. Et hårdkodet hvidt ville være et blændende ark i mørkt tema. */
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
/* Luft nok til at teksten ikke rører kanten.
|
||||
*
|
||||
* Vandret vokser den med ruden: på en bred skærm skal et ark have marginer,
|
||||
* ikke bare en kant — clamp holder den mellem en rimelig mindste og en, der
|
||||
* ikke bliver til et vindue med tekst i midten. */
|
||||
padding: var(--s4) clamp(var(--s4), 3.5%, var(--s6));
|
||||
}
|
||||
|
||||
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||
set on both at once on purpose: a font-size on one and not the other is a
|
||||
whole line of drift by the bottom of a long note. */
|
||||
.paper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.paper textarea,
|
||||
.paper .mirror {
|
||||
font: inherit;
|
||||
font-size: var(--text-base, 1rem);
|
||||
line-height: 1.6;
|
||||
padding: var(--s2) 0;
|
||||
border: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 50vh;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
padding: var(--s2) 0;
|
||||
/* Transparent, so what is read is the mirror underneath. The caret keeps its
|
||||
own colour or there would be nothing to type against. */
|
||||
color: transparent;
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
|
||||
textarea::selection {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Headings change weight and colour, never size: a larger font would take more
|
||||
lines than the textarea does and everything below it would slide. */
|
||||
.mirror .h1,
|
||||
.mirror .h2,
|
||||
.mirror .h3,
|
||||
.mirror .h4,
|
||||
.mirror .h5,
|
||||
.mirror .h6 {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .h1 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mirror .quote {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mirror .bold {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mirror .code {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mirror .tag,
|
||||
.mirror .wikilink {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -298,15 +654,46 @@
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.remove {
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
.share {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
.share select {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
}
|
||||
|
||||
/* Real buttons. They were bare words, which on a Mac reads as a web page rather
|
||||
than as something you can press. */
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s1) var(--s3);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint {
|
||||
color: var(--ink-faint);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
/**
|
||||
* A task, by address.
|
||||
*
|
||||
* There was no such thing until a note wanted to point at one. Opening a task
|
||||
* shows a drawer over whatever list you were on and changes nothing in the URL,
|
||||
* which is right for a click and useless for a link — you cannot send somebody a
|
||||
* task, and a note that mentions one has nowhere to send them either.
|
||||
*
|
||||
* So this opens the drawer and then steps aside, replacing itself with the
|
||||
* task's own project. The person lands where the task lives, with it open, and
|
||||
* the back button does not walk them through a page that only ever redirected.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let status = $state('loading');
|
||||
|
||||
$effect(() => {
|
||||
open($page.params.id);
|
||||
});
|
||||
|
||||
async function open(id) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const task = await api.getTask(id);
|
||||
app.openDetail(id);
|
||||
// replaceState, so Back goes where they came from rather than here.
|
||||
await goto(task.project_id ? `/projekt/${task.project_id}` : '/', { replaceState: true });
|
||||
} catch {
|
||||
// 404 for both "no such task" and "not yours", so an id tells a stranger
|
||||
// nothing about whether it exists.
|
||||
status = 'missing';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if status === 'missing'}
|
||||
<p class="clear">{t('detail.notFound')}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.clear {
|
||||
padding: var(--s4);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -43,21 +43,33 @@
|
||||
load(id);
|
||||
});
|
||||
|
||||
// Notes that mention this project. Looked up by name rather than by id, because
|
||||
// a #tag is written by hand and a person types the name — the same string the
|
||||
// quick-add parser reads out of a task.
|
||||
// Two ways a note belongs here, and both count.
|
||||
//
|
||||
// Filed in the project — which is what sharing a note means — and mentioning it
|
||||
// by #tag, which is what somebody does while writing without stopping to file
|
||||
// anything. Looked up by name for the second, because a #tag is written by hand
|
||||
// and a person types the name: the same string the quick-add parser reads out of
|
||||
// a task.
|
||||
let notes = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
const id = project?.id;
|
||||
const name = project?.name;
|
||||
if (!name) {
|
||||
if (!id || !name) {
|
||||
notes = [];
|
||||
return;
|
||||
}
|
||||
api
|
||||
.notesLinking('project', name)
|
||||
.then((r) => (notes = r.notes ?? []))
|
||||
.catch(() => (notes = []));
|
||||
Promise.all([
|
||||
api.notes({ project: id }).catch(() => ({ notes: [] })),
|
||||
api.notesLinking('project', name).catch(() => ({ notes: [] }))
|
||||
]).then(([filed, mentioned]) => {
|
||||
const seen = new Set();
|
||||
notes = [...(filed.notes ?? []), ...(mentioned.notes ?? [])].filter((n) => {
|
||||
if (seen.has(n.id)) return false;
|
||||
seen.add(n.id);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function load(projectId) {
|
||||
|
||||
Reference in New Issue
Block a user