Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
035d079a8e | ||
|
|
aadfb197b6 | ||
|
|
8aad7c13b0 | ||
|
|
3a02bc8304 |
@@ -1396,6 +1396,57 @@ paths:
|
|||||||
description: The environment variables that are not set.
|
description: The environment variables that are not set.
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/v1/notes/bulk:
|
||||||
|
post:
|
||||||
|
tags: [notes]
|
||||||
|
summary: Archive, unarchive or delete a set of notes in one call.
|
||||||
|
description: |
|
||||||
|
One call rather than one per note, because the thing being asked is one
|
||||||
|
thing: "put these away". Fifty separate requests would be fifty round
|
||||||
|
trips, fifty chances for half of them to fail, and a list that redraws
|
||||||
|
fifty times — and after an import of twelve hundred notes, selecting fifty
|
||||||
|
is the ordinary case rather than the extreme one.
|
||||||
|
|
||||||
|
Each note is still checked on its own. A set is a convenience for the
|
||||||
|
caller and never a way to touch something they could not have touched one
|
||||||
|
at a time; anything they may not write to is counted in `skipped` rather
|
||||||
|
than failing the whole call, so one note somebody else just deleted does
|
||||||
|
not stop the other forty-nine.
|
||||||
|
|
||||||
|
Archiving is not deleting. The trash says "this was a mistake, and in
|
||||||
|
thirty days it is gone"; the archive says "this is finished, and I want to
|
||||||
|
keep it".
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: At most 500.
|
||||||
|
archived:
|
||||||
|
type: boolean
|
||||||
|
description: Put away, or bring back out.
|
||||||
|
delete:
|
||||||
|
type: boolean
|
||||||
|
description: Into the trash instead. Wins over `archived`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: How many were changed, and how many were passed over.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
done: { type: integer }
|
||||||
|
skipped: { type: integer }
|
||||||
|
"422":
|
||||||
|
description: No ids, too many, or neither archived nor delete was asked for.
|
||||||
|
|
||||||
/api/v1/notes/{noteID}/attachments:
|
/api/v1/notes/{noteID}/attachments:
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "search notes", err)
|
s.internal(w, r, "search notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "notes in project", err)
|
s.internal(w, r, "notes in project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive is the same list asked the other way round, so it is the same
|
||||||
|
// endpoint with a flag rather than a second one that would have to be kept in
|
||||||
|
// step with this.
|
||||||
|
if r.URL.Query().Get("archived") != "" {
|
||||||
|
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list archived notes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefly replaces each note's body with the first of it.
|
||||||
|
//
|
||||||
|
// The list shows a title and about ninety characters. It was being sent whole
|
||||||
|
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||||
|
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||||
|
// parsed and held in memory on every visit.
|
||||||
|
//
|
||||||
|
// Body is emptied rather than shortened, and that is the important half. A field
|
||||||
|
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||||
|
// it, the debounce would save it, and the note would be cut down to its own
|
||||||
|
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||||
|
// check, and it does — see the notes page, which fetches the whole note when it
|
||||||
|
// opens one.
|
||||||
|
//
|
||||||
|
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||||
|
// it shows anything, and a heading marker and a bold word are characters that
|
||||||
|
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||||
|
// is meant to fill.
|
||||||
|
const previewChars = 400
|
||||||
|
|
||||||
|
func briefly(notes []store.Note) []store.Note {
|
||||||
|
out := make([]store.Note, 0, len(notes))
|
||||||
|
for _, n := range notes {
|
||||||
|
body := []rune(n.Body)
|
||||||
|
if len(body) > previewChars {
|
||||||
|
body = body[:previewChars]
|
||||||
|
}
|
||||||
|
n.Preview = string(body)
|
||||||
|
n.Body = ""
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// readable drops what this person may not see.
|
// readable drops what this person may not see.
|
||||||
@@ -687,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- putting notes away, one or many ---------------------------------------------
|
||||||
|
|
||||||
|
type noteBulkRequest struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Archived *bool `json:"archived"`
|
||||||
|
Delete bool `json:"delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
||||||
|
// and few enough that the loop below cannot become somebody's afternoon.
|
||||||
|
const maxNoteBulk = 500
|
||||||
|
|
||||||
|
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
||||||
|
//
|
||||||
|
// One call rather than one per note, because the thing being asked is one thing:
|
||||||
|
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
||||||
|
// chances for half of them to fail, and a list that redraws fifty times — and
|
||||||
|
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
||||||
|
// not the extreme one.
|
||||||
|
//
|
||||||
|
// Each note is still checked on its own. A set is a convenience for the caller
|
||||||
|
// and must never become a way to touch something they could not have touched one
|
||||||
|
// at a time.
|
||||||
|
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req noteBulkRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) > maxNoteBulk {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done, skipped := 0, 0
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
n, err := s.db.Note(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "get note", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not an error, and not a 404 for the whole call: a set can contain a note
|
||||||
|
// somebody else deleted a second ago, and the other forty-nine should still
|
||||||
|
// be put away. Counted so the answer is honest about it.
|
||||||
|
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case req.Delete:
|
||||||
|
err = s.db.DeleteNote(r.Context(), id)
|
||||||
|
case req.Archived != nil:
|
||||||
|
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
||||||
|
default:
|
||||||
|
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "note bulk", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
||||||
|
}
|
||||||
|
|||||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
|||||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||||
|
// fetched by id, which is what the interface does when it opens one.
|
||||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||||
notes := listed["notes"].([]any)
|
notes := listed["notes"].([]any)
|
||||||
if len(notes) != 1 {
|
if len(notes) != 1 {
|
||||||
t.Fatalf("found %d notes", len(notes))
|
t.Fatalf("found %d notes", len(notes))
|
||||||
}
|
}
|
||||||
body := notes[0].(map[string]any)["body"].(string)
|
noteID := notes[0].(map[string]any)["id"].(string)
|
||||||
|
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||||
|
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||||
|
}
|
||||||
|
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||||
|
body := whole["body"].(string)
|
||||||
|
|
||||||
// The link points at the file where it now lives, not at a path inside an
|
// The link points at the file where it now lives, not at a path inside an
|
||||||
// archive nobody kept.
|
// archive nobody kept.
|
||||||
|
|||||||
@@ -333,6 +333,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// files in and nothing else could, which made the most ordinary act
|
// files in and nothing else could, which made the most ordinary act
|
||||||
// in a notes app — paste a screenshot — do nothing at all.
|
// in a notes app — paste a screenshot — do nothing at all.
|
||||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||||
|
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||||
|
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||||
|
r.Post("/bulk", s.handleNoteBulk)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/mailboxes", func(r chi.Router) {
|
r.Route("/mailboxes", func(r chi.Router) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- A note can be put away without being thrown away.
|
||||||
|
--
|
||||||
|
-- The trash already exists and is the wrong tool. It says "this was a mistake,
|
||||||
|
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
|
||||||
|
-- an archive of twelve hundred imported notes is exactly the case where most of
|
||||||
|
-- them are worth keeping and almost none are worth looking at. Without somewhere
|
||||||
|
-- to put them, the list is the whole archive forever, and finding this week's note
|
||||||
|
-- means reading past a decade.
|
||||||
|
--
|
||||||
|
-- A column rather than a folder. Verdande already refused a second hierarchy for
|
||||||
|
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
|
||||||
|
-- note is current, and it has to be answerable next to `deleted_at`, which is the
|
||||||
|
-- other bit of the same kind.
|
||||||
|
--
|
||||||
|
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
|
||||||
|
-- one: "when was this put away" answers questions a flag cannot, and it costs the
|
||||||
|
-- same byte.
|
||||||
|
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
|
||||||
|
|
||||||
|
-- The list asks "not deleted, not archived, newest first" on every load, and with
|
||||||
|
-- twelve hundred notes that is the query that has to be quick.
|
||||||
|
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
+102
-21
@@ -10,15 +10,26 @@ import (
|
|||||||
|
|
||||||
// Note is a page of Markdown belonging to a project, or to nobody.
|
// Note is a page of Markdown belonging to a project, or to nobody.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedBy string `json:"created_by,omitempty"`
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
|
||||||
|
// Preview is the first of the body, for a list that shows one line of it.
|
||||||
|
//
|
||||||
|
// It exists so a list of twelve hundred notes does not have to carry twelve
|
||||||
|
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
|
||||||
|
// characters of each. When it is set, Body is deliberately empty — see
|
||||||
|
// handleListNotes. That is not tidiness but a guard: an editor handed a
|
||||||
|
// truncated body would save the truncation, and the note would be cut down to
|
||||||
|
// its own preview.
|
||||||
|
Preview string `json:"preview,omitempty"`
|
||||||
|
|
||||||
// Links is what the body points at, read out of the text on the way in. Never
|
// Links is what the body points at, read out of the text on the way in. Never
|
||||||
// set by a caller: it is derived, and a caller that set it would be describing
|
// set by a caller: it is derived, and a caller that set it would be describing
|
||||||
@@ -33,7 +44,16 @@ type NoteLink struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||||
created_at, updated_at, deleted_at`
|
created_at, updated_at, deleted_at, archived_at`
|
||||||
|
|
||||||
|
// The same columns, said with the table in front of them.
|
||||||
|
//
|
||||||
|
// Needed only by the search, which joins `notes_fts` — and that table carries its
|
||||||
|
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||||
|
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||||
|
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||||
|
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
|
||||||
|
notes.archived_at`
|
||||||
|
|
||||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||||
@@ -199,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
// shared, and a list that dropped it at that moment would make sharing look like
|
// 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.
|
// deleting — which is exactly how it read before this existed.
|
||||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
return db.notesWhere(ctx, `
|
return db.allNotes(ctx, userID, false)
|
||||||
WHERE deleted_at IS NULL
|
}
|
||||||
|
|
||||||
|
// ArchivedNotes is the same list, the other way round: what has been put away.
|
||||||
|
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
|
return db.allNotes(ctx, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
|
||||||
|
// Archived notes are left out of the ordinary list rather than mixed in and
|
||||||
|
// greyed: the point of putting something away is not seeing it.
|
||||||
|
state := "notes.archived_at IS NULL"
|
||||||
|
if archived {
|
||||||
|
state = "notes.archived_at IS NOT NULL"
|
||||||
|
}
|
||||||
|
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
|
WHERE `+state+` AND deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
(project_id IS NULL AND created_by = ?)
|
(project_id IS NULL AND created_by = ?)
|
||||||
OR project_id IN (
|
OR project_id IN (
|
||||||
@@ -238,12 +273,27 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
|||||||
if expr == "" {
|
if expr == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
|
// Ordered by how well it matched, not by when it was last touched.
|
||||||
// on it would work and read the whole index to do it.
|
//
|
||||||
return db.notesWhere(ctx, `
|
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||||
WHERE deleted_at IS NULL
|
// useless at twelve hundred: every one of them was imported the same evening,
|
||||||
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
|
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
||||||
ORDER BY updated_at DESC
|
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
||||||
|
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||||
|
// that does not work, because from where the person is sitting it is one.
|
||||||
|
//
|
||||||
|
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||||
|
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||||
|
// carries the same text transliterated, so it must weigh the same as the body
|
||||||
|
// or a Danish match would outrank an exact one.
|
||||||
|
//
|
||||||
|
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||||
|
// subquery throws it away.
|
||||||
|
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
|
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||||
|
WHERE notes.deleted_at IS NULL
|
||||||
|
AND notes_fts MATCH ?
|
||||||
|
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||||
LIMIT ?`, expr, limit)
|
LIMIT ?`, expr, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +328,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNoteArchived puts a note away, or takes it back out.
|
||||||
|
//
|
||||||
|
// Separate from DeleteNote because they mean different things and the difference
|
||||||
|
// is the whole feature: the trash says "this was a mistake, and in thirty days it
|
||||||
|
// is gone", and the archive says "this is finished, and I want to keep it". A
|
||||||
|
// twelve-hundred-note import is mostly the second, and without somewhere to put
|
||||||
|
// them the list is the whole archive forever.
|
||||||
|
//
|
||||||
|
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
|
||||||
|
// sorted by when things were last written should not be reordered by somebody
|
||||||
|
// tidying up.
|
||||||
|
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
|
||||||
|
var at any
|
||||||
|
if archived {
|
||||||
|
at = time.Now().Unix()
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
@@ -295,7 +366,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
|
return db.notesJoin(ctx, noteColumns, where, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notesJoin is notesWhere with the column list spelled out, for the one caller
|
||||||
|
// that joins another table and must therefore say which `id` it means.
|
||||||
|
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -335,10 +412,10 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var deleted sql.NullInt64
|
var deleted, archived sql.NullInt64
|
||||||
|
|
||||||
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
||||||
&n.Pinned, &created, &updated, &deleted); err != nil {
|
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
n.ProjectID = projectID.String
|
n.ProjectID = projectID.String
|
||||||
@@ -349,5 +426,9 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(deleted.Int64, 0)
|
t := time.Unix(deleted.Int64, 0)
|
||||||
n.DeletedAt = &t
|
n.DeletedAt = &t
|
||||||
}
|
}
|
||||||
|
if archived.Valid {
|
||||||
|
t := time.Unix(archived.Int64, 0)
|
||||||
|
n.ArchivedAt = &t
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
|||||||
t.Error("a note title did not match itself")
|
t.Error("a note title did not match itself")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En søgning skal svare med det, der passer bedst, øverst.
|
||||||
|
//
|
||||||
|
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
||||||
|
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
||||||
|
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
||||||
|
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
||||||
|
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
||||||
|
// fra brugerens plads er præcis dét.
|
||||||
|
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
||||||
|
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
||||||
|
for _, body := range []string{
|
||||||
|
"# Tesla Model Y\n\nBilen selv.\n",
|
||||||
|
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
||||||
|
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
||||||
|
} {
|
||||||
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 3 {
|
||||||
|
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
||||||
|
}
|
||||||
|
if found[0].Title != "Tesla Model Y" {
|
||||||
|
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2740,3 +2740,157 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await page.goto('/');
|
||||||
|
await sidebar.getByLabel('Nyt projekt').click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
|
||||||
|
|
||||||
|
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: '# Løs note\n\nSkal flyttes ved at trække.\n' })
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/noter?note=' + id);
|
||||||
|
await page.evaluate((noteId) => (window.__noteId = noteId), id);
|
||||||
|
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
|
||||||
|
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
|
||||||
|
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
|
||||||
|
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('application/x-verdande-note', window.__noteId);
|
||||||
|
const target = [...document.querySelectorAll('nav a')].find((a) =>
|
||||||
|
a.textContent.includes('Trækmål')
|
||||||
|
);
|
||||||
|
for (const type of ['dragover', 'drop']) {
|
||||||
|
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Det, der tæller: noten ligger i projektet bagefter.
|
||||||
|
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();
|
||||||
|
}, id);
|
||||||
|
expect(body.project_id).toBeTruthy();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
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: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
});
|
||||||
|
await page.goto('/noter?note=' + id);
|
||||||
|
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
const copy = ed.locator('pre .copy');
|
||||||
|
await expect(copy).toBeVisible();
|
||||||
|
await copy.click();
|
||||||
|
await expect(copy).toHaveText('Kopieret');
|
||||||
|
|
||||||
|
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
|
||||||
|
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(clip).toBe('docker pull verdande');
|
||||||
|
|
||||||
|
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
|
||||||
|
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
|
||||||
|
// fælde, den her prøve findes for.
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.type(' ');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
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('docker pull verdande');
|
||||||
|
expect(body).not.toContain('Kopiér');
|
||||||
|
expect(body).not.toContain('Kopieret');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
|
||||||
|
await page.evaluate(async (t) => {
|
||||||
|
await fetch('/api/v1/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
|
||||||
|
});
|
||||||
|
}, title);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
const rows = page.locator('.notes button.row');
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
|
||||||
|
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
|
||||||
|
// hører med i markeringen — ellers ville det andet klik markere én og glemme
|
||||||
|
// den, man stod på.
|
||||||
|
await rows.filter({ hasText: 'Alfa' }).click();
|
||||||
|
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||||
|
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
|
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||||
|
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Vis arkivet').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
|
||||||
|
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
|
||||||
|
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
|
||||||
|
await rows.filter({ hasText: 'Alfa' }).hover();
|
||||||
|
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByLabel('Vis arkivet').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ export const api = {
|
|||||||
|
|
||||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||||
|
noteBulk: (body) => post('/notes/bulk', body),
|
||||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||||
|
|
||||||
|
|||||||
@@ -121,12 +121,71 @@
|
|||||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||||
* man læser den.
|
* man læser den.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* En kopiér-knap på hver kodeblok.
|
||||||
|
*
|
||||||
|
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
|
||||||
|
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
|
||||||
|
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
|
||||||
|
* den findes for øjet og musen, ikke for dokumentet.
|
||||||
|
*
|
||||||
|
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
|
||||||
|
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
|
||||||
|
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
|
||||||
|
*/
|
||||||
|
function addCopyButtons() {
|
||||||
|
if (!editor) return;
|
||||||
|
for (const pre of editor.querySelectorAll('pre')) {
|
||||||
|
if (pre.querySelector('.copy')) continue;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'copy';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.contentEditable = 'false';
|
||||||
|
btn.textContent = t('notes.copyCode');
|
||||||
|
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const text = codeTextOf(pre);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||||
|
btn.textContent = t('notes.copied');
|
||||||
|
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
|
||||||
|
} catch {
|
||||||
|
app.toast(t('notes.copyFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pre.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||||
|
*
|
||||||
|
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
||||||
|
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
||||||
|
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
||||||
|
*/
|
||||||
|
function codeTextOf(pre) {
|
||||||
|
const from = pre.querySelector('code') ?? pre;
|
||||||
|
return [...from.childNodes]
|
||||||
|
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||||
|
.map((n) => n.textContent ?? '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function colourCode() {
|
function colourCode() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const inside = currentBlock();
|
const inside = currentBlock();
|
||||||
for (const pre of editor.querySelectorAll('pre')) {
|
for (const pre of editor.querySelectorAll('pre')) {
|
||||||
if (pre === inside || pre.contains(inside)) continue;
|
if (pre === inside || pre.contains(inside)) continue;
|
||||||
const code = pre.textContent ?? '';
|
// Blokkens tekst uden det, vi selv har lagt oven på den.
|
||||||
|
//
|
||||||
|
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
|
||||||
|
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||||
|
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||||
|
// spiste den kode, den var sat der for at kopiere.
|
||||||
|
const code = codeTextOf(pre);
|
||||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||||
if (lang) pre.setAttribute('data-lang', lang);
|
if (lang) pre.setAttribute('data-lang', lang);
|
||||||
const painted = highlight(code, lang);
|
const painted = highlight(code, lang);
|
||||||
@@ -135,6 +194,7 @@
|
|||||||
const target = pre.querySelector('code') ?? pre;
|
const target = pre.querySelector('code') ?? pre;
|
||||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||||
}
|
}
|
||||||
|
addCopyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changed() {
|
function changed() {
|
||||||
@@ -814,7 +874,17 @@
|
|||||||
*
|
*
|
||||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||||
* blive i den. */
|
* blive i den. */
|
||||||
max-width: 46rem;
|
/* Bredden vokser med ruden, men ikke i det uendelige.
|
||||||
|
*
|
||||||
|
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
|
||||||
|
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
|
||||||
|
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
|
||||||
|
* bredde, blev klemt sammen uden grund.
|
||||||
|
*
|
||||||
|
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
||||||
|
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
||||||
|
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
||||||
|
max-width: clamp(38rem, 92%, 68rem);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +985,40 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kopiér-knappen, øverst til højre i blokken.
|
||||||
|
*
|
||||||
|
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
|
||||||
|
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
|
||||||
|
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
|
||||||
|
* opmærksomhed fra koden.
|
||||||
|
*
|
||||||
|
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
|
||||||
|
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
|
||||||
|
.page :global(pre) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(pre .copy) {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
color: rgb(255 255 255 / 0.55);
|
||||||
|
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(pre:hover .copy),
|
||||||
|
.page :global(pre .copy:hover),
|
||||||
|
.page :global(pre .copy:focus-visible) {
|
||||||
|
background: rgb(255 255 255 / 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
/* 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
|
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,
|
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||||
|
|||||||
@@ -363,6 +363,15 @@
|
|||||||
overProject = project.id;
|
overProject = project.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Et slip på et projekt, uanset hvad der bliver sluppet.
|
||||||
|
*
|
||||||
|
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
|
||||||
|
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
|
||||||
|
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
|
||||||
|
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
|
||||||
|
* virke, helt indtil man slap.
|
||||||
|
*/
|
||||||
async function onTaskDrop(event, project) {
|
async function onTaskDrop(event, project) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
overProject = null;
|
overProject = null;
|
||||||
@@ -371,9 +380,12 @@
|
|||||||
// The same target takes both. A note dropped on a project is filed there,
|
// 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
|
// which is also how it gets shared — one gesture, one meaning, and the
|
||||||
// sidebar already reads as "put this here".
|
// sidebar already reads as "put this here".
|
||||||
const noteID = dragged(event, NOTE);
|
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
|
||||||
if (noteID) {
|
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
|
||||||
await app.moveNoteToProject(noteID, project.id);
|
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
|
||||||
|
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
|
||||||
|
if (noteIDs.length) {
|
||||||
|
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,14 +581,17 @@
|
|||||||
ondragstart={(e) => sortable && onDragStart(e, project)}
|
ondragstart={(e) => sortable && onDragStart(e, project)}
|
||||||
ondragend={clearDrag}
|
ondragend={clearDrag}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (carries(e, TASK)) onTaskDragOver(e, project);
|
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
|
||||||
else if (sortable) onDragOver(e, project);
|
else if (sortable) onDragOver(e, project);
|
||||||
}}
|
}}
|
||||||
ondragleave={() => {
|
ondragleave={() => {
|
||||||
overId = null;
|
overId = null;
|
||||||
overProject = null;
|
overProject = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
ondrop={(e) =>
|
||||||
|
carries(e, TASK) || carries(e, NOTE)
|
||||||
|
? onTaskDrop(e, project)
|
||||||
|
: sortable && onDrop(e, project)}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
{projectName(project)}
|
{projectName(project)}
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ export const da = {
|
|||||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||||
'notes.newHere': 'Ny note',
|
'notes.newHere': 'Ny note',
|
||||||
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
||||||
|
'notes.clearSearch': 'Ryd søgningen',
|
||||||
|
'notes.found': '{count} noter',
|
||||||
|
'notes.picked': '{count} markeret',
|
||||||
|
'notes.archive': 'Arkivér',
|
||||||
|
'notes.unarchive': 'Tag frem igen',
|
||||||
|
'notes.clearPick': 'Ryd markering',
|
||||||
|
'notes.showArchive': 'Vis arkivet',
|
||||||
|
'notes.deleteMany': 'Slet {count} noter?',
|
||||||
|
'notes.archiveOne': 'Arkivér noten',
|
||||||
|
'notes.emptyArchive': 'Arkivet er tomt.',
|
||||||
|
'notes.copyCode': 'Kopiér',
|
||||||
|
'notes.copied': 'Kopieret',
|
||||||
|
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.showSource': 'Vis som kode',
|
'notes.showSource': 'Vis som kode',
|
||||||
'notes.source': 'Notens kildetekst',
|
'notes.source': 'Notens kildetekst',
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ export const en = {
|
|||||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||||
'notes.newHere': 'New note',
|
'notes.newHere': 'New note',
|
||||||
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
|
'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.',
|
||||||
|
'notes.clearSearch': 'Clear the search',
|
||||||
|
'notes.found': '{count} notes',
|
||||||
|
'notes.picked': '{count} selected',
|
||||||
|
'notes.archive': 'Archive',
|
||||||
|
'notes.unarchive': 'Bring back',
|
||||||
|
'notes.clearPick': 'Clear selection',
|
||||||
|
'notes.showArchive': 'Show the archive',
|
||||||
|
'notes.deleteMany': 'Delete {count} notes?',
|
||||||
|
'notes.archiveOne': 'Archive this note',
|
||||||
|
'notes.emptyArchive': 'The archive is empty.',
|
||||||
|
'notes.copyCode': 'Copy',
|
||||||
|
'notes.copied': 'Copied',
|
||||||
|
'notes.copyFailed': 'Could not copy',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.showSource': 'Show as code',
|
'notes.showSource': 'Show as code',
|
||||||
'notes.source': 'Note source',
|
'notes.source': 'Note source',
|
||||||
|
|||||||
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
|
|||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
emit('```' + lang);
|
emit('```' + lang);
|
||||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
||||||
|
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
||||||
|
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
||||||
|
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
||||||
|
const code = [...child.childNodes]
|
||||||
|
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||||
|
.map((n) => n.textContent ?? '')
|
||||||
|
.join('');
|
||||||
|
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||||
emit(l);
|
emit(l);
|
||||||
}
|
}
|
||||||
emit('```');
|
emit('```');
|
||||||
|
|||||||
@@ -53,7 +53,16 @@
|
|||||||
|
|
||||||
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en
|
||||||
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
// note til favorit, og en sortering, der blandede dem ind igen, ville tage den.
|
||||||
let ordered = $derived(
|
let ordered = $derived.by(() => {
|
||||||
|
// En søgning har allerede en rækkefølge, og den er bedre end nogen af de
|
||||||
|
// tre herunder: serveren har sorteret efter hvor godt hver note matchede.
|
||||||
|
// Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en
|
||||||
|
// søgning i tolv hundrede noter brugbar.
|
||||||
|
if (query.trim()) return notes;
|
||||||
|
return sortedByChoice();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedByChoice = () =>
|
||||||
[...notes].sort((a, b) => {
|
[...notes].sort((a, b) => {
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
if (order === 'title') {
|
if (order === 'title') {
|
||||||
@@ -61,10 +70,106 @@
|
|||||||
}
|
}
|
||||||
const field = order === 'created' ? 'created_at' : 'updated_at';
|
const field = order === 'created' ? 'created_at' : 'updated_at';
|
||||||
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
||||||
})
|
});
|
||||||
);
|
|
||||||
let selected = $state(null);
|
let selected = $state(null);
|
||||||
|
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||||
|
let selectedId = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flere ad gangen.
|
||||||
|
*
|
||||||
|
* Et sæt frem for et flag på noten, fordi markeringen hører til skærmen og ikke
|
||||||
|
* til noten: den forsvinder, når man går et andet sted hen, og den skal ikke
|
||||||
|
* gemmes nogen steder.
|
||||||
|
*
|
||||||
|
* `anchor` er den, et skift-klik måler fra. Uden den ville et interval skulle
|
||||||
|
* gættes ud fra "den valgte", og den flytter sig, hver gang man klikker.
|
||||||
|
*/
|
||||||
|
let picked = $state(new Set());
|
||||||
|
let anchor = $state(null);
|
||||||
|
let showArchive = $state(false);
|
||||||
|
|
||||||
|
function rowClick(event, note) {
|
||||||
|
const many = event.metaKey || event.ctrlKey;
|
||||||
|
const range = event.shiftKey;
|
||||||
|
|
||||||
|
if (!many && !range) {
|
||||||
|
picked = new Set();
|
||||||
|
anchor = note.id;
|
||||||
|
open(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(picked);
|
||||||
|
if (range && anchor) {
|
||||||
|
// Fra ankeret til her, i den rækkefølge listen faktisk står i — ikke i
|
||||||
|
// notens egen, som er en anden, når der er sorteret eller søgt.
|
||||||
|
const ids = ordered.map((n) => n.id);
|
||||||
|
const from = ids.indexOf(anchor);
|
||||||
|
const to = ids.indexOf(note.id);
|
||||||
|
if (from !== -1 && to !== -1) {
|
||||||
|
for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Den, der lige er åbnet, hører med i markeringen: ellers ville et
|
||||||
|
// ⌘-klik nummer to markere to noter og glemme den, man stod på.
|
||||||
|
if (!next.size && selectedId) next.add(selectedId);
|
||||||
|
next.has(note.id) ? next.delete(note.id) : next.add(note.id);
|
||||||
|
anchor = note.id;
|
||||||
|
}
|
||||||
|
picked = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arkivér, tag frem igen, eller slet — for det, der er markeret. */
|
||||||
|
async function bulk(change) {
|
||||||
|
const ids = [...picked];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (change.delete && !confirm(t('notes.deleteMany', { count: ids.length }))) return;
|
||||||
|
try {
|
||||||
|
await api.noteBulk({ ids, ...change });
|
||||||
|
picked = new Set();
|
||||||
|
if (ids.includes(selectedId)) open(null);
|
||||||
|
await load(query);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Én note lagt væk, uden at markere den først. */
|
||||||
|
async function archiveOne(note) {
|
||||||
|
try {
|
||||||
|
await api.noteBulk({ ids: [note.id], archived: !note.archived_at });
|
||||||
|
if (selectedId === note.id) open(null);
|
||||||
|
await load(query);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Det, der står i feltet, og det, der er søgt efter.
|
||||||
|
*
|
||||||
|
* To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert
|
||||||
|
* tastetryk,
|
||||||
|
* så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi
|
||||||
|
* det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger
|
||||||
|
* gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom
|
||||||
|
* tilbage — og svarene kunne lande i den forkerte rækkefølge.
|
||||||
|
*/
|
||||||
|
let draftQuery = $state('');
|
||||||
|
let typing = $state(false);
|
||||||
|
let searchTimer;
|
||||||
|
|
||||||
|
function search(next) {
|
||||||
|
draftQuery = next;
|
||||||
|
typing = next.trim() !== '' && next !== query;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
query = draftQuery;
|
||||||
|
typing = false;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
let status = $state('loading');
|
let status = $state('loading');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
@@ -74,6 +179,7 @@
|
|||||||
let timer;
|
let timer;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
showArchive;
|
||||||
load(query);
|
load(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +187,7 @@
|
|||||||
// opens it rather than dropping the person on a list to find it again.
|
// opens it rather than dropping the person on a list to find it again.
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
let asked = $derived($page.url.searchParams.get('note'));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!asked || selected?.id === asked) return;
|
if (!asked || selectedId === asked) return;
|
||||||
const found = notes.find((n) => n.id === asked);
|
const found = notes.find((n) => n.id === asked);
|
||||||
if (found) open(found);
|
if (found) open(found);
|
||||||
else api.note(asked).then(open).catch(() => {});
|
else api.note(asked).then(open).catch(() => {});
|
||||||
@@ -89,19 +195,60 @@
|
|||||||
|
|
||||||
async function load(q) {
|
async function load(q) {
|
||||||
try {
|
try {
|
||||||
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
const params = q ? { q } : showArchive ? { archived: '1' } : {};
|
||||||
|
notes = (await api.notes(params)).notes ?? [];
|
||||||
status = 'ready';
|
status = 'ready';
|
||||||
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = 'failed';
|
status = 'failed';
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(note) {
|
/**
|
||||||
|
* Åbner en note — og henter den hel først, hvis listen kun har uddraget.
|
||||||
|
*
|
||||||
|
* Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter
|
||||||
|
* ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville
|
||||||
|
* gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag
|
||||||
|
* af ingenting andet end at være blevet kigget på.
|
||||||
|
*
|
||||||
|
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
||||||
|
* at vente; teksten kommer, når den er der.
|
||||||
|
*/
|
||||||
|
async function open(note) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
selected = note;
|
if (!note) {
|
||||||
draft = note?.body ?? '';
|
selectedId = null;
|
||||||
|
selected = null;
|
||||||
|
draft = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rækken fremhæves med det samme; editoren får først noten, når den er hel.
|
||||||
|
//
|
||||||
|
// De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id*
|
||||||
|
// skifter — og hvis den fik uddraget først og hele noten bagefter, ville
|
||||||
|
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
|
||||||
|
// sidde med en tom note, der lige havde haft en titel.
|
||||||
|
selectedId = note.id;
|
||||||
|
if (note.body) {
|
||||||
|
selected = note;
|
||||||
|
draft = note.body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const full = await api.note(note.id);
|
||||||
|
// Kun hvis den stadig er den, der er valgt: skifter man note, mens
|
||||||
|
// hentningen er undervejs, må den forrige ikke lande oven i den nye.
|
||||||
|
if (selectedId !== note.id) return;
|
||||||
|
selected = full;
|
||||||
|
draft = full.body ?? '';
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +298,14 @@
|
|||||||
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hvilken dato rækken viser: den, der sorteres efter. */
|
||||||
|
const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
|
||||||
|
|
||||||
|
/** Begge datoer, til den, der holder musen stille. */
|
||||||
|
const stamp = (note) =>
|
||||||
|
`${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` +
|
||||||
|
`${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`;
|
||||||
|
|
||||||
function projectName(id) {
|
function projectName(id) {
|
||||||
return app.projects.find((p) => p.id === id)?.name ?? '';
|
return app.projects.find((p) => p.id === id)?.name ?? '';
|
||||||
}
|
}
|
||||||
@@ -161,7 +316,11 @@
|
|||||||
|
|
||||||
function onDragStart(event, note) {
|
function onDragStart(event, note) {
|
||||||
dragging = note.id;
|
dragging = note.id;
|
||||||
startDrag(event, NOTE, note.id);
|
// Trækker man en note, der er markeret, følger hele markeringen med. Trækker
|
||||||
|
// man en, der ikke er, er det den ene — ellers ville et træk i en tilfældig
|
||||||
|
// række flytte halvtreds noter, man havde glemt var markeret.
|
||||||
|
const ids = picked.has(note.id) ? [...picked] : [note.id];
|
||||||
|
startDrag(event, NOTE, ids.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -194,7 +353,10 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const saved = await api.updateNote(id, { body });
|
const saved = await api.updateNote(id, { body });
|
||||||
notes = notes.map((n) => (n.id === id ? saved : n));
|
// Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers
|
||||||
|
// står linjen tom, indtil siden hentes igen.
|
||||||
|
const forList = { ...saved, preview: saved.body ?? '' };
|
||||||
|
notes = notes.map((n) => (n.id === id ? forList : n));
|
||||||
// Only if it is still the one on screen: switching notes mid-save must not
|
// 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.
|
// drag the previous one's title back onto this one.
|
||||||
if (selected?.id === id) selected = saved;
|
if (selected?.id === id) selected = saved;
|
||||||
@@ -295,22 +457,63 @@
|
|||||||
<option value={o.key}>{t(o.label)}</option>
|
<option value={o.key}>{t(o.label)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
class="archive-toggle"
|
||||||
|
class:on={showArchive}
|
||||||
|
onclick={() => { showArchive = !showArchive; picked = new Set(); }}
|
||||||
|
aria-pressed={showArchive}
|
||||||
|
aria-label={t('notes.showArchive')}
|
||||||
|
title={t('notes.showArchive')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<!-- Samme form som søgefeltet i toppen: samme flade, samme kant, samme
|
||||||
class="search"
|
radius. Det var en pille med et forstørrelsesglas tegnet ind som et
|
||||||
type="search"
|
baggrundsbillede med farven skrevet i hånden — så det var det ene sted i
|
||||||
bind:value={query}
|
programmet, der ikke fulgte temaet, og det kunne ses. -->
|
||||||
placeholder={t('notes.search')}
|
<div class="searchbox" class:busy={typing}>
|
||||||
aria-label={t('notes.search')}
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
/>
|
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M20 20l-3.5-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={draftQuery}
|
||||||
|
oninput={(e) => search(e.currentTarget.value)}
|
||||||
|
placeholder={t('notes.search')}
|
||||||
|
aria-label={t('notes.search')}
|
||||||
|
/>
|
||||||
|
{#if draftQuery}
|
||||||
|
<button class="clear" onclick={() => search('')} aria-label={t('notes.clearSearch')}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if picked.size}
|
||||||
|
<!-- Kun mens der er markeret noget. En bjælke, der altid står der, er en
|
||||||
|
bjælke, der tager plads fra listen hver dag for at gøre gavn sjældent. -->
|
||||||
|
<div class="picked-bar">
|
||||||
|
<span>{t('notes.picked', { count: picked.size })}</span>
|
||||||
|
<button onclick={() => bulk({ archived: !showArchive })}>
|
||||||
|
{showArchive ? t('notes.unarchive') : t('notes.archive')}
|
||||||
|
</button>
|
||||||
|
<button class="danger" onclick={() => bulk({ delete: true })}>{t('notes.delete')}</button>
|
||||||
|
<button onclick={() => (picked = new Set())}>{t('notes.clearPick')}</button>
|
||||||
|
</div>
|
||||||
|
{:else if query && status === 'ready'}
|
||||||
|
<p class="found">{t('notes.found', { count: notes.length })}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if status === 'loading'}
|
{#if status === 'loading'}
|
||||||
<p class="hint">{t('history.loading')}</p>
|
<p class="hint">{t('history.loading')}</p>
|
||||||
{:else if !notes.length}
|
{:else if !notes.length}
|
||||||
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
<p class="hint">
|
||||||
|
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each ordered as note (note.id)}
|
{#each ordered as note (note.id)}
|
||||||
@@ -318,8 +521,9 @@
|
|||||||
<div class="rowline">
|
<div class="rowline">
|
||||||
<button
|
<button
|
||||||
class="row"
|
class="row"
|
||||||
class:on={selected?.id === note.id}
|
class:on={selectedId === note.id}
|
||||||
onclick={() => open(note)}
|
class:picked={picked.has(note.id)}
|
||||||
|
onclick={(e) => rowClick(e, note)}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onDragStart(e, note)}
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
ondragend={() => (dragging = null)}
|
ondragend={() => (dragging = null)}
|
||||||
@@ -329,13 +533,31 @@
|
|||||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
linjer pr. note frem for tre, og datoen er dét, man skanner
|
||||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
efter, når man leder efter noget, man skrev i tirsdags. -->
|
||||||
<span class="under">
|
<span class="under">
|
||||||
<span class="when">{when(note.updated_at)}</span>
|
<!-- Datoen, der svarer til den rækkefølge, listen står i.
|
||||||
<span class="preview">{plain(note.body)}</span>
|
Den viste altid `updated_at`, og for tolv hundrede
|
||||||
|
importerede noter er det i aftes — så en note fra 2017
|
||||||
|
stod som "i går", og den dato, man sorterede efter, var
|
||||||
|
ingen steder at se. -->
|
||||||
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
|
<span class="preview">{plain(note.preview ?? note.body)}</span>
|
||||||
</span>
|
</span>
|
||||||
{#if note.project_id}
|
{#if note.project_id}
|
||||||
<span class="filed">{projectName(note.project_id)}</span>
|
<span class="filed">{projectName(note.project_id)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||||
|
til flere; det her er til den ene, man står med — og i
|
||||||
|
arkivet er det vejen tilbage. -->
|
||||||
|
<button
|
||||||
|
class="archive-one"
|
||||||
|
onclick={() => archiveOne(note)}
|
||||||
|
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="star"
|
class="star"
|
||||||
class:on={note.pinned}
|
class:on={note.pinned}
|
||||||
@@ -474,21 +696,164 @@
|
|||||||
— og skifter til det almindelige, så snart feltet får fokus: ikonet forsvandt
|
— 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
|
og formen blev firkantet midt i et klik. Med appearance: none er der kun én
|
||||||
udgave, og den ser ens ud hele tiden. */
|
udgave, og den ser ens ud hele tiden. */
|
||||||
.search {
|
/* Formet som søgefeltet i toppen — samme flade, kant og radius — så de to
|
||||||
width: 100%;
|
søgefelter på skærmen ligner hinanden i stedet for at være to beslutninger.
|
||||||
appearance: none;
|
Ikonet er en rigtig SVG med `currentColor`, ikke et baggrundsbillede med en
|
||||||
-webkit-appearance: none;
|
farve skrevet ind: den gamle var #8b918d uanset tema. */
|
||||||
padding-left: 30px;
|
.searchbox {
|
||||||
border-radius: 999px;
|
display: flex;
|
||||||
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");
|
align-items: center;
|
||||||
background-repeat: no-repeat;
|
gap: var(--s2);
|
||||||
background-position: 9px center;
|
padding: var(--s2) var(--s3);
|
||||||
background-size: 15px 15px;
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Safaris egen ryd-knap ville sidde oven i vores egen form. */
|
.searchbox:focus-within {
|
||||||
.search::-webkit-search-decoration,
|
border-color: var(--line-strong);
|
||||||
.search::-webkit-search-cancel-button {
|
}
|
||||||
|
|
||||||
|
/* Mens det tastede endnu ikke er søgt efter. Et hint om at der kommer noget,
|
||||||
|
frem for en liste, der ser forkert ud i en kvart sekund. */
|
||||||
|
.searchbox.busy {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
flex: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear:hover {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bjælken over listen, mens der er markeret noget. Den erstatter antallet af
|
||||||
|
fundne frem for at lægge sig oven i det: kun ét af de to er relevant ad
|
||||||
|
gangen. */
|
||||||
|
.picked-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--s2) var(--s1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 2px var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-bar button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En markeret række.
|
||||||
|
*
|
||||||
|
* En kant og en tone, ikke en tone alene: `--accent-sunken` er meget lys i lyst
|
||||||
|
* tema, og tre markerede rækker var næsten ikke til at skelne fra tre, der ikke
|
||||||
|
* var — hvilket er en dårlig ting at være i tvivl om, lige inden man trykker
|
||||||
|
* Slet. Kanten kan ses uanset tema og uanset hvor lys tonen er. */
|
||||||
|
.row.picked {
|
||||||
|
background: var(--accent-sunken);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vises som stjernen: usynlig, indtil rækken er under musen, så listen er
|
||||||
|
rolig at skanne. */
|
||||||
|
.archive-one {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-one svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowline:hover .archive-one,
|
||||||
|
.archive-one:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-one:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle.on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safaris egen ryd-knap ville sidde oven i vores egen. */
|
||||||
|
.searchbox input::-webkit-search-decoration,
|
||||||
|
.searchbox input::-webkit-search-cancel-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +865,25 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rækker uden for skærmen koster ingenting.
|
||||||
|
*
|
||||||
|
* Listen tegner alle noterne. Med ti var det ligegyldigt; med tolv hundrede tog
|
||||||
|
* første tegning to sekunder, og browseren gik i knæ — for hver eneste række
|
||||||
|
* blev der lavet layout og malet, også de tusind, ingen kan se.
|
||||||
|
*
|
||||||
|
* `content-visibility: auto` lader browseren springe netop det over for det,
|
||||||
|
* der er uden for billedet, og tage det, når man ruller derhen. Ingen JavaScript,
|
||||||
|
* ingen rullematematik, ingen liste, der kan komme ud af trit med sig selv —
|
||||||
|
* hvilket er hele grunden til at prøve det her før en vinduesvisning.
|
||||||
|
*
|
||||||
|
* `contain-intrinsic-size` er gættet på, hvor høj en uset række er. Uden det
|
||||||
|
* ville rullebjælken tro, at listen er tom, og hoppe, mens man ruller. Tallet er
|
||||||
|
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||||
|
li {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
.rowline {
|
.rowline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -868,7 +868,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/noter?note={note.id}">
|
<a href="/noter?note={note.id}">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
<span>{note.body.slice(0, 90)}</span>
|
<!-- `preview` når listen bærer det, `body` når noten kom
|
||||||
|
fra et sted, der sender den hel. -->
|
||||||
|
<span>{(note.preview ?? note.body ?? '').slice(0, 90)}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user