diff --git a/docs/openapi.yaml b/docs/openapi.yaml index dc7a95e..46db003 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1396,6 +1396,57 @@ paths: description: The environment variables that are not set. "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: post: tags: [notes] diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 0600360..906898e 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -66,6 +66,19 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { 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 + } + // Everything they can see, not only the loose ones. Filing a note in a project // is how it gets shared, and a list that dropped it at that moment made sharing // look like deleting. @@ -721,3 +734,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque } 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}) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 30e82fd..bab03d2 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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 // in a notes app — paste a screenshot — do nothing at all. 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) { diff --git a/internal/store/migrations/0024_note_archive.sql b/internal/store/migrations/0024_note_archive.sql new file mode 100644 index 0000000..d08edbb --- /dev/null +++ b/internal/store/migrations/0024_note_archive.sql @@ -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; diff --git a/internal/store/notes.go b/internal/store/notes.go index c226fa4..080c34d 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -10,15 +10,16 @@ import ( // Note is a page of Markdown belonging to a project, or to nobody. type Note struct { - ID string `json:"id"` - ProjectID string `json:"project_id,omitempty"` - Title string `json:"title"` - Body string `json:"body"` - CreatedBy string `json:"created_by,omitempty"` - Pinned bool `json:"pinned"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + ID string `json:"id"` + ProjectID string `json:"project_id,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + CreatedBy string `json:"created_by,omitempty"` + Pinned bool `json:"pinned"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + 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. // @@ -43,7 +44,7 @@ type NoteLink struct { } 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. // @@ -51,7 +52,8 @@ const noteColumns = `id, project_id, title, body, created_by, pinned, // 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.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 // already uses. A `#Firma` in a note means the same project as a `#Firma` typed @@ -217,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 // deleting — which is exactly how it read before this existed. func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) { - return db.notesWhere(ctx, ` - WHERE deleted_at IS NULL + return db.allNotes(ctx, userID, false) +} + +// 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 ( (project_id IS NULL AND created_by = ?) OR project_id IN ( @@ -311,6 +328,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time 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. func (db *DB) DeleteNote(ctx context.Context, id string) error { _, err := db.ExecContext(ctx, @@ -374,10 +412,10 @@ func scanNote(row scanner) (Note, error) { var n Note var projectID, createdBy sql.NullString 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, - &n.Pinned, &created, &updated, &deleted); err != nil { + &n.Pinned, &created, &updated, &deleted, &archived); err != nil { return n, err } n.ProjectID = projectID.String @@ -388,5 +426,9 @@ func scanNote(row scanner) (Note, error) { t := time.Unix(deleted.Int64, 0) n.DeletedAt = &t } + if archived.Valid { + t := time.Unix(archived.Int64, 0) + n.ArchivedAt = &t + } return n, nil } diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index ea8ed92..26a0b98 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2794,3 +2794,103 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) 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
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([]);
+});
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index 0a8787d..a96cdc2 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -345,6 +345,7 @@ export const api = {
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
+ noteBulk: (body) => post('/notes/bulk', body),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index 5278e8d..5e5f448 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -121,12 +121,71 @@
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
* 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() {
if (!editor) return;
const inside = currentBlock();
for (const pre of editor.querySelectorAll('pre')) {
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);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
@@ -135,6 +194,7 @@
const target = pre.querySelector('code') ?? pre;
if (target.innerHTML !== painted) target.innerHTML = painted;
}
+ addCopyButtons();
}
function changed() {
@@ -915,6 +975,40 @@
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 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.
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,
diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte
index f6451f4..8894065 100644
--- a/web/src/lib/components/Sidebar.svelte
+++ b/web/src/lib/components/Sidebar.svelte
@@ -380,9 +380,12 @@
// The same target takes both. A note dropped on a project is filed there,
// which is also how it gets shared — one gesture, one meaning, and the
// sidebar already reads as "put this here".
- const noteID = dragged(event, NOTE);
- if (noteID) {
- await app.moveNoteToProject(noteID, project.id);
+ // Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
+ // halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
+ // 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;
}
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index c084446..5cc1f87 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -48,6 +48,17 @@ export const da = {
'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.showSource': 'Vis som kode',
'notes.source': 'Notens kildetekst',
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index f3faff0..0de5ae7 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -44,6 +44,17 @@ export const en = {
'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.showSource': 'Show as code',
'notes.source': 'Note source',
diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js
index b0eac2d..498055a 100644
--- a/web/src/lib/richtext.js
+++ b/web/src/lib/richtext.js
@@ -339,7 +339,15 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-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 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('```');
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index 14b87d5..6c45c7c 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -74,6 +74,77 @@
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('');
/**
@@ -108,6 +179,7 @@
let timer;
$effect(() => {
+ showArchive;
load(query);
});
@@ -123,7 +195,8 @@
async function load(q) {
try {
- notes = (await api.notes(q ? { q } : {})).notes ?? [];
+ const params = q ? { q } : showArchive ? { archived: '1' } : {};
+ notes = (await api.notes(params)).notes ?? [];
status = 'ready';
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
@@ -243,7 +316,11 @@
function onDragStart(event, note) {
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() {
@@ -380,6 +457,18 @@
{/each}
+
@@ -404,14 +493,27 @@
{/if}
- {#if query && status === 'ready'}
+ {#if picked.size}
+
+
+ {:else if query && status === 'ready'}
{t('notes.found', { count: notes.length })}
{/if}
{#if status === 'loading'}
{t('history.loading')}
{:else if !notes.length}
- {query ? t('notes.noneFound') : t('notes.noneYet')}
+
+ {query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
+
{:else}