Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
414a35eaba | ||
|
|
11fdbefe1b | ||
|
|
6e83291444 | ||
|
|
f6c60bdaf9 | ||
|
|
5e670119db |
+49
-3
@@ -2507,6 +2507,22 @@ paths:
|
|||||||
created: { type: integer }
|
created: { type: integer }
|
||||||
"502": { $ref: "#/components/responses/Upstream" }
|
"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:
|
/api/v1/notes:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -2550,6 +2566,37 @@ paths:
|
|||||||
schema: { $ref: "#/components/schemas/Note" }
|
schema: { $ref: "#/components/schemas/Note" }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"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}:
|
/api/v1/notes/{noteID}:
|
||||||
parameters:
|
parameters:
|
||||||
- name: noteID
|
- name: noteID
|
||||||
@@ -3308,7 +3355,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
project_id: { type: string }
|
project_id: { type: string }
|
||||||
title: { type: string, description: "Taken from the first line when not given." }
|
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
|
||||||
body: { type: string, description: Markdown }
|
body: { type: string, description: Markdown }
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
@@ -3326,8 +3373,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
project_id: { type: string, description: "Empty files it under no project." }
|
project_id: { type: string, description: "Empty files it under no project." }
|
||||||
title: { type: string }
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||||
body: { type: string }
|
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
|
|
||||||
Mailbox:
|
Mailbox:
|
||||||
|
|||||||
@@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit
|
|||||||
return true, nil
|
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)
|
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -530,8 +530,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
"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.",
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
|
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||||
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
|
|
||||||
"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."),
|
"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."),
|
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||||
}),
|
}),
|
||||||
@@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"removed.",
|
"removed.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"note_id": mcp.Str("Which note."),
|
"note_id": mcp.Str("Which note."),
|
||||||
"body": mcp.Str("The whole new text, as Markdown."),
|
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||||
"title": mcp.Str("A new title."),
|
|
||||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||||
}),
|
}),
|
||||||
}, s.mcpUpdateNote)
|
}, s.mcpUpdateNote)
|
||||||
@@ -604,7 +602,6 @@ func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) b
|
|||||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
var params struct {
|
var params struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Title string `json:"title"`
|
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
|
|
||||||
n := &store.Note{
|
n := &store.Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: params.Title,
|
|
||||||
Body: params.Body,
|
Body: params.Body,
|
||||||
Pinned: params.Pinned,
|
Pinned: params.Pinned,
|
||||||
}
|
}
|
||||||
@@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
var params struct {
|
var params struct {
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(args, ¶ms)
|
_ = json.Unmarshal(args, ¶ms)
|
||||||
@@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.Body != nil {
|
if params.Body != nil {
|
||||||
n.Body = *params.Body
|
n.Body = *params.Body
|
||||||
}
|
}
|
||||||
if params.Title != nil {
|
|
||||||
n.Title = *params.Title
|
|
||||||
}
|
|
||||||
if params.Pinned != nil {
|
if params.Pinned != nil {
|
||||||
n.Pinned = *params.Pinned
|
n.Pinned = *params.Pinned
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
@@ -97,9 +103,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, n)
|
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 {
|
type noteBody struct {
|
||||||
ProjectID *string `json:"project_id"`
|
ProjectID *string `json:"project_id"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -119,9 +126,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -164,9 +168,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -213,3 +214,246 @@ func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,3 +119,248 @@ func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
|||||||
t.Errorf("%d notes still point at Firma", got)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Route("/notes", func(r chi.Router) {
|
r.Route("/notes", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListNotes)
|
r.Get("/", s.handleListNotes)
|
||||||
r.Post("/", s.handleCreateNote)
|
r.Post("/", s.handleCreateNote)
|
||||||
|
r.Post("/import", s.handleImportNotes)
|
||||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||||
r.Get("/{noteID}", s.handleGetNote)
|
r.Get("/{noteID}", s.handleGetNote)
|
||||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||||
@@ -321,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/account", s.handleExportAccount)
|
||||||
|
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Attachment struct {
|
|||||||
// GroupID hangs the file on a project group rather than on any one task in
|
// 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.
|
// it: the contract that governs all of "Arbejde", not a step in it.
|
||||||
GroupID string
|
GroupID string
|
||||||
|
NoteID string
|
||||||
Filename string
|
Filename string
|
||||||
MimeType string
|
MimeType string
|
||||||
Size int64
|
Size int64
|
||||||
@@ -157,17 +158,18 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
|||||||
a.CreatedAt = time.Now().UTC()
|
a.CreatedAt = time.Now().UTC()
|
||||||
|
|
||||||
_, err := db.ExecContext(ctx,
|
_, 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)
|
size, path, uploaded_by, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
|
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())
|
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -176,11 +178,24 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
|
|||||||
return scanAttachments(rows)
|
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
|
// ListGroupAttachments is the documents that belong to a whole group rather than
|
||||||
// to anything inside it.
|
// to anything inside it.
|
||||||
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
|
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -195,7 +210,7 @@ func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (ma
|
|||||||
args[i] = id
|
args[i] = id
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx,
|
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))+`)
|
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||||
ORDER BY created_at`, args...)
|
ORDER BY created_at`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,15 +233,16 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
|||||||
out := []Attachment{}
|
out := []Attachment{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Attachment
|
var a Attachment
|
||||||
var taskID, commentID, groupID sql.NullString
|
var taskID, commentID, groupID, noteID sql.NullString
|
||||||
var created int64
|
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 {
|
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.TaskID = taskID.String
|
a.TaskID = taskID.String
|
||||||
a.CommentID = commentID.String
|
a.CommentID = commentID.String
|
||||||
a.GroupID = groupID.String
|
a.GroupID = groupID.String
|
||||||
|
a.NoteID = noteID.String
|
||||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
out = append(out, a)
|
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) {
|
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM attachments WHERE id = ?`, attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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;
|
||||||
+17
-6
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
}
|
}
|
||||||
n.UpdatedAt = now
|
n.UpdatedAt = now
|
||||||
|
|
||||||
// A note with no title takes its first line, the way Apple Notes does. A list
|
// The title is always the first line, the way Apple Notes does it. Not "when it
|
||||||
// of untitled notes is a list nobody can read.
|
// is empty": derived once and then left alone, it goes stale the moment somebody
|
||||||
if strings.TrimSpace(n.Title) == "" {
|
// rewrites the opening of a note, and the list ends up calling a note by a name
|
||||||
n.Title = firstLine(n.Body)
|
// 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 {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
|
|||||||
add("task", strings.ToLower(m[1]))
|
add("task", strings.ToLower(m[1]))
|
||||||
}
|
}
|
||||||
for _, m := range projectRef.FindAllStringSubmatch(body, -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) {
|
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
||||||
add("note", strings.TrimSpace(m[1]))
|
add("note", strings.TrimSpace(m[1]))
|
||||||
@@ -209,6 +217,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
// The reason note_links exists. Without it this would mean reading every note's
|
// 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.
|
// prose, and a panel that slow is one nobody leaves open.
|
||||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||||
|
if kind == "project" {
|
||||||
|
targetID = strings.ToLower(targetID)
|
||||||
|
}
|
||||||
return db.notesWhere(ctx, `
|
return db.notesWhere(ctx, `
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
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 {
|
if len(projects) != 2 {
|
||||||
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
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)
|
t.Errorf("got %v", projects)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
|||||||
db, userID := sealedStore(t)
|
db, userID := sealedStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The title is the first line now, so that is where it goes.
|
||||||
if err := db.SaveNote(ctx, &Note{
|
if err := db.SaveNote(ctx, &Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: "Grønt regnskab",
|
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
||||||
Body: "Målt på årsbasis for Århus.",
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
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 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"}}},
|
{"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",
|
{"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},
|
{"a heading is not a tag", "# Overskrift", nil},
|
||||||
} {
|
} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Apple Noter → en mappe med Markdown-filer.
|
||||||
|
#
|
||||||
|
# Apple har ingen eksport, der er værd at bruge: "Eksportér som PDF" er et billede
|
||||||
|
# af en note, ikke noten. Det her læser Notes.app gennem AppleScript, som er den
|
||||||
|
# eneste understøttede vej ind, og skriver én .md-fil pr. note.
|
||||||
|
#
|
||||||
|
# Det kører på din egen maskine og sender ingenting nogen steder. Resultatet er en
|
||||||
|
# mappe, du kan kigge igennem, før du beslutter dig — og et zip af den mappe er
|
||||||
|
# præcis det, Verdande importerer.
|
||||||
|
#
|
||||||
|
# ./apple-notes-til-markdown.sh ~/Desktop/noter
|
||||||
|
# cd ~/Desktop && zip -r noter.zip noter
|
||||||
|
# → Indstillinger → Data → Importér noter
|
||||||
|
#
|
||||||
|
# Første gang spørger macOS, om Terminal må styre Noter. Det skal den.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ud="${1:-$HOME/Desktop/apple-noter}"
|
||||||
|
mkdir -p "$ud"
|
||||||
|
|
||||||
|
echo "Læser Noter. Første gang kan det tage et minut, før macOS svarer."
|
||||||
|
|
||||||
|
# Én note ad gangen frem for at bygge én kæmpe streng: AppleScript bliver meget
|
||||||
|
# langsomt på lange strenge, og på et bibliotek af nogen størrelse ser det ud som
|
||||||
|
# om den er gået i stå.
|
||||||
|
antal=$(osascript -e 'tell application "Notes" to return count of notes')
|
||||||
|
echo "Fandt $antal noter."
|
||||||
|
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$antal" ]; do
|
||||||
|
titel=$(osascript -e "tell application \"Notes\" to return name of note $i" 2>/dev/null || echo "")
|
||||||
|
krop=$(osascript -e "tell application \"Notes\" to return body of note $i" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "$titel" ]; then
|
||||||
|
titel="uden titel $i"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tegn, der ikke kan være i et filnavn på de tre systemer, folk bruger.
|
||||||
|
fil=$(printf '%s' "$titel" | tr '/\\:*?"<>|' '-')
|
||||||
|
sti="$ud/$fil.md"
|
||||||
|
n=2
|
||||||
|
while [ -e "$sti" ]; do
|
||||||
|
sti="$ud/$fil ($n).md"
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Noter er HTML indeni. textutil følger med macOS og kan lave det om til ren
|
||||||
|
# tekst; overskrifter og fed går tabt, men ordene og linjerne overlever, og det
|
||||||
|
# er dem, man kommer efter. Titlen sættes som første linje, fordi det er sådan
|
||||||
|
# Verdande navngiver en note.
|
||||||
|
{
|
||||||
|
printf '# %s\n\n' "$titel"
|
||||||
|
printf '%s' "$krop" | textutil -stdin -stdout -format html -convert txt 2>/dev/null || true
|
||||||
|
} > "$sti"
|
||||||
|
|
||||||
|
# Billeder og bilag.
|
||||||
|
#
|
||||||
|
# En note fra Apple Noter er ofte fuld af fotografier, scanninger og indsatte
|
||||||
|
# skærmbilleder, og en note, der kommer over som ord alene, er ikke den note —
|
||||||
|
# den er et referat af den. AppleScript kan gemme hver vedhæftning som en fil;
|
||||||
|
# de lægges i en undermappe, og der skrives en henvisning til hver i bunden af
|
||||||
|
# noten, så Verdande kan hægte dem på ved importen.
|
||||||
|
bilag=$(osascript -e "tell application \"Notes\" to return count of attachments of note $i" 2>/dev/null || echo 0)
|
||||||
|
if [ "${bilag:-0}" -gt 0 ]; then
|
||||||
|
mkdir -p "$ud/vedhaeftninger"
|
||||||
|
j=1
|
||||||
|
while [ "$j" -le "$bilag" ]; do
|
||||||
|
navn=$(osascript -e "tell application \"Notes\" to return name of attachment $j of note $i" 2>/dev/null || echo "")
|
||||||
|
[ -z "$navn" ] && navn="bilag-$i-$j"
|
||||||
|
# Samme rensning som titlen: et skråstreg i et filnavn laver en mappe.
|
||||||
|
navn=$(printf '%s' "$navn" | tr '/\\:*?"<>|' '-')
|
||||||
|
mål="$ud/vedhaeftninger/$i-$j-$navn"
|
||||||
|
|
||||||
|
# `save` skriver filen. Nogle vedhæftninger — links til websider, kort —
|
||||||
|
# har ingen fil bag sig og fejler her; de springes over frem for at
|
||||||
|
# stoppe hele kørslen.
|
||||||
|
if osascript -e "tell application \"Notes\" to save attachment $j of note $i in POSIX file \"$mål\"" >/dev/null 2>&1; then
|
||||||
|
printf '\n\n' "$(basename "$mål")" >> "$sti"
|
||||||
|
fi
|
||||||
|
j=$((j + 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $((i % 25)) -eq 0 ]; then
|
||||||
|
echo " $i / $antal"
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Færdig. $antal noter ligger i:"
|
||||||
|
echo " $ud"
|
||||||
|
echo
|
||||||
|
echo "Kig dem igennem, og pak dem så:"
|
||||||
|
echo " cd \"$(dirname "$ud")\" && zip -r \"$(basename "$ud").zip\" \"$(basename "$ud")\""
|
||||||
@@ -2129,3 +2129,132 @@ test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ pa
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -391,6 +391,10 @@ export const api = {
|
|||||||
importTodoist: (file) => upload('/import/todoist', file),
|
importTodoist: (file) => upload('/import/todoist', file),
|
||||||
importCSV: (data) => post('/import/csv', data),
|
importCSV: (data) => post('/import/csv', data),
|
||||||
exportAccountURL: () => '/api/v1/export/account',
|
exportAccountURL: () => '/api/v1/export/account',
|
||||||
|
exportNotesURL: () => '/api/v1/export/notes.zip',
|
||||||
|
// 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`,
|
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
|
||||||
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
|
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||||
import { t } from '$lib/i18n.svelte.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 { note, onchange, onsave } = $props();
|
||||||
|
|
||||||
@@ -40,6 +42,13 @@
|
|||||||
active = {};
|
active = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SYNTAX = [
|
||||||
|
{ mark: '#', what: 'notes.syntaxProject' },
|
||||||
|
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||||
|
{ mark: '⌘B', what: 'notes.bold' },
|
||||||
|
{ mark: '⌘U', what: 'notes.underline' }
|
||||||
|
];
|
||||||
|
|
||||||
const STYLES = [
|
const STYLES = [
|
||||||
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
||||||
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
||||||
@@ -96,6 +105,62 @@
|
|||||||
function changed() {
|
function changed() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
onchange?.(htmlToMarkdown(editor));
|
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. */
|
/** The block the caret is standing in. */
|
||||||
@@ -135,6 +200,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event) {
|
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) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
// After the split, not before it.
|
// After the split, not before it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -231,11 +321,48 @@
|
|||||||
spellcheck="true"
|
spellcheck="true"
|
||||||
oninput={changed}
|
oninput={changed}
|
||||||
onblur={onsave}
|
onblur={onsave}
|
||||||
onkeyup={readState}
|
onkeyup={() => {
|
||||||
|
readState();
|
||||||
|
readSuggestions();
|
||||||
|
}}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
></div>
|
></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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -345,6 +472,54 @@
|
|||||||
color: var(--ink-muted);
|
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 {
|
.page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
let subtasks = $state([]);
|
let subtasks = $state([]);
|
||||||
let comments = $state([]);
|
let comments = $state([]);
|
||||||
let attachments = $state([]);
|
let attachments = $state([]);
|
||||||
|
let notes = $state([]);
|
||||||
let reminders = $state([]);
|
let reminders = $state([]);
|
||||||
|
|
||||||
let newSubtask = $state('');
|
let newSubtask = $state('');
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
subtasks = [];
|
subtasks = [];
|
||||||
comments = [];
|
comments = [];
|
||||||
attachments = [];
|
attachments = [];
|
||||||
|
notes = [];
|
||||||
reminders = [];
|
reminders = [];
|
||||||
|
|
||||||
const id = task.id;
|
const id = task.id;
|
||||||
@@ -96,6 +98,11 @@
|
|||||||
api.listReminders(id)
|
api.listReminders(id)
|
||||||
.then((r) => id === seededId && (reminders = r.reminders))
|
.then((r) => id === seededId && (reminders = r.reminders))
|
||||||
.catch(() => {});
|
.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>
|
</form>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h3>{t('detail.comments')}</h3>
|
<h3>{t('detail.comments')}</h3>
|
||||||
|
|
||||||
@@ -582,6 +605,24 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.scrim {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const da = {
|
|||||||
'notes.styleNumbered': 'Nummereret liste',
|
'notes.styleNumbered': 'Nummereret liste',
|
||||||
'notes.favourite': 'Gør til favorit',
|
'notes.favourite': 'Gør til favorit',
|
||||||
'notes.unfavourite': 'Fjern som favorit',
|
'notes.unfavourite': 'Fjern som favorit',
|
||||||
|
'notes.syntaxProject': 'projekt',
|
||||||
|
'notes.syntaxNote': 'en anden note',
|
||||||
'notes.title': 'Noter',
|
'notes.title': 'Noter',
|
||||||
'notes.new': 'Ny note',
|
'notes.new': 'Ny note',
|
||||||
'notes.search': 'Søg i noter',
|
'notes.search': 'Søg i noter',
|
||||||
@@ -363,6 +365,10 @@ export const da = {
|
|||||||
'data.swept': 'ryddet væk',
|
'data.swept': 'ryddet væk',
|
||||||
'data.empty': 'tom',
|
'data.empty': 'tom',
|
||||||
'data.export': 'Eksport',
|
'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.exportAccount': 'Hele kontoen som JSON',
|
||||||
'data.exportOne': 'Ét projekt',
|
'data.exportOne': 'Ét projekt',
|
||||||
'data.pickProject': 'Vælg et projekt',
|
'data.pickProject': 'Vælg et projekt',
|
||||||
@@ -467,6 +473,7 @@ export const da = {
|
|||||||
'auth.forgot': 'Glemt adgangskode?',
|
'auth.forgot': 'Glemt adgangskode?',
|
||||||
|
|
||||||
// --- a task, opened ---------------------------------------------------------------
|
// --- a task, opened ---------------------------------------------------------------
|
||||||
|
'detail.notFound': 'Opgaven findes ikke, eller den er ikke din.',
|
||||||
'detail.task': 'Opgave',
|
'detail.task': 'Opgave',
|
||||||
'detail.close': 'Luk',
|
'detail.close': 'Luk',
|
||||||
'detail.text': 'Opgavens tekst',
|
'detail.text': 'Opgavens tekst',
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const en = {
|
|||||||
'notes.styleNumbered': 'Numbered list',
|
'notes.styleNumbered': 'Numbered list',
|
||||||
'notes.favourite': 'Make a favourite',
|
'notes.favourite': 'Make a favourite',
|
||||||
'notes.unfavourite': 'Remove from favourites',
|
'notes.unfavourite': 'Remove from favourites',
|
||||||
|
'notes.syntaxProject': 'project',
|
||||||
|
'notes.syntaxNote': 'another note',
|
||||||
'notes.title': 'Notes',
|
'notes.title': 'Notes',
|
||||||
'notes.new': 'New note',
|
'notes.new': 'New note',
|
||||||
'notes.search': 'Search notes',
|
'notes.search': 'Search notes',
|
||||||
@@ -358,6 +360,10 @@ export const en = {
|
|||||||
'data.swept': 'swept away',
|
'data.swept': 'swept away',
|
||||||
'data.empty': 'empty',
|
'data.empty': 'empty',
|
||||||
'data.export': 'Export',
|
'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.exportAccount': 'The whole account as JSON',
|
||||||
'data.exportOne': 'One project',
|
'data.exportOne': 'One project',
|
||||||
'data.pickProject': 'Pick a project',
|
'data.pickProject': 'Pick a project',
|
||||||
@@ -463,6 +469,7 @@ export const en = {
|
|||||||
'auth.forgot': 'Forgotten your password?',
|
'auth.forgot': 'Forgotten your password?',
|
||||||
|
|
||||||
// --- a task, opened ---------------------------------------------------------------
|
// --- a task, opened ---------------------------------------------------------------
|
||||||
|
'detail.notFound': 'That task does not exist, or it is not yours.',
|
||||||
'detail.task': 'Task',
|
'detail.task': 'Task',
|
||||||
'detail.close': 'Close',
|
'detail.close': 'Close',
|
||||||
'detail.text': 'The task’s text',
|
'detail.text': 'The task’s text',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
|
||||||
/** Import, export and project templates. */
|
/** Import, export and project templates. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
@@ -57,6 +58,34 @@
|
|||||||
// --- Todoist import ------------------------------------------------------------
|
// --- Todoist import ------------------------------------------------------------
|
||||||
|
|
||||||
let importing = $state(false);
|
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);
|
let result = $state(null);
|
||||||
|
|
||||||
async function importTodoist(event) {
|
async function importTodoist(event) {
|
||||||
@@ -341,6 +370,22 @@
|
|||||||
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
||||||
do with a Content-Disposition, and the session cookie rides along. -->
|
do with a Content-Disposition, and the session cookie rides along. -->
|
||||||
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
||||||
|
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
|
||||||
|
</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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
|
||||||
@@ -31,6 +32,16 @@
|
|||||||
load(query);
|
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) {
|
async function load(q) {
|
||||||
try {
|
try {
|
||||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
||||||
@@ -160,6 +171,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
// What the note points at, for the panel under the editor.
|
||||||
let links = $derived(selected?.links ?? []);
|
let links = $derived(selected?.links ?? []);
|
||||||
|
|
||||||
@@ -229,7 +256,7 @@
|
|||||||
{#if links.length}
|
{#if links.length}
|
||||||
<span class="links">
|
<span class="links">
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
<span class="link">{label(link)}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user