Free unused attachments, and keep versions by count rather than by age
Two rules governed version retention: the newest 50 per note, and a sweep of anything past 90 days. The second was wrong. The count already bounds what a note can accumulate, so age only ever deleted history nothing had replaced — and it bit hardest exactly where history is most wanted, a note left alone for a quarter of a year losing its whole past for having done nothing. It is off unless VERSION_RETENTION_DAYS is set. How many versions to keep is now a notebook's own setting, falling back to the server default when left empty. A reference notebook can keep fifty of everything while a scratch one keeps three. That makes freeing attachments answerable. A picture taken out of a note cannot be deleted straight away: the versions from before still show it, and restoring one would find a hole. It is released once no current note and no kept version refers to it — which, with retention by count, is when the last version holding it is pushed out. The server cannot look for itself, bodies being encrypted, so the client reports a note's attachments on every save as it already does for links, and a version inherits what the note held when it was snapshotted — by definition what that version contains. Two things make it safe rather than merely plausible. Silence is not "uses nothing": a note written before this existed has no rows, which looks identical to one with no pictures, so nothing is released for a user while any of their notes has yet to report — the prefetch that fills the search index reports for them on its first pass. And an upload has a row before the note using it does, so recent attachments are left alone. POST /api/admin/housekeeping runs the sweep on demand. Caught while testing: the same block of markup had been pasted into the create-notebook dialog as well, where the state it referenced did not exist — the dialog crashed on open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a32d6eba99
commit
3acb8b66a4
12 files changed
+507
-28
No files matched your search
@@ -122,6 +122,22 @@ export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of every attachment this document embeds.
|
||||
*
|
||||
* The server stores content encrypted and cannot look inside, so this is the
|
||||
* only account of what a note still uses. It is what lets a picture removed from
|
||||
* a note eventually be freed — and, just as important, what stops one still in
|
||||
* use from being deleted.
|
||||
*/
|
||||
export function extractAttachmentIds(doc: DocNode): string[] {
|
||||
const ids = new Set<string>();
|
||||
walk(doc, (n) => {
|
||||
if (n.type === "image" && n.attrs?.attachmentId) ids.add(n.attrs.attachmentId);
|
||||
});
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Plain text of a document — used for list previews and local search. */
|
||||
export function docToText(doc: unknown, limit = 400): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
|
||||
import { buildExtensions } from "./extensions.js";
|
||||
import { parseNoteContent, serializeNoteContent, extractNoteLinks } from "./content";
|
||||
import { parseNoteContent, serializeNoteContent, extractNoteLinks, extractAttachmentIds } from "./content";
|
||||
import { uploadImage, ACCEPTED_TYPES } from "./attachments.js";
|
||||
import "./editor.css";
|
||||
|
||||
@@ -78,6 +78,9 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
|
||||
onChangeRef.current?.({
|
||||
content: serializeNoteContent(doc),
|
||||
links: extractNoteLinks(doc, noteId),
|
||||
// Travels with every save: the server cannot read the body, so this is
|
||||
// its only account of which pictures the note still uses.
|
||||
attachments: extractAttachmentIds(doc),
|
||||
});
|
||||
},
|
||||
// Rebuilt per note so each one starts with its own document and history.
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface NoteEdit {
|
||||
content?: string;
|
||||
/** Ids of notes and notebooks the body links to; stored server-side for backlinks. */
|
||||
links?: string[];
|
||||
/** Ids of attachments the body embeds, so unused ones can eventually be freed. */
|
||||
attachments?: string[];
|
||||
drawingStrokes?: StrokeDoc | null;
|
||||
}
|
||||
|
||||
@@ -212,7 +214,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
if (edit.content !== undefined) {
|
||||
const content = edit.content;
|
||||
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
|
||||
void sync.updateNoteContent(id, content, edit.links);
|
||||
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
|
||||
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
|
||||
}
|
||||
if (edit.drawingStrokes !== undefined) {
|
||||
|
||||
@@ -71,6 +71,7 @@ interface ServerNotebook {
|
||||
name: string;
|
||||
cover_index?: number;
|
||||
sort_order?: number;
|
||||
max_versions?: number | null;
|
||||
}
|
||||
|
||||
interface ServerNote {
|
||||
@@ -104,6 +105,8 @@ export interface NoteUpdate {
|
||||
notebookId?: string | null;
|
||||
starred?: boolean;
|
||||
links?: string[];
|
||||
/** Attachments the body embeds, so ones nothing uses can be released. */
|
||||
attachments?: string[];
|
||||
/**
|
||||
* The server revision this edit was written on top of.
|
||||
*
|
||||
@@ -399,6 +402,7 @@ export async function fullSync(): Promise<void> {
|
||||
name: nb.name,
|
||||
cover: nb.cover_index || 0,
|
||||
sortOrder: nb.sort_order || 0,
|
||||
maxVersions: nb.max_versions ?? null,
|
||||
}));
|
||||
const serverNbIds = new Set(nbs.map((nb) => nb.id));
|
||||
const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id));
|
||||
@@ -459,7 +463,10 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
|
||||
/** Every kind of change that can wait offline. */
|
||||
type QueuedAction =
|
||||
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
|
||||
| { action: "updateNotebook"; data: { id: string; name?: string; cover?: number; sortOrder?: number } }
|
||||
| {
|
||||
action: "updateNotebook";
|
||||
data: { id: string; name?: string; cover?: number; sortOrder?: number; maxVersions?: number | null };
|
||||
}
|
||||
| { action: "deleteNotebook"; data: { id: string } }
|
||||
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
|
||||
| { action: "updateNote"; data: { id: string; updates: NoteUpdate } }
|
||||
@@ -542,6 +549,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
name: a.data.name,
|
||||
coverIndex: a.data.cover,
|
||||
sortOrder: a.data.sortOrder,
|
||||
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
|
||||
});
|
||||
break;
|
||||
case "deleteNotebook":
|
||||
@@ -695,6 +703,9 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
||||
name: updates.name,
|
||||
coverIndex: updates.cover,
|
||||
sortOrder: updates.sortOrder,
|
||||
// null is meaningful here — it clears the notebook's own setting — so it
|
||||
// has to travel, while undefined must stay away and change nothing.
|
||||
...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}),
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
@@ -703,7 +714,13 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
||||
}
|
||||
await enqueue({
|
||||
action: "updateNotebook",
|
||||
data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder },
|
||||
data: {
|
||||
id,
|
||||
name: updates.name,
|
||||
cover: updates.cover,
|
||||
sortOrder: updates.sortOrder,
|
||||
maxVersions: updates.maxVersions,
|
||||
},
|
||||
});
|
||||
notifyStatus("pending");
|
||||
}
|
||||
@@ -851,7 +868,12 @@ export async function updateNoteTitle(id: string, title: string): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateNoteContent(id: string, content: string, links?: string[]): Promise<void> {
|
||||
export async function updateNoteContent(
|
||||
id: string,
|
||||
content: string,
|
||||
links?: string[],
|
||||
attachments?: string[]
|
||||
): Promise<void> {
|
||||
const ex = await db.getNoteContent(id);
|
||||
await db.putNoteContent(id, content, ex?.drawingData || null, ex?.drawingStrokes || null);
|
||||
const note = await db.getNote(id);
|
||||
@@ -860,9 +882,9 @@ export async function updateNoteContent(id: string, content: string, links?: str
|
||||
|
||||
// The server cannot read the encrypted body, so outgoing links travel with it.
|
||||
// baseUpdatedAt is the revision this copy came from — see NoteUpdate.
|
||||
const updates: NoteUpdate = Array.isArray(links)
|
||||
? { content, links, baseUpdatedAt: ex?.updatedAt ?? null }
|
||||
: { content, baseUpdatedAt: ex?.updatedAt ?? null };
|
||||
const updates: NoteUpdate = { content, baseUpdatedAt: ex?.updatedAt ?? null };
|
||||
if (Array.isArray(links)) updates.links = links;
|
||||
if (Array.isArray(attachments)) updates.attachments = attachments;
|
||||
|
||||
debounce(`content_${id}`, async () => {
|
||||
if (_online && api.token && !note?._pendingCreate) {
|
||||
@@ -1081,6 +1103,26 @@ export async function createPdfNote(
|
||||
* search with almost nothing to look at on a fresh device. This walks the notes
|
||||
* with no cached body (or a stale one) and fills them in, deliberately slowly so
|
||||
* it never competes with what the user is doing. */
|
||||
/**
|
||||
* Tells the server which attachments a note body uses.
|
||||
*
|
||||
* Notes written before attachment references existed have never said, and the
|
||||
* server cannot look inside an encrypted body to find out. Until every note has
|
||||
* reported, nothing is ever released — so this pass, riding along with the
|
||||
* prefetch that fills the search index, is what eventually lets unused pictures
|
||||
* be freed. Sent even when empty: "uses nothing" is the answer that matters.
|
||||
*/
|
||||
async function reportAttachments(noteId: string, content: string): Promise<void> {
|
||||
try {
|
||||
const { parseNoteContent, extractAttachmentIds } = await import("./editor/content");
|
||||
const { parsingExtensions } = await import("./editor/extensions.js");
|
||||
const ids = extractAttachmentIds(parseNoteContent(content, parsingExtensions));
|
||||
await api.put(`/notes/${noteId}`, { attachments: ids });
|
||||
} catch {
|
||||
/* one note that will not parse must not stop the pass */
|
||||
}
|
||||
}
|
||||
|
||||
export interface PrefetchProgress {
|
||||
id: string;
|
||||
content: string;
|
||||
@@ -1114,6 +1156,7 @@ export async function prefetchAllContent({
|
||||
const d = await api.get<ServerNoteBody>(`/notes/${n.id}`);
|
||||
const c = shapeContent(d);
|
||||
await db.putNoteContent(n.id, c.content, c.drawingData, c.drawingStrokes, c.updatedAt);
|
||||
await reportAttachments(n.id, c.content);
|
||||
fetched++;
|
||||
onProgress?.({ id: n.id, content: c.content, fetched, total: pending.length });
|
||||
} catch {
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface Notebook {
|
||||
name: string;
|
||||
cover: number;
|
||||
sortOrder: number;
|
||||
/** Versions kept per note here; null means the server's default. */
|
||||
maxVersions?: number | null;
|
||||
_pendingCreate?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ import { COVERS } from "./constants.jsx";
|
||||
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||
const [name, setName] = useState(notebook.name);
|
||||
const [sel, setSel] = useState(notebook.cover);
|
||||
// Empty means "use the server's default", which is why it is a string here and
|
||||
// not a number with a stand-in value.
|
||||
const [versions, setVersions] = useState(notebook.maxVersions == null ? "" : String(notebook.maxVersions));
|
||||
|
||||
const save = () => {
|
||||
if (!name.trim()) return;
|
||||
const n = versions.trim() === "" ? null : Math.max(1, Math.round(Number(versions)));
|
||||
onSave({ name: name.trim(), cover: sel, maxVersions: Number.isFinite(n) ? n : null });
|
||||
};
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
@@ -69,9 +78,40 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && name.trim()) onSave({ name: name.trim(), cover: sel });
|
||||
if (e.key === "Enter") save();
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label style={{ fontSize: 12, color: T.muted, display: "block", marginBottom: 5 }}>
|
||||
Versionen pro Notiz
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={versions}
|
||||
onChange={(e) => setVersions(e.target.value)}
|
||||
placeholder="Vorgabe"
|
||||
aria-label="Versions per note"
|
||||
style={{
|
||||
width: 110,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: dark ? "#0d1117" : "#f6f8fa",
|
||||
color: T.text,
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
|
||||
Ältere Fassungen werden erst gelöscht, wenn neue sie verdrängen — nicht nach Zeit. Leer lassen für
|
||||
die Vorgabe des Servers.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
|
||||
@@ -115,7 +155,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (name.trim()) onSave({ name: name.trim(), cover: sel });
|
||||
save();
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
|
||||
Reference in new issue
Block a user