100 Commits
Author SHA1 Message Date
chris.giteaandClaude Opus 5.5 ded9907bcb perf(ui): weave @FxThread at build time, keep the agent as a safety net
The load-time Byte Buddy agent cost ~420 ms on every launch before the
splash screen could show. FxThreadPlugin now weaves @FxThread at build time
(byte-buddy-maven-plugin, process-classes) with the same inlined advice and
method matcher as the agent, and marks each woven class @FxThreadWoven.

FxThreadAgent.install() only attaches when the application's classes run
from a directory (an IDE run), where an incremental compiler may have
overwritten woven classes, and then weaves only the classes without the
marker, so nothing is woven twice; -Dpholio.fxthread.agent=always|never
overrides the choice. A Maven-built jar starts with no agent: the splash's
first paint moves from ~0.83 s to ~0.47 s.

The measurements behind this are in .claude/plans/startup-time-analysis.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 18:10:48 -04:00
chris.giteaandClaude Opus 5.5 bfa8a523f4 feat(ui): show a splash screen while the desktop interface starts
SplashPreloader is a JavaFX Preloader, registered by GuiBootstrap through
javafx.preloader, so it only exists on the --ui path and shows as soon as
the toolkit is up, before the Spring context is built. It displays the
splash image with the application name, the build version read from
META-INF/build-info.properties, a status line and a progress bar, and fades
out once the main window is actually on screen.

StartupProgressReporter, a BeanPostProcessor added to the context before it
refreshes, turns the context's construction into real progress (beans
initialised over bean definitions) and status lines following the measured
phases: opening the library, loading components, preparing the interface.

The template's placeholder custom.property is dropped from build-info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:49:28 -04:00
chris.giteaandClaude Opus 5.5 d3d942eeaf fix(ui): stack modal dialogs instead of replacing the open one
Opening the credits from the preferences dialog disposed SettingsView,
unwiring its buttons, but left its window open and modal over the shell:
once the credits were closed, nothing could close the preferences any more,
not even quitting the application.

ModalService now keeps a stack of open dialogs. A dialog shown while another
is open is owned by and centred over it, and the one below stays alive;
hide() closes only the topmost and hands control back. StageManager closes
them all at shutdown through the new hideAll(). ModalServiceTest reproduces
the preferences-then-credits sequence with real stages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:35:20 -04:00
chris.giteaandClaude Opus 5.5 06e396b153 feat(credits): render the credits from a single markdown file
src/main/resources/userguide/credits.md now lists every shipped library
with its license (read from the dependencies' POMs), the recognition models
and the map and place data. CreditsView, opened from the ? in Settings,
renders it through the new MarkdownPage helper (per-language loading, links
opened in the browser), which the search help popup now shares, and the
release's distrib step copies it to distrib/CREDITS.md in the same commit
as the release note.

MarkdownToBBCode learns [text](url) links, inside bold too. URLs are quoted
in the generated BBCode: AtlantaFX's parser reads a tag ending in "/]" as
self-closing, so a URL with a trailing slash broke the whole page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:27:51 -04:00
chris.giteaandClaude Opus 5.5 4e450e88ae feat(tools): add a Wikimedia Commons demo library downloader
tools/demo/fetch-commons-photos.py builds a screenshot library from
Wikimedia Commons: geosearch around ten cities (a ring of nine points, since
each query returns at most 500 files), then only freely licensed JPEG
originals whose own EXIF has a capture date and GPS within range, skipping
placeholder "city centre" positions, at most two per author per city, with
an optional filter on Commons' Quality/Featured pictures. Writes CREDITS.md
and credits.csv, resumes interrupted runs, standard library only. Listed in
the README's useful scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:55:39 -04:00
chris.gitea 1e2e205ef4 chore(release): 2026.9.0 2026-09-30 14:26:17 -04:00
chris.giteaandClaude Opus 5.5 572016c5e0 build: drop unused libraries from the executable jar
The repackaged jar no longer ships the OpenJFX jars (every target runtime
provides JavaFX as modules, and they only held the build machine's
natives), Jilt (compile-time annotations, like Lombok) or the JUnit 6 and
AssertJ that devtoolsfx-connector wrongly declares at compile scope:
107 -> 89 libraries, 236.5 -> 223.4 MB. The POM version is reset to 0.0.1.

The README documents what takes space in the jar, and that the desktop
interface needs --ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:12:55 -04:00
chris.giteaandClaude Opus 5.5 ce318af010 refactor(cli): make headless the explicit default launch mode
Pholio only opens its window with --ui; without it, it runs the given
command or prints its usage. That was already the behavior, but the help
text, UiModeOptions' explicitChoice() and the LaunchOptionsTest names all
claimed the opposite. UiModeOptions now exposes uiRequested(), LaunchOptions
drops the unreachable "no flag means UI" branch, the help says "Runs
headless by default; pass --ui", and the tests are renamed to what they
assert, plus one checking that framework arguments next to --ui still open
the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:12:55 -04:00
chris.giteaandClaude Opus 5.5 844177638a docs: add a README with an overview, dev options and the release command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 13:31:24 -04:00
chris.giteaandClaude Opus 5.5 223a49f77d feat(release): group the release note by type, then by scope
Sections are now the commit types (features, fixes, ...) with one sub-section
per scope, marked with a label icon instead of the puzzle piece.
ReleaseNotesTest, AGENTS.md and the release profile's comment follow the new
order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 13:25:51 -04:00
chris.gitea fb0232b63c chore(release): 2026.9.1 2026-09-30 13:19:33 -04:00
chris.giteaandClaude Opus 5.5 f75d784bd0 feat(release): generate a release note and publish it with the release
The release profile gains two steps. notes writes
distrib/release_note/release-note-<version>.md from the Conventional
Commits since the previous v<version> tag (last 30 commits for the first
release), grouped by scope then type, duplicate first lines merged, with an
icon per type. publish uses that note as the GitHub release description and
attaches it; after the local tag, distrib commits the note in the distrib
worktree and pushes it. Covered by ReleaseNotesTest.

pom.xml is committed at 2026.9.0, so the next release is 2026.9.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 12:42:02 -04:00
chris.giteaandClaude Opus 5.5 445bf0f622 test: align geo initializer and legacy gallery tests with current behavior
GeoReferenceDataInitializerTest called run() directly to check the
"already installed" skip, which now lives in shouldRun(); assert on
shouldRun() instead, in both directions.

ThumbnailGalleryPaneTest's mid-rechunk selection test failed only in the
full suite: when the throttled re-chunk fired before the second click, the
captured card was detached and never refreshed. Assert on every card
currently attached instead, and on the captured one only while attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 12:06:43 -04:00
chris.giteaandClaude Opus 5.5 18aa555037 feat(release): add a maven release profile with YYYY.M.N versions
./mvnw -Prelease validate bumps the project version (same month: build
number + 1, otherwise build 0 of the current month), runs a fresh
./mvnw verify, publishes pholio-<version>.jar and its SHA-256 as GitHub
release v<version> in Imag-In/Pholio through gh, then commits pom.xml and
tags v<version> locally. The non-Maven steps live in
tools/release/Release.java, a JDK-only single-file program; the version rule
is covered by ReleaseVersionTest.

AGENTS.md documents the new release flow and that source never goes to
GitHub (orphan distrib worktree, pre-push guard).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 11:58:04 -04:00
chris.giteaandClaude Opus 5.5 2f9ab82b07 chore(git): ignore the nested distrib worktree
distrib/ is a second checkout of this repository on the distrib branch
(git worktree add -b distrib distrib main); its .git file would otherwise
show up as an untracked embedded repository on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 11:23:15 -04:00
chris.gitea 236db6796f chore(distrib): update jdeploy title. 2026-09-30 11:18:56 -04:00
chris.giteaandClaude Opus 5.5 cfe30dca4a style(build): commit the pom.xml layout sortpom produces
sortpom rewrote pom.xml on every package (property order, schemaLocation
indentation, a closing </arg> tag), leaving the working tree dirty after
each build. Committing its output once keeps builds from modifying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:36:46 -04:00
chris.giteaandClaude Opus 5.5 af651f415b fix(packaging): pass jDeploy launch arguments as separate entries
"-Djdeploy.file.limit=8192 --ui" was a single args entry, so the launcher
handed it to main as one unknown argument; LaunchOptions read it as a
subcommand and started headless, printing the usage and exiting with no
window. Each argument now has its own entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:33:53 -04:00
chris.giteaandClaude Opus 5.5 369ba04d80 feat(packaging): fit the app icon to the macOS icon grid
The full-bleed logo looked bigger than every other Dock icon. Add
tools/icons/make-macos-icon.py, which centres an 832 px rounded square on a
transparent 1024 px canvas and writes the result as jDeploy's icon.png, as
src/main/resources/images/spo-macos-1024x1024.png (the Dock icon set at
runtime) and as a multi-resolution src/main/packaging/macos/pholio.icns.

Also name the unbundled macOS process "Pholio" through LaunchServices
(MacOsProcessName, via the Foreign Function API) and log why the Dock icon
is skipped instead of returning silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:20:52 -04:00
chris.giteaandClaude Opus 5.5 94145f033f feat(gallery): read the all-metadata dialog straight from the file
The details dialog only showed the database copy of MediaMetadata.raw().
Add MetadataReader.readAll, returning (group, name, value) entries for
every tag of every directory, each XMP property and directory errors, and
expose it as MediaAnalysisService.readAllMetadata.

GalleryView runs that read on the METADATA pool as a silent tracked task;
the dialog opens at once with a spinner over the disabled table, then shows
the entries in a group/name/value table or the read error. Closing it
cancels the read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:43:55 -04:00
chris.giteaandClaude Opus 5.5 6000820aaa docs(search): explain that named people are found through synced tags
The search guide and the TagContainsFilter/SearchQueryParser javadocs still
claimed there was no person model, so p: only found names typed in as tags.
Naming a person already syncs their name as a tag on every file their face
appears on (PersonTagSyncService), which is what p: and plain words match.
Also point PersonTagSyncService's javadoc at its actual caller,
PersonManagementService, instead of a future panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:32:01 -04:00
chris.giteaandClaude Opus 5.5 9792599948 feat(search): add a search syntax guide below the search bar
Document the search bar syntax (free text, r:, p:, path:, combining) as a
user guide in src/main/resources/userguide, in English and French. A new
help icon left of the magnifier toggles SearchHelpPopup, which renders the
page for the current language through MarkdownToBBCode and AtlantaFX's
BBCodeParser and hangs flush below the field as its downward extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:29:46 -04:00
chris.giteaandClaude Opus 5.5 53003cc2de fix(search): drop the quotes around a quoted free-text phrase
A bare quoted phrase such as "New York" was kept as a free-text term with
its quotes, which no file name, tag or place ever contains, so it matched
nothing. It is now one term without its quotes; a malformed prefixed token
still keeps its original text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:29:46 -04:00
chris.giteaandClaude Opus 5.5 77eced1f6d feat(geocoding): save only the locality as a photo's place name
Add PlaceMatch.locality and placeName(), which the location dialog now
stores instead of the full address. The local table fills it with the city
name; LocationIQ requests always ask for addressdetails=1&normalizecity=1
and read it from the address breakdown: village if present, otherwise
"suburb, city" (or whichever of the two exists).

Persist the locality in recent_location (V17) so a place re-picked from the
history saves the same locality. V18 backfills pre-existing local rows and
drops web-provider rows whose locality cannot be recovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:13:24 -04:00
chris.giteaandClaude Opus 5.5 5d4d94cbba feat(gallery): smooth isolated wheel notches and widen mid-row date gap
Glide a lone mouse-wheel notch (one arriving more than 200 ms after the
previous direct scroll) over a short decaying animation instead of jumping,
and defer the edge-bounce check until that glide has settled. A mouse press
or a new momentum scroll stops the glide.

Triple DATE_SPACING (16 -> 48 px) so a date boundary inside a shared row
reads as a clear seam; the date header columns derive their offsets from the
same constant and stay aligned with the thumbnails below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-29 22:55:31 -04:00
chris.giteaandClaude Opus 5.5 19f299a575 feat(gallery): make the grid-mode media info overlay slightly translucent
Move the panel surface onto a dedicated backdrop layer behind its content,
since JavaFX CSS cannot add alpha to a looked-up color. The grid overlay
lowers only that layer's opacity, so thumbnails show through faintly while
text and icons stay opaque; the detail-mode panel stays fully opaque.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-29 22:52:01 -04:00
chris.giteaandClaude Sonnet 5 d5b90e37e6 feat(gallery): bring the action-selection checkbox and date actions menu back
Reintroduces legacy.GalleryGridCell's grouped-selection UI on top of
JustifiedGalleryPane's own architecture: a hover-revealed circle
checkbox in each thumbnail's top-left corner (click to check —
Google Photos-style shrink-in-place with a rounded, inset thumbnail
and a grey background ring, all CSS-driven via :checked); a per-date
"select all" icon that slides in from the left of the date label on
header hover, reflecting whether every photo of that day is checked;
and a per-date kebab menu (regenerate thumbnails / set GPS / set
rating), visible once at least one of that day's photos is checked.

JustifiedGalleryPane.actionSelection moves from a bare Set<Long> to
a Map<Long, BooleanProperty> (the same shape legacy.ThumbnailGalleryPane
.checkedByFile always used) — needed so the checkbox icon and the
header's own all-checked glyph/menu visibility can react live instead
of being pushed to by hand. filesForDay/actionSelectedFilesForDay/
actionSelectedPropertiesForDay/toggleActionSelectedForDay round out
the per-date half of that state; setOnRegenerateThumbnails/
setOnSetGpsForFiles/setOnSetRatingForFiles are new hooks GalleryView
wires to the same MediaAnalysisService/MediaMetadataEditService calls
legacy's own menu used.

Driven by CSS's own :checked pseudo-class this time, not a plain-Java
node mutation the way navigation selection is — safe now that the
underlying AtlantaFX/Prism rendering bug that pushed navigation
selection away from CSS (GuiBootstrap's prism.dirtyopts=false) is
fixed application-wide, not just for that one node. The two
selections stay fully independent: the navigation frame is always a
card's topmost child, so it never ends up occluded by the checkbox
or the checked-shrink background ring underneath it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 23:27:10 -04:00
chris.giteaandClaude Sonnet 5 bf6b501116 refactor(gallery): let the parent layout own all inter-card spacing
JustifiedGridCell.BORDER_BLEED shrank each card's image away from
its own edges on every side, and since JustifiedGalleryPane.SPACING
was 0, that per-card inset was actually the only thing producing any
visible gap between thumbnails — spacing was split between the
parent layout (nominally) and a card-internal padding (in practice).

SPACING is now 6 and the sole source of gap, both across a row
(HBox spacing) and between rows (ListCell top padding) — a card has
no padding/inset of its own any more, its ImageView fills the card's
bounds edge to edge. The navigation-selection frame follows: it now
paints directly over the ImageView's outer edge (SELECTION_FRAME_WIDTH,
renamed from BORDER_BLEED, is a purely visual stroke width with no
tie to layout spacing any more) rather than sitting in a margin that
no longer exists.

Also fixed a stale resizeCard javadoc left over from the width-
stretching mechanism removed in an earlier commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 22:55:55 -04:00
chris.giteaandClaude Sonnet 5 b6c9ab8a3f feat(gallery): replace ThumbnailGalleryPane with a justified grid (JustifiedGalleryPane)
New component (JustifiedRow, JustifiedGridItem, JustifiedGridCell,
JustifiedGalleryPane) replaces ThumbnailGalleryPane as the pane
GalleryView instantiates. Single navigation selection only (0 or 1
file, driven uniformly by click and arrow-key browsing) plus a
separate, independent action-selection set (0..N files, keyed by id,
for a future grouped-action trigger not wired up yet) — the old
per-file checkbox/grouped-actions machinery is not ported.

Layout: JustifiedRow.justify groups files into fixed-height rows
(GalleryRow.chunk's own day-merging rule, ported as-is), each entry
rendered at exactly its own natural aspect-ratio width — never
stretched to fill the row, and never squeezed via a growable gap
either (both were tried and reverted: image distortion and an
uneven, growing gap between thumbnails were both worse than a row
occasionally falling short of the container's full width). The one
exception is a single file alone in its row already wider than
availableWidth (an extreme panorama), clamped narrower — cropped,
never stretched. JustifiedGridItem.itemize splits date headers into
their own fixed-height grid item, ahead of the row they label,
keeping exactly two fixed item heights for the ListView (never a
third, row-dependent one) — the same VirtualFlow cell-recycling
desync class of bug legacy.GalleryGridItem's own javadoc documents.

Selection frame: JustifiedGridCell draws navigation selection as a
plain Rectangle added to/removed from its card's children — never a
CSS pseudo-class or Node.visible — after both of those were confirmed
not to reliably repaint a virtualised cell's descendant under
AtlantaFX (see the GuiBootstrap fix below for the actual root cause).

GuiBootstrap now also sets prism.dirtyopts=false before the JavaFX
toolkit starts, working around a genuine Prism dirty-region
computation bug (confirmed against JavaFX 27): a changed pixel region
inside a virtualised ListCell descendant can go unpainted until an
unrelated pointer event forces a larger repaint elsewhere. Isolated
by ruling out every other layer in turn — reproduces regardless of
-Dprism.order (software or hardware rasteriser), disappears entirely
under JavaFX's own near-empty default stylesheet, and the pulse
logger (-Djavafx.pulseLogger=true) named the responsible phase
directly ("Dirty Opts Computed"). Cost measured on a 67 000+ file
library: average paint time per pulse rises from 7.15ms to 10.26ms,
frames missing a 16ms/60Hz budget from 1.3% to 1.8% — real but
imperceptible in manual scroll testing. GuiBootstrapTest guards the
property-setting code itself (a real visual regression test for the
underlying Prism bug is impractical in a headless unit test).

ThumbnailGalleryPane/GalleryGridCell/GalleryRow/GalleryGridItem move
to a new legacy package, package declarations only, zero functional
change (diffed against HEAD to confirm) — kept because they still
carry the grouped-actions mechanism this component doesn't yet have.
GalleryTimelineBar widens to public (used by both packages now, no
duplication, zero behaviour change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 22:34:20 -04:00
chris.giteaandClaude Sonnet 5 bc696f9cdc refactor(gallery): render date headers as their own fixed-height grid item
Root fix for the persistent :selected staleness bug: a date's header
used to be extra, taller content optionally rendered inside that
date's own first content row's cell (GalleryRowCell.renderPhotos), so
row height varied depending on whether a row happened to start a
date. On a virtualised ListView with no fixedCellSize, that variable
height was demonstrably (via -Dpholio.skipTheme=true, confirmed to
never reproduce against JavaFX's own near-empty default stylesheet,
only ever AtlantaFX's much heavier one) part of what let VirtualFlow's
cell recycling/positioning fall out of sync with mouse picking badly
enough to leave a stale (or missing) selection highlight on a
thumbnail clicked shortly after another one in the same multi-row
date group.

GalleryRow.chunk() is untouched (still pure width-packing, markers
embedded exactly as before). New GalleryGridItem is a sealed
interface (GalleryRow | HeaderRow) with itemize(), a pure conversion
step that expands chunk()'s output into ThumbnailGalleryPane.rows'
own item sequence: one HeaderRow item (one or more date columns,
their spanWidth computed once here) immediately before whichever
GalleryRow content item it labels. Every content item is now exactly
the same height regardless of which date(s) its entries belong to;
every header item has its own different fixed height.

GalleryRowCell becomes GalleryGridCell, a ListCell<GalleryGridItem>
branching in updateItem between a content half (photosBox, unchanged
card-reuse/thumbnail logic) and a header half (headerStrip, unchanged
label/select-all/actions-menu logic) — a cell tracks which half it
currently holds and only tears down the other on an actual kind
switch, preserving the existing card-reuse optimisation for the
common content-to-content recycling case.

wireDateSelectHover's hover-reveal narrows to the header column's own
hover only (dropping the "or hovering one of its cards" half, no
longer possible now that a date's header and its cards live in
different cells) — a deliberate, minor UX narrowing, confirmed with
the user.

ThumbnailGalleryPane: rows becomes ListView<GalleryGridItem>;
verticalNeighbour/rowIndexOf skip HeaderRow items; updateTimeline/
resolveGroupLabel read HeaderRow items directly instead of scanning
every entry of every row for a marker.

New GalleryGridItemTest covers itemize() in isolation (no JavaFX),
mirroring GalleryRowTest's style. ThumbnailGalleryPaneTest's row-count
assertions gain the one extra header item per date group; the two
header-alignment tests now exercise cross-cell alignment instead of
intra-cell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:42:19 -04:00
chris.giteaandClaude Sonnet 5 8c33ae7572 feat(gallery): add GalleryGridItem, a header/content item split
First step of the fixed-row-height rework: rows.getItems() will hold
a mix of GalleryRow (thumbnails, uniform height) and a new
GalleryGridItem.HeaderRow (one or more date headers, its own uniform
height) instead of a date's header being an optional taller variant
embedded in a content row's own cell. GalleryRow.chunk() itself is
untouched; GalleryGridItem.itemize() is a new pure conversion step
that expands chunk()'s output (markers intact) into this flat item
sequence, mirroring GalleryRowCell's existing headerSpanEnd/spanWidth
scan but computed once per relayout instead of once per cell render.

Not yet wired into ThumbnailGalleryPane/GalleryRowCell — this is the
isolated, independently-testable data-model half of the rework.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:22:06 -04:00
chris.giteaandClaude Sonnet 5 5c46168446 fix(gallery): retry the selection-highlight resync across a few pulses
A single applyCss() right after pseudoClassStateChanged (previous
commit) was necessary but not sufficient: confirmed, with
-Dpholio.skipTheme=true, that the same code never shows the bug at
all against JavaFX's own near-empty default stylesheet, only against
AtlantaFX's much larger one — the CSS engine has more work to get
through on the same virtualised cell, widening the window where a
pseudo-class change sits uncommitted past a single synchronous pass.

refreshHighlight/refreshCardHighlighting now retry their own resync
across a few more Platform.runLater pulses (HIGHLIGHT_SETTLE_PULSES /
RELAYOUT_SETTLE_PULSES), the same "needs another pulse to settle"
pattern relayout() already uses for a stale thumbnail paint — closing
the window regardless of how heavy the active stylesheet is, instead
of only working by stylesheet-size coincidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:47:55 -04:00
chris.giteaandClaude Sonnet 5 e910e6c61c fix(gallery): clear stale :hover on a reused card that changed column
Two more manifestations of the same "CSS pseudo-class change doesn't
reliably repaint on a virtualised cell" class of bug reported after
the earlier selection-highlight fix, clicking left-to-right within a
row: a stuck accent glow surviving on a card the pointer had already
left, and the corner selection checkbox not reliably showing/hiding.

Root causes, both tied to card reuse across a re-render:
- A reused card landing at a different column than before has no
  guarantee JavaFX's own MOUSE_EXITED ever fires for its old spot —
  by the time picking next runs the node isn't there anymore, so
  :hover can keep reflecting stale pointer history. Cleared explicitly
  (clearStaleHoverIfMoved) whenever a card's index actually changes.
- pholio.css reveals .thumbnail-overlay-selection via a descendant
  rule keyed off the card's own :hover, not a rule on the icon itself;
  a descendant rule driven only by an ancestor's pseudo-class change
  is a known JavaFX weak spot for not cascading reliably. Forced
  explicitly now via an hoverProperty subscription that runs the
  icon's own applyCss() on every hover change, not just a moved card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:43:38 -04:00
chris.giteaandClaude Sonnet 5 788cf0fc9d fix(gallery): force an immediate CSS pass after every selection/checked pseudo-class flip
Toggling :selected/:checked only marks a card CSS-dirty; on a
virtualised ListCell that dirty flag can sit uncommitted until some
unrelated pulse or mouse move forces a CSS pass (Scene's own picking
machinery does this on hit-testing — the exact same mechanism
relayout()'s own javadoc already names for a stale thumbnail paint).
Users saw this as a border staying (or never appearing) blue across
several thumbnails clicked left-to-right, self-correcting only once
the mouse happened to hover over the stale one.

card.applyCss() right after each pseudoClassStateChanged forces that
repaint synchronously instead of leaving it to chance — applied in
GalleryRowCell's own selection/checked handling and in
ThumbnailGalleryPane's scene-wide highlight resync alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:25:10 -04:00
chris.giteaandClaude Sonnet 5 86bd4da342 fix(gallery): resync selection highlight against the live scene, not per-cell state
GalleryRowCell only refreshed :selected on its own cardsByFile, keyed
to whichever GalleryRow it currently renders. A relayout's row
membership change (e.g. the width-driven re-chunk settling after
files load before this pane is resized) can leave a since-superseded
GalleryRowCell fully attached and painted for a beat before VirtualFlow
recycles or clears it — the same "needs another pulse to settle"
staleness relayout()'s own javadoc already documents for thumbnail
painting, just surfacing here as a stuck or missing selection border
instead: a card built from that stale row never learns selection moved
on, and a freshly built card for the same file in the surviving row
can just as easily miss the highlight the first time around.

ThumbnailGalleryPane now also resyncs :selected on every actually-
attached .photo-card directly (rows.lookupAll), on every selection
change — ground truth from the live scene instead of bookkeeping that
can lag behind VirtualFlow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:16:38 -04:00
chris.giteaandClaude Sonnet 5 55c0fd71a6 refactor(gallery): move the detail-mode info panel into PhotoDetailPane
MediaInfoPane used to be a GalleryView-owned HBox sibling for detail
mode, its open state tracked separately from PhotoDetailPane's own
visibility. PhotoDetailPane now owns and docks its own MediaInfoPane
instance directly, so it can never be shown once PhotoDetailPane
itself has closed. photoStack (imageView+overlay) is now the sizing
reference for fitting/hero-transform/slide-in math, since the pane's
own width also includes the docked info panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:31:32 -04:00
chris.giteaandClaude Sonnet 5 0304b4ac41 fix(gallery): collapse detail info pane when photo detail closes
mediaInfoPaneDetail's open state tracked mediaInfoOpenDetail alone,
never photoOpen, so closing PhotoDetailPane left it sitting open as
this view's HBox sibling while mediaInfoPaneGrid reappeared over the
grid underneath — two info panels shown side by side on the right.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:19:02 -04:00
chris.gitea 5780d66a97 feat(scheduling): let a startup task's shouldRun computation pass data to run
IStartupTask becomes generic: shouldRun() now returns a
ShouldRunResult<T> carrying an optional payload instead of a plain
boolean, and run(T) receives it directly - so a task whose "should I
run" check already computed something expensive doesn't need to
recompute it inside run(). StartupTaskRunner threads shouldRun()'s
result straight into run() via Pair instead of calling shouldRun()
twice (once to filter, once again inside the old run()).

Every existing IStartupTask implementation stays Void-parameterised
for now (GeoReferenceDataInitializer, LibrarySyncTask,
MediaFileProcessingFlagsBackfillTask, MediaFaceDetectionBackfillTask,
PersonOrphanCleanupTask) - only the contract changes in this commit,
not their behavior. MaintenanceRecognitionPane's on-demand rescan()
call is updated to the new run(null) signature.
2026-09-23 21:51:30 -04:00
chris.gitea 1e5b97ea69 chore(gallery): remove temporary missing-thumbnail diagnostic logging
Confirmed no longer reproducible after the retry fix, so the [DIAG]
logging added while investigating (relayout/pulseRowsLayout tracing,
the mouse-move card-paint-state dump, and the decode-pool backlog
counter) is no longer needed.
2026-09-23 21:50:07 -04:00
chris.gitea 1a0fdfc037 fix(gallery): retry thumbnail decode when the file isn't on disk yet
ThumbnailImageCache.request never calls back when the decode returns
null, which is the right contract for a file with no thumbnail at all
but left the placeholder stuck forever for a file whose generation
simply hadn't finished writing to disk yet - confirmed via the
[DIAG] logs, no ASYNC delivery line ever appeared for the stuck file.
GalleryRowCell now retries the request every 500ms (up to 8 times)
until it lands, relying on Caffeine evicting a null-completed entry
so each retry is a genuine new decode attempt.

Also reverts an unrelated regression from the same investigation:
deliverThumbnail used to defer/drop the paint while card.getScene()
was null, on the theory the card was mid-reattachment by VirtualFlow.
That check proved unreliable even for genuinely visible cards, so it
started silently dropping real deliveries - worse than the original
bug, since nothing could make a card show content that was never
painted into it. The paint is unconditional again.
2026-09-23 21:45:26 -04:00
chris.giteaandClaude Sonnet 5 c0c21085c3 debug(gallery): log the decode-pool backlog alongside the paint-state dump
The first [DIAG] capture showed no anomaly at all — correct bounds,
FX thread, matching card, opacity 1 — which either missed the bug or
means the real desync sits below what Node's own properties can show
(retained scene graph vs. what Prism actually flushed to the window).
Before chasing that, rule out the much more mundane possibility:
ThumbnailImageCache.pendingDecodeCount() now tracks in-flight decode
requests, logged alongside dumpCardPaintState's dump — a high count
right when the bug is visible would mean a saturated THUMBNAIL pool
backlog resolving itself shortly after, coincidentally around when a
user reaches for the mouse, rather than a genuine repaint bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 21:09:12 -04:00
chris.giteaandClaude Sonnet 5 fd8d3bbf6c debug(gallery): add temporary diagnostic logging for the missing-thumbnail bug
Still reproduces after the previous fix (6abf6b2). Adding targeted,
clearly-marked [DIAG] logging around the actual suspect points, since
guessing a fourth fix blind isn't warranted on a bug this central:

- GalleryRowCell.renderThumbnail/setImageContent: cache hit/miss,
  which thread the async decode callback actually lands on, whether
  the card is still the one cardsByFile currently holds for that file
  (the staleness guard already there), and the card's own bounds/
  visibility/opacity right after content is set.
- ThumbnailGalleryPane.relayout/pulseRowsLayout: file/row counts per
  relayout, realised-cell count per retry pulse, and a full dump of
  every currently-realised photo-card's painted bounds once retries
  are exhausted — plus the same dump on the first mouse move after a
  relayout, to diff the before/after state around whatever a mouse
  move actually changes.

To remove once the real cause is found: every line is tagged [DIAG]
or "TEMPORARY DIAGNOSTIC".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 19:35:23 -04:00
chris.giteaandClaude Sonnet 5 6abf6b2d0d fix(gallery): force layout on every realised cell after a relayout
Escalates the existing pulseRowsLayout retry (from 09c58ab): calling
rows.requestLayout() only asks VirtualFlow to reconsider ITS OWN
layout — whether that walks back down into any one already-realised
GalleryRowCell is VirtualFlow's own per-cell dirty tracking to decide,
not something a request at the ListView level can force. A cell whose
position/size VirtualFlow already considers settled can sit with
content that never actually went through a real layoutChildren() pass
at all — its thumbnail decoded, but never painted, until something
else (a mouse move) forces one. That single retry was enough for a
search filter clearing back to a longer list; a folder-tree selection
swapping in an unrelated, differently sized list has kept showing the
same symptom survive it on repeated filtered navigation.

pulseRowsLayout now also calls requestLayout() directly on every
currently-realised .list-cell, marking each one's own needsLayout
regardless of VirtualFlow's own conclusion — reaching rowContainer/
photosBox/each card's ImageView on the next pulse regardless.

Not verified visually — no GUI in this environment. Please retest the
filter-navigation case this was reported against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 19:06:47 -04:00
chris.giteaandClaude Sonnet 5 fbb99c4574 fix(gallery): remove MediaInfoPane's actions bar top border
The 1px separator line between the scrolling rows and the actions
bar underneath them read as a stray/misplaced border rather than a
clean divide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 23:11:54 -04:00
chris.giteaandClaude Sonnet 5 0a5d176a47 fix(gallery): fix hashRow's SelectableTextFlow reporting a 0 preferred width
Found the real cause behind MediaInfoPane's vertical scrollbar: not a
phantom estimate at all, a genuinely too-tall row. SelectableTextFlow's
own constructor sets prefWidth to Region.USE_PREF_SIZE — a sentinel
Region's own doc says applies to minWidth/maxWidth only ("this bound
equals whatever prefWidth computes to"); Region.prefWidth(height) has
no special case for it, so it falls through its own "negative override
-> 0" branch (confirmed against javafx-graphics' own Region source).
Every layout pass reading this flow's preferred width then sees a flat
0, and the 64-character hash wraps one/two characters per line instead
of the 2-3 lines the row was actually designed around.

hashRow now resets it to the ordinary USE_COMPUTED_SIZE right after
construction, restoring real natural-width computation; the existing
minWidth(0) + HGrow.ALWAYS on the same node is what then shrinks it
back down to fit, same as originally intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 23:03:28 -04:00
chris.giteaandClaude Sonnet 5 4394d1ae3c fix(gallery): decide MediaInfoPane's vbar from real heights, not AS_NEEDED
The previous commit's viewport-width binding fed rows' own content
layout the real, final width, but AS_NEEDED's built-in vbar-needed
check turned out to run its own separate (and still stale) estimate
on top of that — the vertical phantom scrollbar survived regardless,
confirmed against empty space below the hash row, the last one built.

refreshScrollPolicy now decides the vbar itself, comparing rows'
actual already-laid-out height against the viewport's actual height —
ground truth, so no staleness is possible regardless of where exactly
upstream it was coming from. Not a feedback loop despite both listening
to the other's property: every row here is built to wrap, never to
grow shorter with less width, so showing the vbar (which narrows the
viewport) can only ever reconfirm that decision next pass, never flip
it back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:49:47 -04:00
chris.giteaandClaude Sonnet 5 017bb294a4 fix(gallery): drive MediaInfoPane's rows width from the viewport directly
The vertical phantom scrollbar survived the previous fix: fitToWidth
pins rows' width to the viewport, but its own internal vbar-needed
check runs against a width that has not necessarily settled yet —
with wrap-text labels (whose preferred height depends on the width
they wrap at), that stale width produced a too-tall estimate and the
scrollbar it decided on from it never got revisited once layout
actually settled back to empty space below the last row.

fitToWidth is now off; rows.prefWidthProperty() binds straight to
scroll.viewportBoundsProperty()'s own already-final width instead, so
every prefHeight/vbar-needed computation downstream runs against the
real width, not a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:45:54 -04:00
chris.giteaandClaude Sonnet 5 e8351cb2d5 fix(gallery): stop MediaInfoPane's horizontal and vertical scrollbars
Both traced to the same root cause: rows (the VBox scroll wraps) was
left at its default min width, which VBox computes from its widest
child — pinned wide here by hashRow's SelectableTextFlow, which by
design keeps its full unwrapped hash on one line. A Region can never
render narrower than its own min width, so fitToWidth was unable to
actually shrink rows down to the viewport, regardless of how much of
it was designed to wrap instead — hence a horizontal scrollbar
despite every row being built to shrink, and a wrong vertical
vbar-needed check riding along on that same too-wide layout.

rows.setMinWidth(0) lets it actually shrink to the viewport width
fitToWidth already asks for; hbarPolicy is now a flat NEVER, since a
fixed-width column like this one should always wrap, never scroll
sideways. That fixed the vbar phantom too, so the previous commit's
reactive height-driven policy toggle (itself a source of feedback-
loop risk between vbarPolicy and viewport width) is no longer needed
and is removed in favour of a plain AS_NEEDED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:40:48 -04:00
chris.giteaandClaude Sonnet 5 15666a6063 feat(gallery): fix MediaInfoPane's phantom scrollbar, add a details dialog
MediaInfoPane's ScrollPane kept showing a vertical scrollbar even
with empty space left below the last row: AS_NEEDED decides that off
a viewport-width estimate taken before layout settles, and with
wrap-text labels in the rows (whose preferred height depends on the
width they wrap at) that estimate regularly landed a hair over the
real viewport height. The policy is now driven directly by comparing
rows' actual height against the scroll viewport's actual height.

While there: pulled the scrolling area apart from a new bottom action
bar, docked outside it so it stays visible regardless of how many
rows fit above — currently home to one icon, opening a new "all
metadata" dialog (MediaMetadataDetailsView) listing every entry of
MediaMetadata.raw() in a plain two-column name/value table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:32:33 -04:00
chris.giteaandClaude Sonnet 5 bb6dcb1d38 feat(gallery): remember the last 10 locations picked in the GPS dialog
GeoLocationEditView now shows a last-used-first-out list of up to 10
previously picked places ahead of the live search, separated by a
row once both sides are non-empty, and re-picking an already-listed
place moves it back to the front instead of duplicating it. Recorded
on Save via the new RecentLocationService, backed by a per-library
"recent_location" table pruned to the 10 most recent on every write —
the same trim-on-write pattern SyncReportService already uses for
sync reports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 21:47:54 -04:00
chris.giteaandClaude Sonnet 5 e7ba91e63a feat(shell): remember and restore dialog sizes across restarts
ModalService now saves each dialog content's size to preferences.yaml
on close, keyed by its class name (window.dialog.<SimpleClassName>,
reusing the same window.* mechanism the main window's own geometry
uses), and reapplies it the next time that same dialog opens. The
saved size is only reused if it is still under 80% of the current
main window's width and height in both dimensions, so a size saved
against a large monitor never reopens oversized or clipped on a
smaller one — the dialog's own natural size wins in that case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 21:29:36 -04:00
chris.giteaandClaude Sonnet 5 f90ce1f9dc feat(shell): add a GemFx DialogPane modal backend behind a flag
Set -Dpholio.modalBackend=dialogPane to swap ModalService's default
Stage-based dialogs for an embedded com.dlsc.gemsfx.DialogPane, shown
as a BLANK dialog (no header/footer chrome, dimmed via DialogPane's
own glass pane instead of the Stage backend's scrim). Off by default;
kept only to make an A/B comparison against the Stage backend easy
without redoing this wiring — see ModalService's own javadoc for the
one known behavioural gap (DialogPane's scene-wide Escape handler
closes the whole dialog, ahead of any narrower Escape handling the
dialog's own content wires up).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 18:43:46 -04:00
chris.giteaandClaude Sonnet 5 87e00b96f6 fix(shell): show dialogs as real Stages instead of AtlantaFx's ModalPane
ModalPane wraps its content in a ScrollPane, which was silently
corrupting rendering and mouse hit-testing for anything nested inside
it once its contents mutated rapidly: a TextField's own caret/text
could freeze mid-edit, and clicking a ListView row could stop
registering selection (also seen, independently, in the unrelated
Preferences dialog). None of it self-healed short of a full window
resize. A plain owned Stage (transparent, window-modal, centered and
kept in sync with the dialog's own size) shows neither symptom.

ModalService's public API is unchanged, so no caller needed touching.

Also in GeoLocationEditView: coalesce in-flight geocoding searches so
fast typing/deleting against a slow remote provider never queues more
than one call at a time, and back the debounce off to 500ms to stay
under LocationIQ's own per-second rate limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 18:38:53 -04:00
chris.giteaandClaude Sonnet 5 07f8e962d0 fix(geocoding): index the local place-name prefix search
UPPER("name") LIKE UPPER(?) could never use the plain index on "name",
so every local search did a full table scan over ~170k reference
rows on TaskService's single-threaded DATABASE pool. Add a generated
name_upper column with its own index and query that instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-20 23:43:00 -04:00
chris.giteaandClaude Sonnet 5 77522de02d feat(control): add IconBadge notification-dot overlay component
Reusable FontIcon + colored corner dot (AtlantaFx ACCENT/SUCCESS/WARNING/DANGER),
sized off the icon alone so the badge never grows the icon's footprint.
Wire it into NotificationsWidget, dropping the old text count label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-20 17:50:48 -04:00
chris.giteaandClaude Sonnet 5 9e86fc3bfb chore(preferences): remove the one-time preference migration code
The app has never been deployed, so there was no installation this
was actually protecting. Removes PreferenceService's YAML-to-database
import (migrateToDbOnce, readLegacyYaml renamed back to
readWindowYaml since it now only ever serves window.*), and
GeocodingProviderRepository/RecognitionProviderRepository's
providers-json-to-table import, along with the isYamlMigrationDone/
markYamlMigrationDone pair on PreferenceValueStore and its two
implementations.

The now-unused settings_migration table is dropped via a new
V4__drop_settings_migration.sql rather than editing V1 in place --
V1 already shipped and is checksum-locked by Flyway (my own local
settings database, from testing this exact feature, already has V1
applied with real migrated data in it: a genuine LocationIQ provider
config, current theme, etc. -- editing V1 would have broken Flyway
validation against that on next launch and had nothing to do with
"not deployed"). V4 leaves preference_value/geocoding_provider/
recognition_provider entirely untouched.

Rewrote the PreferenceServiceTest cases that used to hand-edit
preferences.yaml with non-window values (ui.theme, sync.*) to expect
migration to pick them up -- those now seed the in-memory store
directly, since a legacy YAML file is no longer read for anything but
window.*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:42:45 -04:00
chris.giteaandClaude Sonnet 5 4913d297dd fix(preferences): stop rewriting preferences.yaml on every non-window change
write() persisted both sinks unconditionally on every debounced call,
mirroring the pre-split single-sink implementation. But every editable
item is now non-window (window.* is always editable: false), so the
debounced trigger watchGroup subscribes to is only ever fired by a
non-window change -- meaning a theme toggle, a spinner nudge, anything
in the settings view, was rewriting the now-window-only YAML file for
no reason. The automatic per-change write now calls the narrower
writeNonWindowValues() instead; write() (both sinks) is kept for the
explicit "flush everything" paths -- savePreferences(), resetToDefaults(),
shutdown().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:35:24 -04:00
chris.giteaandClaude Sonnet 5 75a204aff3 chore(preferences): drop the dead sync group
sync.enabled/sync.interval-minutes were declared, visible and
editable in the schema, but nothing in the codebase ever read them --
no scheduler, no LibrarySyncTask config, nothing. Removed the group
from preferences.yaml, its entry from SettingsView.GROUP_ORDER, and
the now-orphaned settings.group.sync/settings.sync.* labels from both
message bundles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:30:18 -04:00
chris.giteaandClaude Sonnet 5 082beec1b9 feat(preferences): move geocoding/recognition provider lists into real tables
geocoding.providers-json and recognition.providers-json were each a
single STRING preference holding a JSON-encoded array, rewritten whole
on every edit through GeocodingProviderEditView/
MaintenanceRecognitionPane.addProvider() -- structured, repeated data
forced into a scalar because PreferenceType had nowhere else to put
it. Replaces both with real tables (geocoding_provider,
recognition_provider) in the settings database, via plain-JDBC
repositories -- not Spring Data JDBC, for the same reason
SettingsDatabase itself avoids publishing a JdbcOperations bean.

*.active-provider stays a plain preference: it only ever names one of
these rows, so there's no "at most one active" invariant worth
enforcing in the repository.

Each repository migrates its own legacy JSON blob out of
preference_value on construction, guarded by its own sentinel,
independent of PreferenceService's own YAML-import sentinel so each
piece could in principle ship on its own schedule.
@DependsOn("preferenceService") is load-bearing here: without it nothing
guarantees that YAML-to-database migration has already populated
preference_value before a provider repository goes looking for its
row there.

PlaceSearchService/RecognitionService/GeocodingProviderEditView/
MaintenanceRecognitionPane/MaintenanceGeoView keep their exact public
method signatures (providers()/saveProviders()/activeProvider()/
saveActiveProvider()) -- only PlaceSearchService and RecognitionService
needed a new constructor dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:23:17 -04:00
chris.giteaandClaude Sonnet 5 6475397340 feat(preferences): add a run-button preference type, wire onboarding replay
New PreferenceType.ACTION: a settings-view row with no value at all,
just a button that triggers a named PreferenceAction bean
(item.getActionId(), looked up from a Map<String, PreferenceAction>
Spring assembles from every such bean in the context) -- reusing the
disable-while-running pattern MaintenanceGeoView/MaintenanceRecognitionPane
already use for their own run-now buttons, instead of inventing a new one.

First concrete user: "Replay the initial walkthrough" in the onboarding
group. StageManager.replayOnboarding() resets onboarding.completed and
re-runs the exact coach-mark steps showMainWindow already builds
(extracted into onboardingSteps() so both share it), exposed as
@Bean("onboarding.replay") -- a plain @Bean method on StageManager
itself works fine here, no dedicated @Configuration class needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:06:41 -04:00
chris.giteaandClaude Sonnet 5 960c9c860b feat(preferences): back non-window preferences with a dedicated H2 database
Preferences other than window.* were stuck in a single flat YAML file
-- fine for scalars, but the wrong shape for anything structured
(providers, run-button state to come). Adds SettingsDatabase, a
GeoReferenceDatabase-style global (never library-routed) H2 database
under dataDir()/settings, Flyway-migrated for real schema evolution.

PreferenceService now splits its two sinks: window.* still reads/
writes <configDir>/preferences.yaml exactly as before (still the
disposable file a user can just delete), everything else goes through
the new PreferenceValueStore (JdbcPreferenceValueStore in production).
A one-time, sentinel-guarded migration copies an existing
installation's non-window values out of the legacy file on first load;
AppPreferences's public API is untouched, so none of its ~80 call
sites needed to change.

PreferenceService had two constructors and neither was @Autowired --
harmless before (the public one took no args, so Spring's ambiguous-
constructor fallback to a plain no-arg instantiation just happened to
work), but broke outright once the public constructor needed a
PreferenceValueStore argument. Fixed by annotating it explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:06:26 -04:00
chris.giteaandClaude Sonnet 5 1e508c309c fix(library): rename the default-name key and fix its broken values
Moved LibraryCatalog's default-name message key next to app.name/
app.title as app.library.defaultName (grouped with the other app-level
strings, dropping the old library.defaultName). Also fixes two real
bugs the rename introduced along the way: the new key's value carried
literal quote characters ("My Photos" / "Mes Photos" instead of My
Photos / Mes Photos), which would have made it into the actual default
library name, and the leftover French library.defaultName had been
overwritten to "Doublons" -- French for "Duplicates", clearly a stray
edit given nav.tools.duplicates sits right above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:26:06 -04:00
chris.giteaandClaude Sonnet 5 40319bf7dd fix(library): default library name is now locale-aware, not the OS user name
defaultLibrary() no longer falls back to System.getProperty("user.name")
-- AppDirectories.dataDir() is already namespaced per OS user profile on
every platform, so that dedup never actually protected anything. It now
reads a friendly name ("My Photos" / "Mes photos") straight off the
messages* bundles for Locale.getDefault(), since no Spring context (and
so no I18nService) exists yet at this point in the bootstrap.

sanitize() now leaves a space untouched (safe in both a filename and the
JDBC URL) so "My Photos" survives as a real space instead of turning
into "My_Photos".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:08:42 -04:00
chris.giteaandClaude Sonnet 5 9702de0018 feat(shell): add a help/credits icon to the preferences dialog
The preferences header gets a help icon, pushed to the dialog's own
right edge via a Spacer so it reads as a separate affordance from the
tightly-grouped filter/directory/repair icons. Clicking it opens
CreditsView, a plain informational panel listing the open source
libraries the application is built on plus the OpenStreetMap/ODbL
attribution its geocoding lookups require.

Every icon in the preferences header (and the new dialog's own) now
goes through FxUtils.icon with EIconSize.MEDIUM instead of a bare
FontIcon.of(..., 14) literal, for one consistent size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:33:00 -04:00
chris.giteaandClaude Sonnet 5 6c8ff66c09 fix(gallery): retry the relayout pulse for a few more frames
09c58ab's single follow-up Platform.runLater(rows::requestLayout)
fixed the "clearing a filter back to a much longer list" case, but a
LibraryFolderTree selection swapping in a different, differently
sized item list has since shown the same missing-paint symptom
survive that one retry -- thumbnails stay blank until an unrelated
pulse (typically a mouse move) catches them up. pulseRowsLayout keeps
forcing a fresh layout pass for a few more pulses instead of giving
up after one, the same "VirtualFlow needs another pulse" retry
already used by pulseCardOnceSettled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 13:15:18 -04:00
chris.giteaandClaude Sonnet 5 eb4b700b94 style(shell): nudge Tools' nested entries back toward the left edge
Geo/Sync/Faces/Duplicates sit under the generic expandable-entry
content padding (34px), on top of their own leading column, reading
as noticeably deeper than a top-level entry like Favorites. A
translate on their wrapper (not a padding change, which would also
shift Photos'/Albums' own content) pulls just these back left a bit;
top-level entries are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 13:15:11 -04:00
chris.giteaandClaude Sonnet 5 e27e1aacdd fix(shell): select a newly added library folder in the tree
Adding a root left it in the tree but unselected -- the user had to
go find and click it themselves. addRoot now selects it right away
(onFolderSelected then jumps to Photos and filters to it, same as a
manual click), and replaceRoot carries that selection over to the
rebuilt row once the folder's scan finishes, the same way it already
carries over the expanded state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 13:15:04 -04:00
chris.giteaandClaude Sonnet 5 ed464ae73b feat(shell): resize header/sidebar icons via shared EIconSize enum
Logo now fills the header bar's full height instead of sitting fixed
next to the label, and header/sidebar action icons share one size
scale (EIconSize) applied through FxUtils.icon/iconSize.

FxUtils forces the icon size via a merged inline style rather than a
CSS class: AtlantaFX's own .ikonli-font-icon rule and FontIcon's
setIconSize both report USER_AGENT origin, so a plain size call
routinely loses that cascade tie and clamps every icon back to 18px.
A naive setStyle() replace also corrupted FontIcon's own inline
bookkeeping (it stores the icon font's family there too), which is
what showed up as icons rendering as stray math symbols.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:49:50 -04:00
chris.giteaandClaude Sonnet 5 6cb808821e feat(shell): move toast notifications to the bottom-left corner
Both ToastLayer's own VBox alignment and StageManager's outer
StackPane.setAlignment moved from top-right to bottom-left, plus a
bottom margin matching StatusDrawer's own STATUS_BAR_HEIGHT so a toast
sits above the real status bar instead of underneath it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:00:51 -04:00
chris.giteaandClaude Sonnet 5 e52bf11f15 chore: update TODO notes, reformat MediaFaceDetectionBackfillTask imports
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:56:37 -04:00
chris.giteaandClaude Sonnet 5 af31a50b4b feat(shell): split Outils into an expandable Geo/Sync/Faces/Duplicates group
MaintenanceGeocodingView was a single flat destination with four tabs
(reference geocoding data, search providers, sync history, recognition
settings). "Outils" is now expandable, like Photos/Albums, revealing four
independently navigable sidebar entries instead:

- Geo (MaintenanceGeoView): the old referenceData + providers tabs.
- Sync (MaintenanceSyncView): the old sync report tab.
- Faces (MaintenanceFacesView): wraps the existing standalone
  MaintenanceRecognitionPane in its own tab.
- Duplicates (MaintenanceDuplicatesView): three new tabs (100% Match, By
  hash, By date), all placeholder "under construction" content for now.

NavigationSidebar.toolsContent() builds the four sub-rows as plain
leafEntry()s (same active-highlight/dispose plumbing as every other
entry), nested inside MAINTENANCE_TOOLS' own expandableEntry — whose own
row now mounts the generic ModulePlaceholderView, same as Albums, since
it no longer has real content of its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:53:59 -04:00
chris.giteaandClaude Sonnet 5 5004c17fc7 refactor(task): consolidate 6 executor pools into 3 by resource profile
TaskService/ExecutorConfig had one ThreadPoolTaskExecutor per TaskType
(DATABASE, THUMBNAIL, METADATA, IMAGE_ANALYSIS, BACKGROUND_SYNC, HASH),
each with its own hand-tuned size, but the actual work only falls into
three resource profiles: a single-writer DB pool (unchanged), a CPU-bound
pool for native-codec-backed decode/hash work (THUMBNAIL+IMAGE_ANALYSIS,
still bounded platform threads to avoid oversubscribing the CPU and
native-call pinning), and an I/O-bound pool (METADATA+BACKGROUND_SYNC+HASH)
now backed by Executors.newVirtualThreadPerTaskExecutor() instead of a
guessed thread count, since none of that work touches native code.

TaskType keeps its 6 values as semantic labels; TaskService now maps
several of them onto the same underlying executor instead of injecting
six. PholioProperties.Pools drops thumbnail/metadata/image-analysis/
background-sync/hash in favour of a single cpu size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:00:45 -04:00
chris.giteaandClaude Sonnet 5 09c58aba1b fix(gallery): force a follow-up pulse after a large grid relayout
Clearing a search filter that had narrowed the grid to a handful of rows
swaps in a much longer item list in one go. Some of VirtualFlow's newly
realised GalleryRowCells were laid out (thumbnail decode requested) but
not actually painted until an unrelated later pulse caught up -- moving
the mouse only "fixed" it because Scene's picking machinery forces a
synchronous CSS+layout pass before hit-testing. Scheduling
rows.requestLayout() on a fresh Platform.runLater pulse reproduces that
same forced pass without needing real input, the same trick already used
elsewhere in this class (pulseCardOnceSettled, hookNativeScrollBar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 15:03:33 -04:00
chris.giteaandClaude Sonnet 5 05e4b0bf92 fix(gallery): re-key checked selection map by MediaFile id, not the record
checkedByFile was keyed on MediaFile itself. It's a record, and
MediaLibraryState replaces a file's entry wholesale on any
metadata/processing-flag change (same trap fixed for PhotoDetailPane in
9bcee4a). An already-rendered card's checked subscription stayed bound to
the stale instance, while toggleCheckedForDay/filesForDay re-read fresh
state.files() and created a new entry under the new instance -- so a
day-header select-all only visually checked whichever thumbnails hadn't
had their record replaced since render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:21:51 -04:00
chris.giteaandClaude Sonnet 5 9bcee4ada7 fix(gallery): PhotoDetailPane's currentFile went stale after any metadata edit
One root cause behind three visible symptoms: GalleryView.updateRating
(favorite toggle and MediaInfoPane's star control), openDateDialog and
openLocationDialog all completed by refreshing the info panes but
never told PhotoDetailPane its own currentFile had just changed.
MediaFile is a record, and MediaLibraryState replaces that file's
entry in the shared list every other view reads from -- so the stale
value left behind no longer equals the fresh one, breaking:

1. A second favorite-star press (reads the stale, pre-edit rating,
   computes the same target rating again -- can never toggle back off)
2. ThumbnailGalleryPane.previous/next (an equals-based lookup against
   the now-refreshed list; the stale file is never found in it)
3. revealAndPulse on Escape/close (same lookup, same failure -- lands
   back on the grid without scrolling to the photo that was open)

PhotoDetailPane.updateCurrentFile(MediaFile) swaps in the fresh value
(and refreshes the favorite icon) whenever it's the same file (by id)
this pane is already showing; all three completion handlers in
GalleryView now call it instead of just refreshing the info panes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 22:26:14 -04:00
chris.giteaandClaude Sonnet 5 4ae89c55e3 fix(shell): selecting a library folder now switches to Photos
Clicking a row in LibraryFolderTree already narrowed the gallery via
GallerySearchState's path: token, but never actually navigated there
-- selecting a folder while looking at another destination (Personnes
et Animaux, Maintenance, ...) silently set the filter with nothing
visible changing. Publishes NavigateToDestinationEvent(PHOTOS) on every
real selection; ViewSwitcher.mount already no-ops when Photos is
already showing, so this is free the rest of the time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 22:07:27 -04:00
chris.giteaandClaude Sonnet 5 8a302b2174 fix(gallery): consolidate open+orient a MediaFile's full image into one loader
MaintenancePeoplePane's region preview forgot EXIF orientation entirely
-- not the first time a full-image call site has, since FullImageCache
deliberately hands back raw, un-rotated pixels and leaves the rotate
step to the caller (PhotoDetailPane is the only other one, and applies
it as its own cheap node transform for resize performance).

OrientedFullImageLoader wraps FullImageCache + LibraryFolderService and
bakes the file's own Orientation into the pixels via the same,
already-proven ThumbnailGenerator.applyOrientation the thumbnail
pipeline uses (converted through SwingFXUtils), off the FX thread. Any
future one-shot (non-resizing) full-image display can go through this
instead of reimplementing -- or forgetting -- the same step.
PhotoDetailPane is intentionally left as-is: its resize-driven node
transform is the right tool for its own hot path, pixel-baking would
not be.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 21:42:25 -04:00
chris.giteaandClaude Sonnet 5 7d0838732e fix(recognition): prune an orphaned anonymous person right after discard/reassign
discardRegion and reassignRegion could each leave a person's very last
region gone -- discarding a bad detection, or moving that region onto
someone else -- but neither called the orphan-pruning path
onMediaFileRemoved/onLibraryFolderRemoved already use. An anonymous
person left with zero regions stayed in the management panel's list
forever, with nothing to show. Both methods now call the same
pruneOrphanPersons() at the end; a named person is still never pruned
this way, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 21:25:19 -04:00
chris.giteaandClaude Sonnet 5 9a09023008 fix(maintenance): cap the region preview to 80% of the shell instead of a fixed size
A fixed 900px cap read as full-screen on anything but a large monitor.
Sized relative to the current Scene (the shell's own content area)
instead: at most 80% of its width/height, image scaled down (never
up) to fit what's left after the file-name/close-button chrome.
ModalPane already centers it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 19:31:37 -04:00
chris.giteaandClaude Sonnet 5 36ace4d5de feat(maintenance): region preview opens the real photo, not a thumbnail
Requests through FullImageCache (keyed by absolute path, resolved via
LibraryFolderService.absolutePathOf) instead of ThumbnailImageCache --
PhotoDetailPane's own source of truth for the actual decoded file,
not a capped-resolution cached tier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 19:25:30 -04:00
chris.giteaandClaude Sonnet 5 f8b7da9975 fix(maintenance): region preview never opened, requested a tier never cached
ThumbnailImageCache.request never generates a thumbnail on demand --
it only reads whichever tier is already on disk. Hardcoding
EThumbnailQuality.ULTRA meant the callback silently never fired unless
the user's thumbnails.quality preference already happened to be ULTRA,
so double-clicking a chip did nothing. Uses the same configured tier
loadChipImage already relies on -- guaranteed to exist since it's what
rendered the chip itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 19:11:20 -04:00
chris.giteaandClaude Sonnet 5 4bd91d7d99 feat(maintenance): show file name below each region chip, double-click to preview
MaintenancePeoplePane's chips now show the photo's own file name below
the cropped thumbnail, and double-clicking a chip's image opens a
modal dialog with the whole source photo (scaled to fit, ULTRA
quality) and the detected region drawn as a highlighted rectangle at
its actual position.

Replaced the pane's hashByMediaFileId map with mediaFileById (whole
MediaFile, not just its hash) -- both the file name and the ULTRA-tier
preview need more than the hash alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 19:06:50 -04:00
chris.giteaandClaude Sonnet 5 ae5b29ac5a style(recognition): reformat RecognitionService, tune default detection thresholds
RecognitionService: IDE reformat only (indentation/javadoc), plus
dropping a redundant debug log line already implied by the local-only
early return.

preferences.yaml: default-value tuned from real-world testing --
min-face-size-fraction 0.1, min-detection-confidence 0.7,
min-sharpness 40 -- rather than the initial all-disabled placeholders.
MediaRecognitionServiceTest now neutralizes all three thresholds in
setUp() explicitly instead of relying on the bundled schema's own
defaults staying at "disabled", so it stays correct independently of
further tuning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 19:00:31 -04:00
chris.giteaandClaude Sonnet 5 3d28b726c4 fix(settings): accept a dot when typing a DOUBLE preference in any locale
DoubleSpinnerValueFactory's own default converter parses through a
locale-sensitive DecimalFormat("#.##"). Under a French default locale
it expects a comma, and DecimalFormat.parse doesn't throw on a dot it
can't consume -- it just stops there, so typing "0.05" silently
committed as 0. Only surfaced now: the three new recognition
thresholds are the first DOUBLE preference ever actually visible in
Settings. Replaced with a converter that accepts either separator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 18:51:52 -04:00
chris.giteaandClaude Sonnet 5 c9ef5e7d73 feat(recognition): add a force full re-analysis option next to the backlog scan
The existing "Re-scan library now" button only ever caught up files
never analyzed (MediaFaceDetectionBackfillTask.run() skips anything
already flagged FACE_DETECTED), so it had no way to re-apply a
since-changed detection threshold (face size/confidence/sharpness) to
files a previous run had already accepted regions for.

MediaFaceDetectionBackfillTask.runAll() is the same backlog logic
without that flag filter. The single scan button is now a flat
MenuButton titled "Scan" with two explicit choices: analyze files not
yet scanned (the old behaviour, clearer title), and re-analyze the
entire library (the new one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-17 18:33:11 -04:00
chris.giteaandClaude Sonnet 5 4b379d6001 feat(recognition): make face detection quality thresholds user-tunable
Faces coming back too small or too low quality had no lever to pull:
YuNet's own confidence floor was hardcoded, and no minimum face size or
sharpness check existed anywhere in the pipeline. Three new
recognition preferences (min-face-size-fraction, min-detection-confidence,
min-sharpness), visible/editable in Settings, filtered once in
MediaRecognitionService.detect() so they apply regardless of which
engine produced the region. Every default preserves current behaviour
exactly (confidence default matches YuNet's existing 0.6 floor, the
other two default to disabled) -- strictly opt-in.

Sharpness is a variance-of-Laplacian score on the face crop, resized to
a fixed canonical size first so it stays comparable across differently
sized faces -- no new dependency or model, reuses the image already
decoded for detection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 23:08:42 -04:00
chris.giteaandClaude Sonnet 5 964b5a5928 refactor(scheduling): i18n every IStartupTask's own title
getTitle() feeds straight into the visible task list (StartupTaskRunner
submits it as the task's title), so a hard-coded English string or the
default getClass().getSimpleName() fallback never respected the user's
language. GeoReferenceDataInitializer, LibrarySyncTask,
MediaFileProcessingFlagsBackfillTask and MediaFaceDetectionBackfillTask
now all resolve their title through I18nService, same as
PersonOrphanCleanupTask already did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 22:27:05 -04:00
chris.giteaandClaude Sonnet 5 1a4deabf16 feat(recognition): add startup task to sweep anonymous orphan persons
PersonOrphanCleanupTask runs at every application start and prunes any
unnamed Person left with no media_face_region at all -- catches
whatever the MediaFileRemovedEvent/LibraryFolderRemovedEvent listeners
missed (data from before this feature shipped, or a session that ended
before either event fired). shouldRun() checks
PersonManagementService.hasAnonymousOrphanPersons() so the task is
skipped entirely when there is nothing to do; its title is i18n'd
(task.startup.pruning.anonymous.persons).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 21:51:37 -04:00
chris.giteaandClaude Sonnet 5 7631a4560b fix(recognition): only auto-prune anonymous persons
A named Person is a deliberate user decision and must survive every
one of their photos disappearing (a folder re-added, a file restored
from the OS trash) -- only unnamed, automatic clusters left with no
region are swept away on MediaFileRemovedEvent/LibraryFolderRemovedEvent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 21:46:51 -04:00
chris.giteaandClaude Sonnet 5 7739243f53 fix(recognition): prune persons left with no region after files are removed
media_face_region rows already cascade-delete with their media_file
(folder removal, missing-on-disk sync, or a file moved to the OS
trash), but the Person row those regions pointed at did not -- it
stayed in the management panel forever with zero regions left.
PersonManagementService now listens for MediaFileRemovedEvent and
LibraryFolderRemovedEvent and deletes any Person no longer referenced
by any region.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 21:40:59 -04:00
chris.giteaandClaude Sonnet 5 30e9101ea7 fix(search): load tags in the gallery's in-memory media files
MediaFileService.list() never attached media_file_tag/tag rows onto
MediaMetadata (MediaMetadataMapper.toDomain always returned an empty
list), so the search bar's free-text and p: filters never matched a
tag at all -- including a Person's own name synced in by
PersonTagSyncService.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 21:22:42 -04:00
chris.giteaandClaude Sonnet 5 e861318d78 feat(shell): add search/clear icons and Escape-to-cancel to search bar
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
2026-09-16 21:17:10 -04:00
chris.gitea f739d762a9 fix(shell): align the search bar with the gallery below it
HeaderBar's centre region defaults to centring on the whole bar's own
width, regardless of how wide the leading/trailing regions actually
are — search floated visibly off from NavigationSidebar's own right
edge as a result. CENTER_LEFT switches it to fill the entire leftover
span instead, left-aligned within it; a dynamic left margin then places
it exactly at NavigationSidebar.TOTAL_WIDTH.

Margin is computed from the brand's actual bounds, not from pholio.css's
own header padding: on macOS, HeaderBar also reserves leading space of
its own for the native traffic-light buttons, which that padding alone
doesn't account for and which disappears in fullscreen.
2026-09-16 20:40:44 -04:00
chris.gitea 4b605cdbf9 fix(shell): let the folder tree grow instead of scrolling internally
A bound prefHeight is only ever a request — TreeView's own max height
stayed unconstrained, so an ancestor giving it less room than that let
its internal ScrollBar pick up the difference instead of the tree
actually growing. Pinning min/max to USE_PREF_SIZE forces it to grow,
so NavigationSidebar's own outer ScrollPane is the only one that scrolls.
2026-09-16 19:03:10 -04:00
chris.gitea 477a6f7b27 fix(shell): make the navigation sidebar scrollable vertically
A window shorter than the full destination list (Photos expanded, every
leaf entry, both section captions) left the bottom entries permanently
unreachable — nothing in the sidebar's own VBox shrank to make room.
Wrapped in a fitToWidth ScrollPane, vertical only; transparent like the
tree-view beside it so it reads as part of the same surface.
2026-09-16 18:56:53 -04:00
chris.gitea 741cf026a2 fix(shell): let a double-click expand/collapse a folder row untouched
The reclick-to-deselect press filter couldn't tell a genuine reclick
apart from a double-click's second press (the row is already selected
either way), so it consumed that second press too — eating
TreeCellBehavior's own default double-click expand/collapse and
clearing the path filter the first press had only just set.
clickCount() > 1 tells the two apart.
2026-09-16 18:52:21 -04:00
chris.gitea 828ea96385 fix(gallery): use a human-readable path: query syntax
Replaced the folder-id-based "path:<id>:<url-encoded>" scheme with
plain "path:Guerville/Grange" — the folder's own name plus whatever
subfolder was selected, quoted only when it contains a space.

Relativizing against the library folder's own root produced an empty
path (and a bare, meaningless "path:") when the root itself was
selected, since MediaFile#path() is already relative to it. The token
now carries the root's name too, so GalleryView can resolve it back to
a libraryFolderId (PathChildOfFilter itself stays a plain relative-path
predicate, unaware of any of this).

SearchQueryParser's tokenizer now also accepts a quoted value after any
prefix, single or double quotes, not just double-quoted free text.
2026-09-16 18:46:56 -04:00
chris.gitea 54ee2296b7 fix(shell): equalize navigation sidebar row heights across entries
TitledPaneSkin only adds its arrow-button node when the pane is
collapsible (Photos/Albums, never a plain leaf entry) — hiding it by
width alone still left its height counted as the title's tallest child,
stretching only those two rows past every sibling. Zeroed height too.

The destination icon (left of every title) was also silently capped to
AtlantaFX's theme default (18px) rather than whatever size was actually
requested, the same Ikonli iconSize/USER_AGENT-origin issue already
worked around for the "+" accessory icon — same fix applied here, now
rendering at a real, uniform size everywhere instead of by accident.
2026-09-16 18:27:48 -04:00
chris.gitea 006426a2fc feat(gallery): filter the grid by folder from the library tree
Clicking a FolderTreeItem narrows Photos to that folder's descendants;
re-clicking the already-selected row deselects it and clears the filter,
and picking any other sidebar destination clears it too.

New "path:<folderId>:<url-encoded relative path>" SearchQueryParser
prefix backs it, resolved via PathChildOfFilter. The folder id
disambiguates two library roots that happen to share an identical
subfolder name, since MediaFile#path() is relative to its own root and
carries no root identity of its own. AppHeaderBar now syncs the search
field both ways so the token is visible and editable, not just active
underneath.
2026-09-15 23:57:14 -04:00
chris.gitea b13e65659e style(shell): half-pill selection shape for the sidebar's active rows
Applies the same flat-left/fully-round-right radius (0 999 999 0, JavaFX
clamps the right corners to the row's own half-height) to both the
NavigationSidebar's .active title and LibraryFolderTree's selected
tree-cell, so both levels of the sidebar share one selection shape.
2026-09-15 23:12:57 -04:00