diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 6198774..0600360 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "search notes", err) 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 } @@ -62,7 +62,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "notes in project", err) return } - writeJSON(w, http.StatusOK, map[string]any{"notes": found}) + writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)}) return } @@ -74,7 +74,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "list notes", err) 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. diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go index 2c4fbe3..d120b73 100644 --- a/internal/httpapi/notes_test.go +++ b/internal/httpapi/notes_test.go @@ -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) } + // 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) notes := listed["notes"].([]any) if len(notes) != 1 { 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 // archive nobody kept. diff --git a/internal/store/notes.go b/internal/store/notes.go index 9a3ec87..c226fa4 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -20,6 +20,16 @@ type Note struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_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 // set by a caller: it is derived, and a caller that set it would be describing // a note whose text says something else. diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index e128ba5..c084446 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -46,6 +46,8 @@ 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.', 'notes.newHere': 'Ny note', '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.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 8763940..f3faff0 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -42,6 +42,8 @@ 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.', 'notes.newHere': 'New note', 'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.', + 'notes.clearSearch': 'Clear the search', + 'notes.found': '{count} notes', 'notes.strike': 'Strikethrough', 'notes.showSource': 'Show as code', 'notes.source': 'Note source', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 21e347d..14b87d5 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -72,7 +72,33 @@ return new Date(b[field]).getTime() - new Date(a[field]).getTime(); }); let selected = $state(null); + // Hvilken række der er valgt. Se open(): editoren venter på hele noten. + let selectedId = $state(null); 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 saving = $state(false); @@ -89,7 +115,7 @@ // opens it rather than dropping the person on a list to find it again. let asked = $derived($page.url.searchParams.get('note')); $effect(() => { - if (!asked || selected?.id === asked) return; + if (!asked || selectedId === asked) return; const found = notes.find((n) => n.id === asked); if (found) open(found); else api.note(asked).then(open).catch(() => {}); @@ -99,17 +125,57 @@ try { notes = (await api.notes(q ? { q } : {})).notes ?? []; 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) { status = 'failed'; 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); - selected = note; - draft = note?.body ?? ''; + if (!note) { + 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)); + } } /** @@ -210,7 +276,10 @@ saving = true; try { 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 // drag the previous one's title back onto this one. if (selected?.id === id) selected = saved; @@ -315,13 +384,29 @@ - + + + {#if query && status === 'ready'} +

{t('notes.found', { count: notes.length })}

+ {/if} {#if status === 'loading'}

{t('history.loading')}

@@ -334,7 +419,7 @@