Compare commits
@@ -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,163 @@ 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]
|
||||
summary: Notes — listed, narrowed to a project, or searched.
|
||||
description: >
|
||||
With no parameters: the notes filed under no project, which are the
|
||||
person's own. With `project`: that project's, which needs read access to
|
||||
it. With `q`: a full-text search across every note they can see, matching
|
||||
Danish spelling either way round — "grøn" finds "gron" and the reverse.
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
schema: { type: string }
|
||||
- name: project
|
||||
in: query
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
notes:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Note" }
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Write a note.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/NoteInput" }
|
||||
responses:
|
||||
"201":
|
||||
description: Written
|
||||
content:
|
||||
application/json:
|
||||
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
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: One note.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Note" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
patch:
|
||||
tags: [notes]
|
||||
summary: Change a note. Every field is optional; what is sent is what changes.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/NoteInput" }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Note" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Put a note in the trash. It can be brought back.
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/linking/{kind}/{targetID}:
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: What has been written about this.
|
||||
description: >
|
||||
The backwards direction. A task's id gives every note that mentions it,
|
||||
a project's name gives every note carrying that #tag.
|
||||
parameters:
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, enum: [task, note, project] }
|
||||
- name: targetID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
notes:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Note" }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/mailboxes:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -3162,6 +3349,33 @@ components:
|
||||
items: { type: string }
|
||||
|
||||
schemas:
|
||||
Note:
|
||||
type: object
|
||||
description: A page of Markdown. The body is the stored form and the exported form.
|
||||
properties:
|
||||
id: { type: string }
|
||||
project_id: { type: string }
|
||||
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 }
|
||||
updated_at: { type: string, format: date-time }
|
||||
links:
|
||||
type: array
|
||||
description: What the body points at, read out of the text on the way in.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
kind: { type: string, enum: [task, note, project] }
|
||||
target_id: { type: string }
|
||||
|
||||
NoteInput:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Empty files it under no project." }
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Mailbox:
|
||||
type: object
|
||||
description: One connected mailbox. The password and any token are never returned.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Searching and listing are the same endpoint, because they answer the same
|
||||
// question with different words. ?q= searches; ?project= narrows; neither gives
|
||||
// the loose ones.
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
||||
found, err := s.db.SearchNotes(r.Context(), q, 50)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
return
|
||||
}
|
||||
|
||||
if projectID := r.URL.Query().Get("project"); projectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
|
||||
s.storeError(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesInProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
//
|
||||
// Filtered after the search rather than inside it: FTS5 answers about text, and
|
||||
// teaching the index about roles would put an access rule in a place nobody would
|
||||
// think to look when it was wrong.
|
||||
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if s.mayTouchNote(r, &n, store.RoleViewer) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// The same answer for "no such note" and "not yours", so an id cannot be probed.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
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"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID}
|
||||
if body.ProjectID != nil && *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "move note", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleNotesLinking answers the backwards question for a task or a project:
|
||||
// what has somebody written about this.
|
||||
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
||||
kind := chi.URLParam(r, "kind")
|
||||
if kind != "task" && kind != "project" && kind != "note" {
|
||||
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes linking", err)
|
||||
return
|
||||
}
|
||||
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 two ways a Markdown file names a neighbouring image: by the path it
|
||||
// was written with, and by its bare name.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
||||
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
|
||||
}
|
||||
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
body = strings.ReplaceAll(body, relative, url)
|
||||
body = strings.ReplaceAll(body, base, 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"
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A note in a project is the project's; a note in no project is its author's.
|
||||
// Both answers come from the project's own roles rather than from a rule notes
|
||||
// invented for themselves, which is the point of filing them there.
|
||||
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Noget privat om #Firma",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("no id came back")
|
||||
}
|
||||
|
||||
// The tags were read out of the text on the way in, without being asked for.
|
||||
links, _ := body["links"].([]any)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("the note recorded %v", body["links"])
|
||||
}
|
||||
|
||||
// Somebody else gets the same answer as for a note that does not exist, so an
|
||||
// id cannot be probed for existence.
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Searching must not become a way to read what you cannot open. The filter is
|
||||
// applied after the index, and this is what says so.
|
||||
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Hemmeligt kodeord til hjemmesiden",
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(mine["notes"].([]any)); got != 1 {
|
||||
t.Fatalf("the author found %d of their own notes", got)
|
||||
}
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(theirs["notes"].([]any)); got != 0 {
|
||||
t.Errorf("a stranger's search returned %d notes", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The backwards question, which is what makes notes worth linking at all.
|
||||
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til Anders",
|
||||
})
|
||||
taskID, _ := task["id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("no task id: %v", task)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatal("the note was not written")
|
||||
}
|
||||
|
||||
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
|
||||
notes, _ := found["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("%d notes point at the task, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// Editing the text moves the links with it. A link that outlives its mention
|
||||
// claims a connection that is not there.
|
||||
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
|
||||
id, _ := note["id"].(string)
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(before["notes"].([]any)) != 1 {
|
||||
t.Fatal("the first save recorded nothing")
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
|
||||
"body": "Om #Regnskab i stedet",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("the note was not changed")
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if got := len(after["notes"].([]any)); got != 0 {
|
||||
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)
|
||||
@@ -276,6 +277,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
|
||||
// belongs to the person who connected it, so there is no admin gate
|
||||
// here and no shared registration: the credential is theirs.
|
||||
// Notes. Listing and searching are the same endpoint because they answer
|
||||
// the same question with different words; the backwards one — what has
|
||||
// been written about this task — hangs off the thing being asked about.
|
||||
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)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListMailboxes)
|
||||
r.Post("/", s.handleAddMailbox)
|
||||
@@ -308,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;
|
||||
+45
-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]))
|
||||
@@ -176,11 +184,42 @@ func (db *DB) NotesInProject(ctx context.Context, projectID string) ([]Note, err
|
||||
projectID)
|
||||
}
|
||||
|
||||
// LooseNotes are the ones filed under no project — a person's own, the way a task
|
||||
// in the inbox is. Nobody else can see them, so there is no role to ask about.
|
||||
func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE project_id IS NULL AND created_by = ? AND deleted_at IS NULL
|
||||
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
+308
@@ -0,0 +1,308 @@
|
||||
#!/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.
|
||||
lines = text.split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
||||
lines = lines[1:]
|
||||
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())
|
||||
+413
-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.
|
||||
//
|
||||
@@ -1900,3 +1910,405 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan skrives, findes igen, og peger på det den nævner', 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('Notetesteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Noter' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
|
||||
// Saved on the way out, so there is no Gem-knap to forget.
|
||||
await body.blur();
|
||||
|
||||
// The title comes from the first line, the way Apple Notes does it. Without it
|
||||
// a list of notes is a list of "Uden titel".
|
||||
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||
|
||||
// And the #tag was read out of the text, unasked. This is the whole point: it
|
||||
// is the same tag a task carries, so the note can be found from the project.
|
||||
// Scoped to the panel under the editor: the same text is also in the textarea
|
||||
// and in the list preview, which is three of the same thing and none of them
|
||||
// the one being asserted.
|
||||
await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
|
||||
|
||||
// Findable by a word from the body, and in the other Danish spelling too.
|
||||
await page.getByLabel('Søg i noter').fill('møde');
|
||||
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||
await page.getByLabel('Søg i noter').fill('mode');
|
||||
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||
|
||||
// It survives a reload, which is the only proof that it was ever written down.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
|
||||
|
||||
// And the payoff: the note turns up in the project it named, without anybody
|
||||
// filing it there. This is what a #tag being the same tag actually buys.
|
||||
await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.notes').getByRole('link', { name: /Møde om/ })
|
||||
).toBeVisible();
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
+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 }),
|
||||
@@ -360,6 +361,18 @@ export const api = {
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
// Notes. Listing and searching are one endpoint because they answer the same
|
||||
// question with different words.
|
||||
notes: (params = {}) => {
|
||||
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
|
||||
return get('/notes' + (q ? `?${q}` : ''));
|
||||
},
|
||||
note: (id) => get(`/notes/${id}`),
|
||||
createNote: (body) => post('/notes', body),
|
||||
updateNote: (id, body) => patch(`/notes/${id}`, body),
|
||||
deleteNote: (id) => del(`/notes/${id}`),
|
||||
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
||||
|
||||
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||
// there is no instance-wide registration behind these the way Gmail has one.
|
||||
mailboxes: () => get('/mailboxes'),
|
||||
@@ -378,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,694 @@
|
||||
<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;
|
||||
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 = {};
|
||||
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();
|
||||
}
|
||||
|
||||
// --- suggesting a project ------------------------------------------------------
|
||||
|
||||
let suggestions = $state([]);
|
||||
let chosen = $state(0);
|
||||
|
||||
/**
|
||||
* The word being typed after a #, if that is what is happening.
|
||||
*
|
||||
* Read from the text node the caret is in rather than from the whole document:
|
||||
* a note can mention twenty projects, and only the one under the cursor is being
|
||||
* written.
|
||||
*/
|
||||
function partialTag() {
|
||||
const selection = window.getSelection();
|
||||
const node = selection?.anchorNode;
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
}
|
||||
|
||||
function readSuggestions() {
|
||||
const partial = partialTag();
|
||||
if (!partial) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
const term = partial.term.toLowerCase();
|
||||
suggestions = app.projects
|
||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||
.slice(0, 6);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, partial.start);
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
|
||||
/** The block the caret is standing in. */
|
||||
function currentBlock() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.anchorNode || !editor) return null;
|
||||
let node = selection.anchorNode;
|
||||
while (node && node.parentNode !== editor) node = node.parentNode;
|
||||
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title for the first line, body for everything after it.
|
||||
*
|
||||
* Run after the browser has split the block on Enter, because until then there
|
||||
* is no second block to change. Pressing return at the end of a heading leaves
|
||||
* the caret in another heading in most browsers, and a note where every line is
|
||||
* a title is a note with no title at all.
|
||||
*/
|
||||
function keepTitleFirst() {
|
||||
const block = currentBlock();
|
||||
if (!block) return;
|
||||
|
||||
// A heading that is not the first line becomes body.
|
||||
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
return;
|
||||
}
|
||||
|
||||
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||
// return rather than choosing Brødtekst from the menu.
|
||||
if (block.tagName === 'DIV') {
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
}
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
// The suggestion list owns the arrows and return while it is open, the way
|
||||
// every other completion does. Escape closes it without choosing.
|
||||
if (suggestions.length) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen + 1) % suggestions.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
accept(suggestions[chosen]);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// After the split, not before it.
|
||||
setTimeout(() => {
|
||||
keepTitleFirst();
|
||||
changed();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// The shortcuts people already have in their fingers. execCommand handles
|
||||
// bold and italic itself; underline and strikethrough are claimed here so
|
||||
// they behave the same way.
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'u') {
|
||||
event.preventDefault();
|
||||
apply('underline');
|
||||
} else if (key === 'x' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
apply('strikeThrough');
|
||||
}
|
||||
}
|
||||
|
||||
// Pasted text arrives as whatever the source was — a web page brings its fonts,
|
||||
// its colours and its layout with it. Only the words are wanted.
|
||||
function onpaste(event) {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
document.execCommand('insertText', false, text);
|
||||
changed();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||
collapses the selection first, so the format lands on nothing — the button
|
||||
appears to do nothing at all, which is how it read the first time. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="toolbar" onmousedown={(e) => e.preventDefault()}>
|
||||
<div class="styles">
|
||||
<button
|
||||
class="aa"
|
||||
onclick={() => (stylesOpen = !stylesOpen)}
|
||||
aria-expanded={stylesOpen}
|
||||
aria-label={t('notes.format')}
|
||||
title={t('notes.format')}>Aa</button
|
||||
>
|
||||
{#if stylesOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="menu" role="menu">
|
||||
{#each STYLES as style}
|
||||
<button class={style.key} role="menuitem" onclick={() => setStyle(style)}>
|
||||
{t(style.label)}
|
||||
</button>
|
||||
{/each}
|
||||
<hr />
|
||||
<button
|
||||
class:on={active.bullet}
|
||||
role="menuitem"
|
||||
onclick={() => setStyle({ key: 'bullet' })}>• {t('notes.styleBullet')}</button
|
||||
>
|
||||
<button
|
||||
class:on={active.numbered}
|
||||
role="menuitem"
|
||||
onclick={() => setStyle({ key: 'numbered' })}>1. {t('notes.styleNumbered')}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<button class:on={active.bold} onclick={() => apply('bold')} title="⌘B" aria-label={t('notes.bold')}>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button class:on={active.italic} onclick={() => apply('italic')} title="⌘I" aria-label={t('notes.italic')}>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button class:on={active.underline} onclick={() => apply('underline')} title="⌘U" aria-label={t('notes.underline')}>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="page"
|
||||
bind:this={editor}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={t('notes.body')}
|
||||
spellcheck="true"
|
||||
oninput={changed}
|
||||
onblur={() => {
|
||||
colourCode();
|
||||
onsave?.();
|
||||
}}
|
||||
onkeyup={() => {
|
||||
readState();
|
||||
readSuggestions();
|
||||
}}
|
||||
onclick={colourCode}
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||
one that sits still. -->
|
||||
<ul class="suggestions" role="listbox">
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
role="option"
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- What the text understands, said where it is being typed. The same idea as
|
||||
the line under the quick-add box: a note is the other place in this program
|
||||
where what you type is read for meaning. -->
|
||||
<p class="legend">
|
||||
{#each SYNTAX as item}
|
||||
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
padding: 0 0 var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.toolbar button.on {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--line);
|
||||
margin: 0 var(--s1);
|
||||
}
|
||||
|
||||
.styles {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.aa {
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--s1));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 210px;
|
||||
padding: var(--s1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
height: auto;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.menu hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--s1) 0;
|
||||
}
|
||||
|
||||
/* The menu shows each style in the style it applies, the way Apple Notes does.
|
||||
Reading the word "Overskrift" set as one is faster than reading a label. */
|
||||
.menu .title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 640;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .subheading {
|
||||
font-weight: 620;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu .mono {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.menu .quote {
|
||||
border-left: 2px solid var(--line-strong);
|
||||
padding-left: var(--s2);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s1);
|
||||
min-width: 200px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1) var(--s3);
|
||||
padding-top: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.legend kbd {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
a title that is a title, and body text that is comfortable to read for a page
|
||||
at a time. */
|
||||
.page :global(h1) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--s2);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.page :global(h2) {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 640;
|
||||
margin: var(--s3) 0 var(--s1);
|
||||
}
|
||||
|
||||
.page :global(h3) {
|
||||
font-size: 1rem;
|
||||
font-weight: 620;
|
||||
margin: var(--s3) 0 var(--s1);
|
||||
}
|
||||
|
||||
.page :global(p) {
|
||||
margin: 0 0 var(--s2);
|
||||
}
|
||||
|
||||
.page :global(ul),
|
||||
.page :global(ol) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s4);
|
||||
}
|
||||
|
||||
.page :global(li) {
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* 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,70 @@
|
||||
}
|
||||
|
||||
|
||||
// --- the fixed views ---------------------------------------------------------
|
||||
|
||||
// Delegated is only offered where delegating is possible at all: on an instance
|
||||
// with one person that view can never have anything in it, and a permanent empty
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
let orderedNav = $derived(
|
||||
app
|
||||
.navOrder(navItems.map((i) => i.key))
|
||||
.map((key) => navItems.find((i) => i.key === key))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let draggingNav = $state(null);
|
||||
let overNav = $state(null);
|
||||
|
||||
function onNavDragStart(event, key) {
|
||||
draggingNav = key;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// A payload, because Firefox refuses to start a drag without one.
|
||||
event.dataTransfer.setData('text/plain', key);
|
||||
}
|
||||
|
||||
function onNavDragOver(event, key) {
|
||||
if (!draggingNav || key === draggingNav) return;
|
||||
event.preventDefault();
|
||||
overNav = key;
|
||||
}
|
||||
|
||||
function clearNavDrag() {
|
||||
draggingNav = null;
|
||||
overNav = null;
|
||||
}
|
||||
|
||||
async function onNavDrop(event, key) {
|
||||
const moved = draggingNav;
|
||||
// Checked before anything is swallowed. Stopping the event first meant a task
|
||||
// dragged onto I dag never reached the handler that gives it a date — the row
|
||||
// took the drop and then discovered it was not for it.
|
||||
if (!moved || moved === key) {
|
||||
clearNavDrag();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearNavDrag();
|
||||
|
||||
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
|
||||
keys.splice(keys.indexOf(key), 0, moved);
|
||||
await app.setNavOrder(keys);
|
||||
}
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -271,16 +335,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,46 +415,71 @@
|
||||
|
||||
<div class="views">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
{t('nav.today')}
|
||||
</a>
|
||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
||||
<!-- The fixed views, in the order this person put them.
|
||||
Drawn from a list rather than written out, because they can be reordered
|
||||
now and a hand-written run cannot be. Unknown keys are dropped and new
|
||||
ones appended, so adding a view later does not strand anybody's order. -->
|
||||
{#each orderedNav as item (item.key)}
|
||||
{#if item.key === 'inbox'}
|
||||
{#if app.inbox}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
<!-- I dag also takes a task dropped on it — the most-made rescheduling
|
||||
there is, and the sidebar is where the pointer already is. The
|
||||
handlers were lost when this run became a list; the test caught
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (item.key !== 'today' || !carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
They are not a fourth way of looking at the same tasks, which is what
|
||||
everything above is; they are the other half of the program. Sitting in that
|
||||
run made them read as one more filter. -->
|
||||
<div class="apart">
|
||||
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.upcoming')}
|
||||
{t('notes.title')}
|
||||
</a>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
person this view can never have anything in it, and a permanent empty
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
@@ -387,6 +487,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}"
|
||||
@@ -863,6 +978,64 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.navrow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Takes no room until it is wanted: it sits in the padding the row already has,
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navrow.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line where it will land, not a box around what it will land on: the first
|
||||
reads as a position and the second as a target. */
|
||||
.navrow.over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: var(--s3) 0;
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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,45 @@
|
||||
*/
|
||||
export const da = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Del i',
|
||||
'notes.private': 'Kun mig',
|
||||
'notes.shared': 'Noten er delt med projektets folk.',
|
||||
'notes.unshared': 'Noten er din igen.',
|
||||
'notes.save': 'Gem',
|
||||
'notes.format': 'Formatér',
|
||||
'notes.bold': 'Fed',
|
||||
'notes.italic': 'Kursiv',
|
||||
'notes.underline': 'Understreget',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
'notes.styleSubheading': 'Underrubrik',
|
||||
'notes.styleBody': 'Brødtekst',
|
||||
'notes.styleMono': 'Monotype',
|
||||
'notes.styleQuote': 'Blokcitat',
|
||||
'notes.styleBullet': 'Punktliste',
|
||||
'notes.styleNumbered': 'Nummereret liste',
|
||||
'notes.favourite': 'Gør til favorit',
|
||||
'notes.unfavourite': 'Fjern som favorit',
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'notes.yesterday': 'i går',
|
||||
'notes.title': 'Noter',
|
||||
'notes.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
'notes.body': 'Notens tekst',
|
||||
'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.',
|
||||
'notes.untitled': 'Uden titel',
|
||||
'notes.noneYet': 'Ingen noter endnu.',
|
||||
'notes.noneFound': 'Ingen noter matcher.',
|
||||
'notes.pickOne': 'Vælg en note, eller lav en ny.',
|
||||
'notes.saving': 'Gemmer …',
|
||||
'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',
|
||||
@@ -111,6 +150,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.',
|
||||
@@ -305,6 +354,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',
|
||||
@@ -318,6 +369,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',
|
||||
@@ -422,6 +477,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,45 @@
|
||||
*/
|
||||
export const en = {
|
||||
// --- the shell ---------------------------------------------------------------
|
||||
'notes.shareWith': 'Share in',
|
||||
'notes.private': 'Only me',
|
||||
'notes.shared': "The note is shared with the project's people.",
|
||||
'notes.unshared': 'The note is yours again.',
|
||||
'notes.save': 'Save',
|
||||
'notes.format': 'Format',
|
||||
'notes.bold': 'Bold',
|
||||
'notes.italic': 'Italic',
|
||||
'notes.underline': 'Underline',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
'notes.styleSubheading': 'Subheading',
|
||||
'notes.styleBody': 'Body',
|
||||
'notes.styleMono': 'Monospaced',
|
||||
'notes.styleQuote': 'Block quote',
|
||||
'notes.styleBullet': 'Bulleted list',
|
||||
'notes.styleNumbered': 'Numbered list',
|
||||
'notes.favourite': 'Make a favourite',
|
||||
'notes.unfavourite': 'Remove from favourites',
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.yesterday': 'yesterday',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
'notes.body': "The note's text",
|
||||
'notes.placeholder': 'Write. #project and [[another note]] become references.',
|
||||
'notes.untitled': 'Untitled',
|
||||
'notes.noneYet': 'No notes yet.',
|
||||
'notes.noneFound': 'No notes match.',
|
||||
'notes.pickOne': 'Pick a note, or write a new one.',
|
||||
'notes.saving': 'Saving …',
|
||||
'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',
|
||||
@@ -107,6 +146,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.',
|
||||
@@ -300,6 +349,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',
|
||||
@@ -313,6 +364,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',
|
||||
@@ -418,6 +473,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,227 @@
|
||||
/**
|
||||
* 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 list = null;
|
||||
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||
|
||||
const closeList = () => {
|
||||
if (list) {
|
||||
out.push(`</${list}>`);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||
|
||||
if (bullet || numbered) {
|
||||
const want = bullet ? 'ul' : 'ol';
|
||||
if (list !== want) {
|
||||
closeList();
|
||||
out.push(`<${want}>`);
|
||||
list = want;
|
||||
}
|
||||
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
|
||||
const block = BLOCKS.find((b) =>
|
||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||
);
|
||||
if (block) {
|
||||
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}</${block.tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// An empty line is still a line. Without the break the browser collapses the
|
||||
// block to nothing and the caret has nowhere to stand.
|
||||
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
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.
|
||||
*/
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
const walk = (node) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.textContent.trim()) lines.push(child.textContent);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
switch (child.tagName) {
|
||||
case 'UL':
|
||||
case 'OL': {
|
||||
let n = 1;
|
||||
for (const li of child.children) {
|
||||
const text = inlineToMarkdown(li).trim();
|
||||
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DIV':
|
||||
// The browser's own container when nothing else applies. Read through
|
||||
// it rather than treating it as a block, or every paste adds a level.
|
||||
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
|
||||
walk(child);
|
||||
} else {
|
||||
lines.push(inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
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') ?? '';
|
||||
lines.push('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
lines.push(l);
|
||||
}
|
||||
lines.push('```');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||
const text = inlineToMarkdown(child);
|
||||
lines.push(block ? block.prefix + text : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
// Trailing blank lines are what an editor accumulates from people pressing
|
||||
// return at the end; they are not content and they grow every time.
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
<script>
|
||||
/**
|
||||
* Notes.
|
||||
*
|
||||
* A list on the left, the note itself on the right. Not a grid of cards: a note
|
||||
* is read by its first line far more often than by its whole self, and a list
|
||||
* shows twenty of those where a grid shows six.
|
||||
*
|
||||
* Markdown in a plain textarea for now. The live preview — where the marks show
|
||||
* only in the line the cursor is in — is the next piece and the expensive one;
|
||||
* this is the shape it will sit inside, and everything else is already true
|
||||
* without it.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.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);
|
||||
let query = $state('');
|
||||
let status = $state('loading');
|
||||
let saving = $state(false);
|
||||
|
||||
// The text being edited, kept apart from the note it came from so a save that
|
||||
// arrives late cannot overwrite what has been typed since.
|
||||
let draft = $state('');
|
||||
let timer;
|
||||
|
||||
$effect(() => {
|
||||
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 ?? [];
|
||||
status = 'ready';
|
||||
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
||||
} catch (e) {
|
||||
status = 'failed';
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function open(note) {
|
||||
clearTimeout(timer);
|
||||
selected = note;
|
||||
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: '' });
|
||||
notes = [note, ...notes];
|
||||
open(note);
|
||||
document.querySelector('.editor textarea')?.focus();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved as you stop typing rather than on a button.
|
||||
*
|
||||
* A note is the one place in this program where an hour of writing can be lost
|
||||
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
|
||||
* delay is long enough that a sentence is one save and not twenty.
|
||||
*/
|
||||
function typed() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(save, 700);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!selected) return;
|
||||
const id = selected.id;
|
||||
const body = draft;
|
||||
saving = true;
|
||||
try {
|
||||
const saved = await api.updateNote(id, { body });
|
||||
notes = notes.map((n) => (n.id === id ? saved : n));
|
||||
// Only if it is still the one on screen: switching notes mid-save must not
|
||||
// drag the previous one's title back onto this one.
|
||||
if (selected?.id === id) selected = saved;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
await api.deleteNote(note.id);
|
||||
notes = notes.filter((n) => n.id !== note.id);
|
||||
if (selected?.id === note.id) open(notes[0] ?? null);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
<div class="notes">
|
||||
<aside>
|
||||
<div class="head">
|
||||
<h1>{t('notes.title')}</h1>
|
||||
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="search"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('notes.search')}
|
||||
aria-label={t('notes.search')}
|
||||
/>
|
||||
|
||||
{#if status === 'loading'}
|
||||
<p class="hint">{t('history.loading')}</p>
|
||||
{:else if !notes.length}
|
||||
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each notes as note (note.id)}
|
||||
<li>
|
||||
<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>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="editor">
|
||||
{#if selected}
|
||||
<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">{label(link)}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- 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>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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. */
|
||||
padding: var(--s3) 0 0 var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: var(--s3);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.new {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.new:hover {
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
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;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
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 {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: var(--surface-raised);
|
||||
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: 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
background: none;
|
||||
/* Transparent, so what is read is the mirror underneath. The caret keeps its
|
||||
own colour or there would be nothing to type against. */
|
||||
color: transparent;
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
|
||||
textarea::selection {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Headings change weight and colour, never size: a larger font would take more
|
||||
lines than the textarea does and everything below it would slide. */
|
||||
.mirror .h1,
|
||||
.mirror .h2,
|
||||
.mirror .h3,
|
||||
.mirror .h4,
|
||||
.mirror .h5,
|
||||
.mirror .h6 {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .h1 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mirror .quote {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mirror .bold {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mirror .code {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mirror .tag,
|
||||
.mirror .wikilink {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding-top: var(--s2);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 var(--s1);
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.share {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.notes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
aside {
|
||||
border-right: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,6 +43,35 @@
|
||||
load(id);
|
||||
});
|
||||
|
||||
// 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 (!id || !name) {
|
||||
notes = [];
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!projectId) return;
|
||||
status = 'loading';
|
||||
@@ -793,6 +822,24 @@
|
||||
{#if project.role === 'viewer'}
|
||||
<p class="readonly">{t('project.readOnly')}</p>
|
||||
{/if}
|
||||
<!-- What has been written about this project, which is the payoff for a #tag
|
||||
in a note being the same tag a task carries: nobody files anything here,
|
||||
it turns up because somebody mentioned the project while writing. -->
|
||||
{#if notes.length}
|
||||
<section class="notes">
|
||||
<h2>{t('notes.title')}</h2>
|
||||
<ul>
|
||||
{#each notes as note (note.id)}
|
||||
<li>
|
||||
<a href="/noter?note={note.id}">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<span>{note.body.slice(0, 90)}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{:else if status === 'loading'}
|
||||
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
|
||||
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
|
||||
@@ -809,6 +856,53 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notes {
|
||||
margin-top: var(--s4);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.notes h2 {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.notes ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.notes a {
|
||||
display: block;
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notes a:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.notes strong {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 560;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.notes span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
||||
completed styling; this is about the section around them. */
|
||||
.done {
|
||||
|
||||
Reference in New Issue
Block a user