Repository navigation
v1.15.0 "Planche-contact"
Planche-contact
A planche-contact — a contact sheet — is the print you get by laying the whole strip of negatives directly on the paper, under a sheet of glass, and exposing it: every frame of the roll at once, each at the negative's own size, nothing enlarged and nothing yet chosen. It is the sheet the photographer reads with a loupe and marks with a grease pencil — a circle round the frame to keep, a cross through the frame to drop — before a single print is made. Its whole value is that it shows the roll rather than the frames you happened to pull first, that you can always see which frame the pencil is resting on, and that the marks survive into the enlargement.
Facet's gallery was not that sheet. "Select all" selected the photos infinite scroll had already fetched and quietly left the rest of the roll out of whatever came next; nothing drawn on screen said which photo the rating keys would land on; and an edit made in the enlargement was shown back at its pre-edit value the moment you returned to the grid. This release is those three things, plus the bounds that make acting on a whole roll safe to offer at all.
Selecting the roll, not the page you have scrolled to
"Select all" and "Invert selection" only ever covered the loaded page. On a 650-photo library narrowed to an album or a tag, either button selected the 64 photos fetched so far and silently dropped the other 586 from the bulk action that followed — a favourite, a rating, a reject, a download, a cull. (#126)
Both actions now distinguish two cases. From an empty selection they take the entire filtered view — every photo the current filters match — and they do it at no network cost: nothing is enumerated up front, and the row set is derived from the filters server-side only when a bulk action actually runs. Because a whole-view action can reach far more than the tiles on screen, applying one first raises a confirmation stating the exact photo count, so nothing ever touches more photos than the number you approved. From a non-empty selection both keep the old loaded-only behaviour — extending or inverting a hand-picked keep list must not silently reach photos you have never looked at — and the selection bar offers a banner to widen an already-loaded selection to the whole view, as a decision rather than a side effect. Two read-only endpoints back this, GET /api/photos/count and GET /api/photos/paths, both scoped by exactly the filters the grid renders with.
Widening the selection used to throw keyboard focus to the top of the page: both buttons that widen a selection sit inside a block keyed on the state their own click flips, so Angular removed the button the instant it succeeded and focus landed on document.body. Measured in a browser, a keyboard user was dropped to the start of the tab order mid-task while 150 photos entered the selection with nothing announced. Focus now moves to the selection-count text — which states the new count, survives both toggles and stays out of the tab order — and that same text is announced through the live region.
What a whole-view action is allowed to reach
Handing the server a filter set instead of a path list moves the enumeration behind the API, which is what makes it cheap; it also means the bounds have to live there.
The filter-scoped bodies added to the batch writes, POST /api/cull/apply and POST /api/export/sidecars resolved their rows without the album access check every read path already performs, so a body carrying filters: {"album_id": N} acted on an album whose own GET answers 403 or 404 — favouriting, rejecting, rating, moving or trashing another user's photos on a multi-user install. Building the scope and authorising it are now a single step inside the one helper all four call sites share, so a fifth caller cannot forget it.
Sending both paths and filters was resolved silently in paths' favour, dropping the whole-view scope without a word on endpoints that move and trash files; it is now a 422, which is the status the batch endpoints already gave it. Naming neither target is still a 400. exclude is deliberately no part of that refusal, because it can only ever narrow what a destructive action reaches — but until this release it only did so on the filters branch: against an explicit paths list the named exceptions were moved, trashed, exported or rewritten anyway. exclude now narrows whichever target is sent, an empty list stays a no-op, and the multi-frame expansion can no longer re-admit an excluded frame — a bracket rung or panorama tile sharing a set with a frame that did match is left out of the files acted on and out of the reported sibling count when the request excluded it.
Three caps make the whole-view forms bounded rather than merely fast. GET /api/photos/paths materialised, validated and serialised every matching path with no limit and inside the event loop, and on the shipped open install it answers anonymously — so one "select the whole view, Copy filenames" on an unfiltered 100 000-photo library was enough to stall the server for everyone. It is capped at 10 000 paths, and a larger view is refused with a 412 naming both the count and the cap rather than returning a partial list that looks complete; Copy filenames, Download and Add to album now say the view is too large and to narrow the filters. Sidecar export from filters is capped at 10 000 as well, counted before any path list is built, because each photo costs two exiftool spawns on a synchronous endpoint and a library-wide export held one FastAPI worker for hours. POST /api/cull/apply from filters gets the same 10 000 — as its own constant, not shared with the export one: they agree today only because both request forms bind paths there, while one shutil.move and two exiftool processes are different enough that retuning one should not silently retune the other. That cap also ends a smaller absurdity, since the resolution happened before the dry_run branch and a harmless preview therefore cost exactly as much as applying the thing.
On the client side, "Keep top N%" hands out up to 5 000 paths to reject while the batch endpoints bind at most 1 000 per request, so any cut larger than a thousand photos was rejected outright, the optimistic change reverted, and the only feedback was "action failed" — on precisely the large library the workflow exists for. A path-scoped batch now goes out as as many 1 000-path requests as it takes, summing the server's own counts; if a later request fails, only the photos whose request never landed are reverted and the message says how many did change instead of reporting a blanket failure over a half-applied write. Whole-view batches still go out as one filter body.
Two whole-view actions were acting without asking: Copy filenames joined every path in the view into a single clipboard string on the UI thread, and Add to album posted the same list into a per-row write, while the identically shaped Download had been confirming past its own threshold all along. Both now raise that confirmation with the exact count, with copy-specific wording since copying changes nothing on disk. And the export dialog, which was given the selected count but never rendered it, now shows it the way the cull dialog does — falling back to the explicit path count, and showing none for an album export, whose size only the server knows.
Which frame the pencil is on
The grid has had a keyboard cursor since rate-and-advance arrived: arrow keys move it, and 1-5, 0/X and F act on whatever it is standing on. The only thing it did with that position was call focus() on a card and lean on the browser's own focus ring — and Chrome withholds :focus-visible from programmatic focus that follows a mouse interaction, so in practice the cursor was invisible. At best it flickered past, which is not something you can aim a destructive keystroke with. (#131)
The one marker that is drawn from state, the 2px selection ring, showed what not to copy: drawn at the tile's own edge against a photograph, its straight runs vanish into skin tones and blurred backgrounds and only the four corner arcs survive, where the ring curves off the image onto the page. A thicker line would have been the same losing bet at a larger size. So the current photo is marked in two ways that never ask the photograph's pixels for contrast: every card that is not current drops to 50% opacity, and the current one is framed by a 4px outline in --mat-sys-tertiary, offset 2px outward so the frame sits in the 8px gutter between cards rather than on the image. Tertiary contrasts with the primary the selection ring uses in every theme this project ships, because a card can be selected and current at once and those say different things — selection is the set the batch actions operate on, current is the single photo the next keystroke changes. Nothing dims until the cursor is genuinely standing on a photo in the results, so a gallery nobody has navigated and one whose cursor a narrower filter has left pointing past the end both render at full strength instead of as a wall of faded tiles.
The second half is what makes the first half honest: clicking a card now moves the cursor onto it. It never did, so a rating typed after a click was applied to whatever the arrow keys had last been on — the wrong photo, with nothing to indicate it. Drawing the marker from state brings two obligations, both met. It must not outlive the keyboard it points at, so clearing the selection hands focus back to the marked card from the Clear button and from every batch action alike; Escape is left alone, since it comes from inside the grid and is already standing on the card it would be sent to. And it must be visible once the cursor arrives: cards are scrolled with block: 'nearest', which stops at the edge of the scrolling region and knows nothing about the action bar fixed across the bottom of it, so arrowing downward used to tuck the newly marked photo and its star badge underneath. Every card now reserves 5rem of scroll margin below itself — enough for that bar at its tallest, 61px on wide layouts, plus the 6px the frame occupies outside the card — and 0.5rem above for the same 6px on the way back up. The mosaic rows also repeated, verbatim, the content-visibility hint each card already declares, and the paint containment that comes with it clipped the frame off at every row's top and bottom edge; the rows no longer carry it.
The enlargement and the sheet agree
Rating a photo in the detail view, favouriting it, rejecting it, overriding its category or clearing that override all wrote the new value into the lightbox's own copy of the photo and nowhere else. The grid renders from the gallery store's list, which nothing had told, so closing the lightbox put the old star count, flag or category straight back on the tile — the edit looked discarded even though the server had accepted it, and a reload would have shown it. Rating, favouriting and rejecting from a grid card were never affected, because those go through the store and it patches its own list; only the detail view kept a second copy that could disagree. (#132)
All five now write the fields the server confirmed into the store, from the same partial that updates the local copy. That matters most where the server couples fields: rejecting a photo also clears its stars and its favourite flag and the response says so, so patching the reject flag alone would have left the tile wearing a rating the photo no longer has. A field the response reports as untouched is left out of the patch rather than overwritten with the lightbox's belief about it, and nothing is written until the server has confirmed it, so a failed call leaves both views exactly as they were with no state to roll back.
Captions were the two handlers the same write-back had stopped short of, and they behaved the same way — an edited or generated caption stayed in the tooltip at its old value. Both now write the confirmed caption into the store and drop the stale translation alongside it, as the server does. Generating a caption gains a guard the store write is what makes necessary: the request is slow enough to outlive the photo that started it, and its result used to be applied to whatever photo was open when it landed — a mistake the lightbox discarded on the next navigation, and one that would now reach a tile and stay there. A response whose photo is no longer open is dropped.
Seven languages
The viewer, the documentation and the landing page now ship in seven languages: English, French, German, Italian, Spanish, Portuguese and Simplified Chinese. The zh bundle covers every string in the English one and uses photography and culling terminology throughout — bursts, exposure bracketing, focus peaking, shallow depth of field, keep and reject decisions, RAW and XMP sidecars — keeping the three cull outcomes distinct, since marking a photo rejected, moving it to a folder and sending it to the system trash are three different things Facet does not conflate. Registering the code was the whole wiring, because i18n.LANGUAGES is the single source of truth the API and the switcher both derive from; browser locales resolve on their primary subtag, so zh-CN selects it automatically, as do zh-TW and zh-HK, which get Simplified for now. docs/zh/, README.zh.md and the landing page carry it too, so this is a full seventh language rather than a UI-only one. (#129)
Containers keep your config, and can finally trash
A config bind-mounted from the host for editing had its ownership reset on every restart. Under rootless Podman, or Docker with a user namespace, docker-entrypoint.sh unconditionally chown'd and chmod 600'd /config/scoring_config.json at every start, including a file that already existed, so your config became owned by a uid inside the container's namespace and you lost the ability to edit your own file without root. Both now apply only to a file the entrypoint actually seeds. The one exception is deliberate and loud: a pre-existing config the container's facet user cannot read is still taken over, with a line on stderr saying so, because an unreadable config locks every route and leaves no UI to fix it from — the remedy is chmod o+r, not handing the file over. It follows that a config an older image seeded 0644 is no longer tightened to 0600 on restart; Facet warns about that mode at boot instead, which is the policy this project committed to in 1.14.3. Facet's own config writes preserve the file's identity as well: each tries to adopt the destination's existing owner and rename over it, and rewrites in place — same inode, same owner, same mode — when that owner cannot be adopted. That also reaches a deployment the rename-only writer never could, since a scoring_config.json bind-mounted as a single file cannot be renamed over: the boot-time share_secret eviction used to give up and ask you to delete the key by hand. (#127)
Turning on viewer.cull.allow_trash did not make "Trash rejects" work in any published or self-built image. send2trash was commented out in requirements.txt, absent from both lock files — the only two the Dockerfile installs from — and reachable only through a [cull] extra that even .[all] did not pull in, so the action raised send2trash is not installed on the same request the config had just said was permitted. It is now a base dependency, pinned in both lock files, the redundant extra is gone, and CI fails the build if the pin is ever dropped again. The dialog could not tell the two failure modes apart from a working option either, hardcoding all three cull actions regardless of config or package, so "Trash rejects" looked identical whether it would 403 or 400 until you clicked it. GET /api/config now returns a cull object with allow_trash and trash_available, and the dialog omits the radio unless trashing can actually succeed, naming which of the two reasons applies where the radio would have been — so "an administrator disabled this" is distinguishable from "this server is missing the package" without reading a server log. The endpoint that enforces the key and the one that reports it also read it two different ways, so the string "false" had the API announce trashing was off while the destructive endpoint went ahead, and a null made GET /api/config answer 500 and took the gallery's startup call down with it; both now read it through one helper and the schema types it as a boolean. No permanent-delete action was added: move_rejects already gets files out of the library into a folder you choose, container-safely, and covers the same case. (#125)
Also fixed
- In French, the camera capsule was titled
{caméra}instead of "Canon EOS R5". An accent-restoration pass overfr.jsonhad accented an interpolation token as well as the prose, and the client substitutes by exact token name, so the accented form never matched and rendered literally — and that string is the entire title. Both tokens are back to{camera}; the visible French wording is unchanged, because neither string contains any prose. - The i18n gate that should have caught it ran in CI and passed, because it compared key paths and a corrupted token lives inside a value. Tokens are now compared per string, and two structural gaps close with it: a language registered in
i18n.LANGUAGESwith no readable bundle, and a bundle file no language registers. The first is worth gating because it fails quietly — a missing bundle answers with an empty object and HTTP 200, and the client renders an absent key as its own dot-path, so the install looks fine while servinggallery.selection.countas user-visible text. - The Docker entrypoint test suite wrote to the host when it happened to run as root. It only assumed it ran unprivileged, so under
sudo pytestthe script's root branch created/app/data,/app/storage,/configand/home/facet/*on the real machine, and all four tests then failed at the absentgosuinstead of reporting anything about the config seeding they exist to check. It now skips under uid 0 like its sibling suites, and the docstrings that claimed the ownership-takeover branch was covered somewhere now say plainly that it is not. - Dependency maintenance: the Angular packages, the npm minor/patch groups,
fast-uri, and a 27-package Python minor/patch group were all bumped this cycle, covered by the existing suites.
Thanks
- @quanfanpro-code — the complete Simplified Chinese viewer translation in #129, which is what makes seven languages a real number rather than a registry entry.
- @achase90 — reported #131 and #132 and then fixed both: #133 gave the rating keys a visible cursor and made a click move it, and #134 wrote detail-view edits back to the gallery store.
- @jarppiko — three reports, each a real bug and each with a precise reproduction: #125 (
send2trash is not installedin Docker), #126 ("Select all" only selecting the loaded photos) and #127 (config ownership reset under rootless Podman).
Upgrade notes
- Nothing to do for most installs. No configuration key was renamed or removed.
- Docker and rootless Podman: an existing
/config/scoring_config.jsonkeeps its owner and its mode from now on, and a file an older image seeded0644is no longer tightened to0600on restart — Facet warns about the mode at boot instead. If your file is0600and owned by a uid the container'sfacetuser is not, Facet can neither adopt it nor open it for writing, so follow the Container file ownership recipe before expecting config writes from the UI to stick. send2trashis now a base dependency. A self-built image or a venv install picks it up on the nextpip install -r requirements.txt. "Trash rejects" appears in the cull dialog only whenviewer.cull.allow_trashis true and the package imports; if you deliberately do not want it, leaving the key false hides the option as before.- Any script that calls the batch endpoints,
POST /api/cull/applyorPOST /api/export/sidecarswith bothpathsandfiltersnow gets a422instead of having thefiltershalf silently ignored. Send one or the other;excludemay accompany either.
Still unverified
- The whole-view flows were exercised end to end in a real browser against a scratch library of 10 560 photos, not against a 100 000-photo one. The "stall the server" scenario is bounded by the cap, not measured at scale.
- A rootless Podman start with a bind-mounted
0600config was not re-run for this release; the entrypoint's behaviour is covered by the unit suite and by the recipe in the documentation. - macOS was not exercised at all, and Windows only by CI's test suite. The system-trash path through
send2trashwas tested on Linux only. - The Simplified Chinese documentation was checked for structural parity with the English — headings, code fences, links — by script, not read by a native speaker. The UI bundle is the contributor's.
- The Angular and Python dependency bumps are covered only by the existing test suites.