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}
+			
+			
+ {t('notes.picked', { count: 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}
    {#each ordered as note (note.id)} @@ -420,7 +522,8 @@ + +