Document trash, collections, thumbnails, and the UI direction
Specs for the work that follows: soft delete via a MOVE that preserves the remote id, the collection tree and smart collections, the thumbnail store, and the derived-state folder. Adds two design documents. ui-refinement.md names the structural gaps between the v0.1 UI and something that feels like a photo editor. view-composition.md proposes a view controller for the display layer, against the 500-line run() that has become one by accretion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+222
-10
@@ -372,12 +372,19 @@ pub enum Selector {
|
||||
IsoRange { min: u32, max: u32 }, // ⊕
|
||||
Availability(Availability), // ⊕ "what can I edit right now"
|
||||
Text(String), // ⊕ filename/keyword substring
|
||||
Person { id: PersonId, include_suggested: bool }, // ⊕ §10 (FR-CULL-11)
|
||||
All_(Vec<Selector>),
|
||||
Any(Vec<Selector>),
|
||||
Not(Box<Selector>),
|
||||
}
|
||||
```
|
||||
|
||||
`Person` carries `include_suggested` rather than defaulting silently. A saved collection built from
|
||||
confirmed faces must not quietly change membership because a later indexing pass guessed at another
|
||||
face; the user chose "photos of Anna", not "photos the model currently believes contain Anna". The
|
||||
default is `false`, and the interactive filter offers the looser form explicitly as a way to *find*
|
||||
faces to confirm.
|
||||
|
||||
`Availability` as a selector earns its place: on a tablet the most useful filter is often "what do I
|
||||
actually have here", and it is also the natural thing to *pin* — "keep everything I've flagged that
|
||||
isn't already local".
|
||||
@@ -402,6 +409,7 @@ pub enum JobKind {
|
||||
ContentHash, // on demand only
|
||||
FetchPreview, // remote range-extract (FR-NC-3)
|
||||
FetchOriginal, // pinned or explicitly requested
|
||||
DetectFaces, // §10, on the proxy tier (FR-CULL-8)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -482,16 +490,117 @@ Server previews (`/core/preview`) are tried only where PROPFIND reported `nc:has
|
||||
verified stock Nextcloud ships no RAW preview provider, so for RAW this is nearly always absent — it
|
||||
is an opportunistic saving, never the mechanism.
|
||||
|
||||
### 7.3 Storage
|
||||
### 7.3 Storage: sharded, shared, synced
|
||||
|
||||
Thumbnails are content-addressed by `(content_hash | source_ref, size_class)` and stored as files
|
||||
under the platform cache directory, with the `cache` table holding the index. Files, not BLOBs:
|
||||
SQLite handles small blobs well but a 50k-image thumbnail cache is gigabytes, and mixing it into the
|
||||
catalog would bloat the file the app must open in under two seconds.
|
||||
Decided 2026-08-09, implemented in `dr-thumbs`. Thumbnails live in **sharded SQLite databases that
|
||||
sync to Nextcloud**, so a second device gets a full grid without re-fetching a byte of RAW.
|
||||
|
||||
Two size classes at v1 — grid (256px) and filmstrip/loupe (1024px) — both long-edge, both JPEG. The
|
||||
cache is LRU-capped per NFR-RES-4, and thumbnails evict before proxies and long after sidecars,
|
||||
which never evict at all (FR-NC-6b).
|
||||
```text
|
||||
thumbs/
|
||||
index.sqlite fileid → shard, plus size accounting
|
||||
shard-0000.sqlite ≤ 25 MB, sealed
|
||||
shard-0001.sqlite ≤ 25 MB, active
|
||||
```
|
||||
|
||||
**Why a thumbnail is worth syncing when the catalog mostly is not.** It is expensive to produce — a
|
||||
range fetch plus a decode, per image — and byte-identical for every client looking at the same file.
|
||||
This does not make it authoritative: losing the store costs regeneration and nothing else, so §6.12
|
||||
is untouched.
|
||||
|
||||
**Why shards, and why small.** The 25 MB cap is about *sync granularity*, not SQLite's limits. One
|
||||
growing database means every client re-downloads all of it whenever a single thumbnail is added.
|
||||
With sequential fill only the newest shard is ever dirty, so an up-to-date client transfers one small
|
||||
file. Sealed shards are immutable, which makes them safe to cache forever and cheap to skip.
|
||||
|
||||
At ~20 KB per 256px JPEG a shard holds roughly 1,200 thumbnails, so the 17,185-image reference
|
||||
library lands in ~14 shards.
|
||||
|
||||
**Keyed on `oc:fileid`** — stable across server-side rename and move (FR-NC-5), and already in hand
|
||||
from PROPFIND. Accepted consequence: shards are account-scoped, so the same photograph on two
|
||||
servers is thumbnailed twice.
|
||||
|
||||
**Stored as JPEG, not raw pixels.** A 256×170 RGBA buffer is ~174 KB against ~15 KB encoded. Since
|
||||
shards sync, that 11× is transfer cost paid by every client, not just disk.
|
||||
|
||||
Three invariants, each tested:
|
||||
|
||||
| Invariant | Why it matters |
|
||||
|---|---|
|
||||
| A sealed shard never reopens | Reopening one forces every client that holds it to re-download |
|
||||
| Re-storing an existing id updates in place, never migrates | Migrating would rewrite a sealed shard |
|
||||
| Merging another client's shard is insert-only and idempotent | Both copies derive from the same bytes by the same code, so neither is better; preferring ours avoids dirtying a shard others have synced |
|
||||
|
||||
**Not yet built:** the transfer itself. `ThumbStore::shards()` reports which shards are sealed —
|
||||
the input a sync pass needs — and `merge_shard` adopts a downloaded one, but nothing uploads or
|
||||
downloads them yet. Until that lands the store is a local cache that happens to have the right shape.
|
||||
|
||||
Two size classes remain planned — grid (256px) and filmstrip/loupe (1024px). Only the grid class is
|
||||
implemented. The cache is LRU-capped per NFR-RES-4, and thumbnails evict before proxies and long
|
||||
after sidecars, which never evict at all (FR-NC-6b).
|
||||
|
||||
---
|
||||
|
||||
## 7a. Editing collections
|
||||
|
||||
Decided 2026-08-09. The schema for collections landed with §2 and the cross-device merge rules with
|
||||
§8; this is the layer between them — the operations a user actually performs, in
|
||||
`dr_catalog::collections`.
|
||||
|
||||
### 7a.1 Hierarchy and membership are independent
|
||||
|
||||
Two structures, deliberately not entangled:
|
||||
|
||||
| | Mechanism | Meaning |
|
||||
|---|---|---|
|
||||
| Hierarchy | `collections.parent_id` | A collection inside a collection (Lightroom's "collection set"). A parent is an ordinary collection, not a separate kind, so a set can hold images of its own |
|
||||
| Membership | `collection_members` | An image is in as many collections as the user likes. Nothing moves on disk; no collection owns an image |
|
||||
|
||||
**Adding an image to a child does not write a row for the parent.** A parent's contents are the union
|
||||
of its own members and its descendants', computed on read. Materialising it instead would make one
|
||||
add touch every ancestor, and a reparent rewrite membership — both of which §8's row-level merge
|
||||
would then have to reconcile. The read path pays a bounded tree walk instead, which at sidebar scale
|
||||
is nothing.
|
||||
|
||||
The consequence the UI depends on: dragging images onto a collection is **additive**. It does not
|
||||
remove them from anywhere, which is why the gesture's default action is `copy` and not `move`.
|
||||
|
||||
### 7a.2 Rules that exist to prevent silent damage
|
||||
|
||||
| Rule | Why |
|
||||
|---|---|
|
||||
| Every mutation bumps `revision` | §8.4 resolves conflicts by revision. An edit that updates `modified` alone is invisible to the merge, so the *other* device silently wins and the user's work vanishes |
|
||||
| A no-op add does **not** bump it | Otherwise an idle device that re-dropped the same images outranks one that did real work |
|
||||
| Deleting a parent **promotes** its children | The schema's `ON DELETE CASCADE` would take the whole subtree. Losing a nested collection because its container was tidied away is not recoverable |
|
||||
| Deletion leaves a tombstone | Without it, merging with a device that still holds the collection resurrects it (§8.4) |
|
||||
| Cycles are refused at the write | Both kinds — parenting under a descendant, and a smart collection whose selector reaches itself. A cycle is unbounded recursion in the tree walk, so it must not be *representable*, not merely handled when drawn |
|
||||
| Tree walks are depth-guarded anyway | A merge can deliver a row this device never validated. The read path must terminate, so it truncates and logs rather than hanging the UI thread |
|
||||
| A drop onto a smart collection is refused | Its membership *is* its selector; member rows would be a second source of truth that nothing reads |
|
||||
| Deep counts are `count(DISTINCT image_id)` | An image in both a parent and a child is one photograph. A count that disagrees with the number of cells drawn makes both untrustworthy |
|
||||
|
||||
`collections.uuid` is generated from the OS CSPRNG. A collision fuses two unrelated collections at
|
||||
the next merge, so the fallback path (used only if `/dev/urandom` cannot be read) logs loudly rather
|
||||
than degrading identity quality in silence.
|
||||
|
||||
### 7a.3 Drag and drop is Slint's, not ours
|
||||
|
||||
The first implementation hand-rolled the gesture on `TouchArea` — tracking the press, measuring
|
||||
travel to distinguish a click from a drag, and deciding the drop target from the last row hovered.
|
||||
**It did not work**, for a reason worth recording: an interactive `Flickable` claims any drag
|
||||
beginning inside it for scrolling and *cancels* the child `TouchArea`'s press, so the gesture could
|
||||
never leave the grid. It also had a correctness hole — a tree rebuilt mid-drag could redirect the
|
||||
drop, since a captured pointer is invisible to every other element.
|
||||
|
||||
Slint 1.17's `DragArea`/`DropArea` own all of it: capture, the click-versus-drag threshold,
|
||||
arbitration against the `Flickable`, the image under the cursor, and hit-testing the release. What
|
||||
remains in `collections_ui` is only what Slint cannot know — the payload (which images, read from the
|
||||
selection when the drag starts) and the **spring**: a dwell timer that opens a collapsed collection
|
||||
so a nested child can be reached mid-drag, and closes again whatever the drag merely passed over.
|
||||
|
||||
One hazard survives the change and is easy to reintroduce. Every consequence of a drop — rebuilding
|
||||
the tree, refreshing the badges, rereading the grid — *replaces a Slint model*, and doing that inside
|
||||
the `dropped` handler destroys the elements Slint is still using to deliver that event. So the drop
|
||||
records its target and `drag-finished` acts on it. This is the same hazard `sync_rows` in `lib.rs`
|
||||
documents for the adjust panel, and it presents as a control that works once and then goes dead.
|
||||
|
||||
---
|
||||
|
||||
@@ -578,7 +687,104 @@ because if the SQLite path proves troublesome, this is the fallback with a known
|
||||
|
||||
---
|
||||
|
||||
## 9. Requirements touched
|
||||
## 10. People and faces
|
||||
|
||||
Specified by FR-CULL-8 … FR-CULL-12, NFR-SEC-5, [architecture.md §6.4](architecture.md). Gated on
|
||||
spike S14 and decision D13 — the runtime and the model licences are unresolved, so this is the shape
|
||||
of the subsystem, not a build order.
|
||||
|
||||
### 10.1 Schema (a v5 migration)
|
||||
|
||||
```sql
|
||||
CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE, -- merge identity, not the name (ARCH §6.3)
|
||||
name TEXT NOT NULL,
|
||||
-- Tombstone-by-redirect. A merged person must outlive its merge, or a
|
||||
-- device that still has it resurrects it — same hazard collections have.
|
||||
merged_into INTEGER REFERENCES people(id) ON DELETE SET NULL,
|
||||
created INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE faces (
|
||||
id INTEGER PRIMARY KEY,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- Normalised to the image's long edge, so a face survives the proxy it was
|
||||
-- found on being regenerated at another resolution.
|
||||
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
|
||||
landmarks BLOB, -- 5 × (x, y) f32, the alignment input
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 × f16, L2-normalised
|
||||
-- Which model produced this. An embedding is only comparable to others
|
||||
-- from the same model; mixing them silently yields nonsense similarities.
|
||||
model_id TEXT NOT NULL,
|
||||
detected_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX faces_image ON faces(image_id);
|
||||
|
||||
CREATE TABLE face_person (
|
||||
face_id INTEGER PRIMARY KEY REFERENCES faces(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
-- Calibrated P(this face is this person), never a raw cosine (FR-CULL-9).
|
||||
probability REAL NOT NULL,
|
||||
-- The user said so. Never overwritten by a later inference pass.
|
||||
confirmed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX face_person_person ON face_person(person_id, confirmed);
|
||||
```
|
||||
|
||||
Three things in that schema are load-bearing:
|
||||
|
||||
**`model_id` on every face.** Embeddings from different models are not comparable — this is the one
|
||||
mistake that produces plausible-looking garbage rather than an error. Storing the model with the
|
||||
embedding means a model change is detectable and re-indexable, instead of quietly poisoning every
|
||||
similarity in the library.
|
||||
|
||||
**Normalised bounding boxes.** Detection runs on whichever proxy exists (FR-CULL-8). Storing pixel
|
||||
coordinates would bind a face to a resolution that the cache is entitled to evict and regenerate
|
||||
differently.
|
||||
|
||||
**`confirmed` as a column, not a probability of 1.0.** A confirmation is a different kind of fact
|
||||
from a confident guess, and collapsing them loses the ability to recompute suggestions without
|
||||
touching user data.
|
||||
|
||||
### 10.2 Why clustering is not a job kind
|
||||
|
||||
Detection is per-image and parallel, so it is a job (`DetectFaces`, coalesced per image like any
|
||||
other). Clustering is a *whole-library* operation over the embeddings detection produced — it has no
|
||||
natural `subject_id`, and running it per-image would rebuild the world on every photograph.
|
||||
|
||||
It therefore runs as a debounced library-level pass, triggered when detection has been idle and the
|
||||
face count has moved materially since the last clustering. The same reasoning as sidecar writes: the
|
||||
work is cheap to defer, expensive to repeat, and nobody is waiting on it.
|
||||
|
||||
### 10.3 The calibration lives with the library
|
||||
|
||||
FR-CULL-9 requires similarity to be a calibrated probability, fitted from this library's own faces.
|
||||
That fit is a property of the catalog and its model, so it is stored alongside — a small table
|
||||
holding the fit parameters, its validity flag, and a hash of the face set it was derived from, so a
|
||||
materially changed library recomputes rather than trusting a stale fit.
|
||||
|
||||
When the fit is not valid — a library with too few faces to have positive pairs — the UI says the
|
||||
confidence is unavailable. It does not fall back to an untuned default dressed up as a measurement.
|
||||
|
||||
### 10.4 What this does not settle
|
||||
|
||||
- **Which model, and which runtime.** D13. Everything above holds regardless of the answer, which is
|
||||
why it is specified in terms of "a 512-d embedding from a stated model" rather than a named one.
|
||||
- **The clustering algorithm.** Density-based over the calibrated distance is the obvious starting
|
||||
point, but the parameters are an S14 question, not a design-time one.
|
||||
- **Whether embeddings sync.** NFR-SEC-5 permits it, opt-in. The shard mechanism in §7.3 is the
|
||||
obvious carrier if they do, but nothing here depends on that decision.
|
||||
- **Faces in trashed images.** FR-CAT-15's trash moves files; whether their faces stay indexed and
|
||||
keep contributing to clusters is unspecified. Probably they should be excluded from suggestions but
|
||||
not deleted, so a restore does not re-index.
|
||||
|
||||
---
|
||||
|
||||
## 11. Requirements touched
|
||||
|
||||
| ID | How this document addresses it |
|
||||
|---|---|
|
||||
@@ -587,7 +793,7 @@ because if the SQLite path proves troublesome, this is the fallback with a known
|
||||
| FR-CAT-4 | §4.1 windowed queries, memory independent of catalog size |
|
||||
| FR-CAT-5 | §3.5 two-pass metadata |
|
||||
| FR-CAT-6 | §4.3 indexed filter compilation, §5 selectors |
|
||||
| FR-CAT-7 | §2 collections schema, §5 manual and smart |
|
||||
| FR-CAT-7 | §2 collections schema, §5 manual and smart, §7a hierarchy, membership and editing |
|
||||
| FR-CAT-9 | §3.4 the offline/deleted distinction and the sweep guard |
|
||||
| FR-CAT-11 | §3.5 lazy content hashing |
|
||||
| FR-NC-3 | §7.2 range-extract for remote thumbnails |
|
||||
@@ -598,3 +804,9 @@ because if the SQLite path proves troublesome, this is the fallback with a known
|
||||
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
|
||||
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
|
||||
| NFR-RES-4 | §7.3 LRU cap, eviction order |
|
||||
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
|
||||
| FR-CULL-9 | §10.3 per-library calibration, stored with its validity and source hash |
|
||||
| FR-CULL-10 | §10.1 `people` and `face_person`, merge-by-redirect, §10.2 clustering as a library pass |
|
||||
| FR-CULL-11 | §5 `Selector::Person`, confirmed-only by default |
|
||||
| FR-CULL-12 | §10.1 derived data in the catalog; names to the sidecar, UUID as merge identity |
|
||||
| NFR-SEC-5 | §10.4 sync left undecided and off; nothing in §10 emits an embedding |
|
||||
|
||||
Reference in New Issue
Block a user