chore(clean-arc): Add nullaway.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
|
||||
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
@@ -22,7 +22,7 @@
|
||||
Extra themes for AtlantaFx, from DLSC. Pinned to a release present in the local
|
||||
repository so an offline build keeps working; Maven Central carries newer ones.
|
||||
-->
|
||||
<atlantafx-themes.version>1.5.0</atlantafx-themes.version>
|
||||
<atlantafx-themes.version>1.9.0</atlantafx-themes.version>
|
||||
|
||||
<atlantafx.version>2.1.0</atlantafx.version>
|
||||
<!--
|
||||
@@ -32,6 +32,7 @@
|
||||
reach the user.
|
||||
-->
|
||||
<devtoolsfx.version>1.0.1</devtoolsfx.version>
|
||||
<errorprone.version>2.50.0</errorprone.version>
|
||||
<ikonli.version>12.4.0</ikonli.version>
|
||||
<!--
|
||||
JavaFX 26 is compiled for Java 24 (class file major 68), so a JDK >= 24 is required at runtime
|
||||
@@ -60,7 +61,12 @@
|
||||
|
||||
<!-- caffeine, h2, flyway and jackson versions come from the Spring Boot 4.1.0 BOM. -->
|
||||
<main.class>org.icroco.pholio.PholioApplication</main.class>
|
||||
<!-- Spring Boot 4.1's BOM does not manage MapStruct, so it is pinned here directly. -->
|
||||
<mapstruct.version>1.7.0.Beta2</mapstruct.version>
|
||||
<maven-compiler-plugin.version>3.15.0</maven-compiler-plugin.version>
|
||||
<maven-enforcer-plugin.version>3.6.3</maven-enforcer-plugin.version>
|
||||
<metadata-extractor.version>2.21.0</metadata-extractor.version>
|
||||
<nullaway.version>0.14.0</nullaway.version>
|
||||
<picocli.version>4.7.7</picocli.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<sortpom-maven-plugin.version>4.0.0</sortpom-maven-plugin.version>
|
||||
@@ -107,6 +113,16 @@
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
As of Boot 4.1, Flyway's autoconfiguration (used by the test datasource, where routing is off) no
|
||||
longer ships inside spring-boot-autoconfigure — it is this separate module. The production path,
|
||||
one migration per library pool, calls org.flywaydb.core.Flyway directly and does not need it, but
|
||||
the plain single-DataSource path (tests, and any future run with routing disabled) does.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-flyway</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===================== Preferences (YAML) ======================= -->
|
||||
<!-- Jackson 3 (tools.jackson.*) — the default in Spring Boot 4.x. -->
|
||||
@@ -230,6 +246,12 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<!-- Maps entities to domain records and back; the processor is wired below, next to Lombok's. -->
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${mapstruct.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.threeten</groupId>
|
||||
<artifactId>threeten-extra</artifactId>
|
||||
@@ -293,9 +315,30 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>${maven-enforcer-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-maven</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireMavenVersion>
|
||||
<version>3.9</version>
|
||||
</requireMavenVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven-compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<!--
|
||||
Lombok has to be named here, not just declared as a dependency. Since JDK 23 javac no
|
||||
@@ -313,10 +356,35 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<!--
|
||||
Lombok and MapStruct both claim the same annotation-processing round; without this
|
||||
binding artifact MapStruct generates mappers against the class as javac sees it before
|
||||
Lombok's getters/setters/builders exist, silently emitting empty mapping methods.
|
||||
-->
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${mapstruct.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>com.google.errorprone</groupId>
|
||||
<artifactId>error_prone_core</artifactId>
|
||||
<version>${errorprone.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>com.uber.nullaway</groupId>
|
||||
<artifactId>nullaway</artifactId>
|
||||
<version>${nullaway.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
<compilerArgs>
|
||||
<!--
|
||||
@@ -327,9 +395,80 @@
|
||||
warnings stay on and are suppressed case by case.
|
||||
-->
|
||||
<arg>-Xlint:all,-serial,-processing,-this-escape</arg>
|
||||
<arg>-XDcompilePolicy=simple</arg>
|
||||
<arg>-XDshould-stop.ifError=FLOW</arg>
|
||||
<arg>-Xplugin:ErrorProne
|
||||
-Xep:NullAway:ERROR
|
||||
-XepOpt:NullAway:AnnotatedPackages=org.icroco.pholio
|
||||
-XepOpt:NullAway:CustomNullableAnnotations=org.jspecify.annotations.Nullable
|
||||
-XepOpt:NullAway:CheckOptionalEmptiness=true
|
||||
-XepOpt:NullAway:AcknowledgeRestrictiveAnnotations=true
|
||||
-XepExcludedPaths:.*/generated-sources/.*
|
||||
</arg>
|
||||
<!-- Required for Java 16+ to allow ErrorProne to access JDK internals -->
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- <plugin>-->
|
||||
<!-- <groupId>org.apache.maven.plugins</groupId>-->
|
||||
<!-- <artifactId>maven-compiler-plugin</artifactId>-->
|
||||
<!-- <version>3.13.0</version>-->
|
||||
<!-- <configuration>-->
|
||||
<!-- <source>17</source>-->
|
||||
<!-- <target>17</target>-->
|
||||
<!-- <compilerArgs>-->
|
||||
<!-- <arg>-XDcompilePolicy=simple</arg>-->
|
||||
<!-- <arg>-Xplugin:ErrorProne-->
|
||||
<!-- -Xep:NullAway:ERROR-->
|
||||
<!-- -XepOpt:NullAway:AnnotatedPackages=com.yourpackage-->
|
||||
<!-- -XepOpt:NullAway:CustomNullableAnnotations=org.jspecify.annotations.Nullable-->
|
||||
<!-- -XepOpt:NullAway:CheckOptionalEmptiness=true-->
|
||||
<!-- </arg>-->
|
||||
<!-- <!– Required for Java 16+ to allow ErrorProne to access JDK internals –>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>-->
|
||||
<!-- <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>-->
|
||||
<!-- </compilerArgs>-->
|
||||
<!-- <!– Allows -J JVM arguments to be passed directly to javac –>-->
|
||||
<!-- <fork>true</fork>-->
|
||||
<!-- <annotationProcessorPaths>-->
|
||||
<!-- <path>-->
|
||||
<!-- <groupId>org.projectlombok</groupId>-->
|
||||
<!-- <artifactId>lombok</artifactId>-->
|
||||
<!-- <version>1.18.30</version>-->
|
||||
<!-- </path>-->
|
||||
<!-- <path>-->
|
||||
<!-- <groupId>com.google.errorprone</groupId>-->
|
||||
<!-- <artifactId>error_prone_core</artifactId>-->
|
||||
<!-- <version>2.26.0</version>-->
|
||||
<!-- </path>-->
|
||||
<!-- <path>-->
|
||||
<!-- <groupId>com.uber.nullaway</groupId>-->
|
||||
<!-- <artifactId>nullaway</artifactId>-->
|
||||
<!-- <version>0.10.26</version>-->
|
||||
<!-- </path>-->
|
||||
<!-- </annotationProcessorPaths>-->
|
||||
<!-- </configuration>-->
|
||||
<!-- </plugin>-->
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
config.stopBubbling = true
|
||||
|
||||
# 1. Copy JSpecify annotations to generated fields, getters, setters, and builder parameters
|
||||
lombok.copyableAnnotations += org.jspecify.annotations.Nullable
|
||||
lombok.copyableAnnotations += org.jspecify.annotations.NonNull
|
||||
lombok.copyableAnnotations += org.jspecify.annotations.NullMarked
|
||||
|
||||
# 2. Tell Lombok to explicitly mark generated builder methods/classes as @NullMarked
|
||||
# so JSpecify rules apply inside the generated builder code
|
||||
lombok.addNullAnnotations = jspecify
|
||||
@@ -34,6 +34,7 @@ public class PholioCommand implements Callable<Integer> {
|
||||
private UiModeOptions uiMode = new UiModeOptions();
|
||||
|
||||
@Spec
|
||||
@SuppressWarnings("NullAway.Init") // picocli injects this after construction, before call()
|
||||
private CommandSpec spec;
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.icroco.pholio.cli;
|
||||
|
||||
import org.icroco.pholio.infra.media.MediaFormatRegistry;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
@@ -43,7 +44,8 @@ public class ScanCommand implements Callable<Integer> {
|
||||
arity = "0..1",
|
||||
paramLabel = "<directory>",
|
||||
description = "Directory to scan. Defaults to the configured library root.")
|
||||
private Path directory;
|
||||
@SuppressWarnings("NullAway.Init") // picocli injects this after construction, before call(); absent when arity 0
|
||||
private @Nullable Path directory;
|
||||
|
||||
/**
|
||||
* A plain opt-out flag rather than a {@code negatable = true} {@code --recursive}.
|
||||
@@ -100,7 +102,7 @@ public class ScanCommand implements Callable<Integer> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private Path resolveTarget() {
|
||||
private @Nullable Path resolveTarget() {
|
||||
if (directory != null) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.icroco.pholio.domain.library;
|
||||
|
||||
import lombok.Builder;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One user-chosen root directory of the currently open library, persisted so it survives a restart.
|
||||
*
|
||||
* <p>Not to be confused with a "library" elsewhere in this codebase ({@code LibraryCatalog}), which names a
|
||||
* whole switchable database. This is a folder inside the one currently open.
|
||||
*
|
||||
* @param id {@code null} before the row is persisted; the database assigns it on insert
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record LibraryFolder(@Nullable Long id, Path path, Instant addedAt) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.icroco.pholio.domain.library;
|
||||
|
||||
import lombok.Builder;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One directory found under a {@link LibraryFolder} root at the time it was last scanned.
|
||||
*
|
||||
* <p>Not read by anything yet — it is the foundation for a future task that diffs the filesystem against
|
||||
* this table to detect added or removed subfolders, and for filtering the gallery by folder.
|
||||
*
|
||||
* @param id {@code null} before the row is persisted; the database assigns it on insert
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record LibrarySubfolder(@Nullable Long id, Long libraryFolderId, Path path, Instant lastScannedAt) {
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.domain.media;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Where a photo was taken, in decimal degrees.
|
||||
*
|
||||
@@ -8,7 +10,7 @@ package org.icroco.pholio.domain.media;
|
||||
* @param altitude metres above sea level, {@code null} when the file records none — which is far more
|
||||
* common than a missing position, since many cameras log GPS without a barometer
|
||||
*/
|
||||
public record GeoLocation(double latitude, double longitude, Double altitude) {
|
||||
public record GeoLocation(double latitude, double longitude, @Nullable Double altitude) {
|
||||
|
||||
public GeoLocation {
|
||||
if (latitude < -90 || latitude > 90) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.domain.media;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -91,7 +93,7 @@ public enum ImageFormat {
|
||||
* <p>Answers what a name <em>means</em>, not whether the application can handle it. Ask
|
||||
* {@code MediaFormatRegistry} for that.
|
||||
*/
|
||||
public static Optional<ImageFormat> ofExtension(String extension) {
|
||||
public static Optional<ImageFormat> ofExtension(@Nullable String extension) {
|
||||
if (extension == null || extension.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.icroco.pholio.domain.media;
|
||||
|
||||
import lombok.Builder;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -27,15 +29,15 @@ public record MediaMetadata(ImageFormat format,
|
||||
int width,
|
||||
int height,
|
||||
Orientation orientation,
|
||||
LocalDateTime capturedAt,
|
||||
String cameraMake,
|
||||
String cameraModel,
|
||||
String lens,
|
||||
Integer isoSpeed,
|
||||
Double aperture,
|
||||
Double shutterSeconds,
|
||||
Double focalLength,
|
||||
GeoLocation location,
|
||||
@Nullable LocalDateTime capturedAt,
|
||||
@Nullable String cameraMake,
|
||||
@Nullable String cameraModel,
|
||||
@Nullable String lens,
|
||||
@Nullable Integer isoSpeed,
|
||||
@Nullable Double aperture,
|
||||
@Nullable Double shutterSeconds,
|
||||
@Nullable Double focalLength,
|
||||
@Nullable GeoLocation location,
|
||||
Map<String, String> raw) {
|
||||
|
||||
public MediaMetadata {
|
||||
@@ -70,23 +72,25 @@ public record MediaMetadata(ImageFormat format,
|
||||
* Make and model joined for display, empty when the file names neither.
|
||||
*/
|
||||
public Optional<String> camera() {
|
||||
if (isBlank(cameraMake) && isBlank(cameraModel)) {
|
||||
boolean makeBlank = isBlank(cameraMake);
|
||||
boolean modelBlank = isBlank(cameraModel);
|
||||
if (makeBlank && modelBlank) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (isBlank(cameraMake)) {
|
||||
return Optional.of(cameraModel.trim());
|
||||
if (makeBlank) {
|
||||
return Optional.of(Objects.requireNonNull(cameraModel).trim());
|
||||
}
|
||||
if (isBlank(cameraModel)) {
|
||||
return Optional.of(cameraMake.trim());
|
||||
if (modelBlank) {
|
||||
return Optional.of(Objects.requireNonNull(cameraMake).trim());
|
||||
}
|
||||
String make = cameraMake.trim();
|
||||
String model = cameraModel.trim();
|
||||
String make = Objects.requireNonNull(cameraMake).trim();
|
||||
String model = Objects.requireNonNull(cameraModel).trim();
|
||||
// Many bodies repeat the make inside the model ("NIKON" / "NIKON D850"); joining blindly would
|
||||
// render "NIKON NIKON D850".
|
||||
return Optional.of(model.toLowerCase().startsWith(make.toLowerCase()) ? model : make + " " + model);
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.domain.media;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* How a stored image must be transformed before it is displayed, as recorded by EXIF tag 0x0112.
|
||||
*
|
||||
@@ -62,7 +64,7 @@ public enum Orientation {
|
||||
* <p>Absent and out-of-range are treated the same on purpose: a camera writing garbage here should give
|
||||
* an upright photo, not an exception in the middle of a library scan.
|
||||
*/
|
||||
public static Orientation ofExifValue(Integer exifValue) {
|
||||
public static Orientation ofExifValue(@Nullable Integer exifValue) {
|
||||
if (exifValue == null) {
|
||||
return NORMAL;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.icroco.pholio.infra.support.AppDirectories;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -80,7 +81,7 @@ public final class LibraryCatalog {
|
||||
* append parameters to the JDBC URL the name is interpolated into. Leading dots are stripped too, so a
|
||||
* name can never produce a hidden file or a {@code ..} traversal.
|
||||
*/
|
||||
public static String sanitize(String name) {
|
||||
public static String sanitize(@Nullable String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return FALLBACK_NAME;
|
||||
}
|
||||
@@ -110,7 +111,7 @@ public final class LibraryCatalog {
|
||||
* <p>Guards the case where the user deleted the database file that {@code library.last-opened} still
|
||||
* points at: without this the application would silently recreate an empty database under the old name.
|
||||
*/
|
||||
public static String resolve(String candidate, List<String> available) {
|
||||
public static String resolve(@Nullable String candidate, List<String> available) {
|
||||
String name = candidate == null ? "" : sanitize(candidate);
|
||||
if (!name.isBlank() && available.contains(name)) {
|
||||
return name;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
|
||||
/**
|
||||
* Published once a new root folder has been persisted, before its subfolders have been scanned.
|
||||
*
|
||||
* <p>Carries only the added folder, not the whole list, so a listener can append rather than rebuild.
|
||||
*/
|
||||
public record LibraryFolderAddedEvent(LibraryFolder folder) {
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Recursively lists every subdirectory under a {@link org.icroco.pholio.domain.library.LibraryFolder} root,
|
||||
* for {@link LibraryFolderService} to persist into {@code library_subfolder}.
|
||||
*
|
||||
* <p>Unlike the UI's lazy, one-level-at-a-time {@code FolderTreeItem}, this walks the whole tree in one
|
||||
* call — it runs off the FX thread, in the background, once per added root. The per-directory error
|
||||
* handling and hidden-folder filter mirror {@code FolderTreeItem} exactly: an unreadable directory partway
|
||||
* down the tree must not abort the rest of the scan, and dot-prefixed or OS-hidden directories are noise in
|
||||
* a photo library.
|
||||
*
|
||||
* <p>An explicit stack, not recursion, so a library nested tens of thousands of directories deep cannot
|
||||
* overflow the call stack. Directories are never followed through symlinks ({@link Files#list} does not
|
||||
* traverse them), so there is no cycle to guard against.
|
||||
*/
|
||||
@Component
|
||||
public class LibraryFolderScanner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LibraryFolderScanner.class);
|
||||
|
||||
public List<Path> scan(Path root) {
|
||||
List<Path> found = new ArrayList<>();
|
||||
Deque<Path> pending = new ArrayDeque<>();
|
||||
pending.push(root);
|
||||
|
||||
while (!pending.isEmpty()) {
|
||||
for (Path child : subdirectoriesOf(pending.pop())) {
|
||||
found.add(child);
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static List<Path> subdirectoriesOf(Path directory) {
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.filter(LibraryFolderScanner::isVisible)
|
||||
.filter(Files::isDirectory)
|
||||
.toList();
|
||||
}
|
||||
catch (IOException | UncheckedIOException e) {
|
||||
log.debug("Cannot list '{}': {}", directory, e.toString());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isVisible(Path path) {
|
||||
Path name = path.getFileName();
|
||||
if (name == null || name.toString().startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return !Files.isHidden(path);
|
||||
}
|
||||
catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.icroco.pholio.domain.library.LibrarySubfolder;
|
||||
import org.icroco.pholio.infra.persistence.folder.*;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.icroco.pholio.infra.task.TaskType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Owns the folders that make up the currently open library — the database-backed replacement for the old
|
||||
* single {@code library.root-path} preference.
|
||||
*
|
||||
* <p>Adding a folder is two steps, deliberately not one transaction: the folder itself is saved — and
|
||||
* that {@code repository.save} call commits on its own, since nothing here wraps it in a wider
|
||||
* transaction — before the event fires and the background scan starts, so neither ever sees the root row
|
||||
* as uncommitted. The recursive scan of its subdirectories (into {@code library_subfolder}, unused by
|
||||
* anything yet — see {@link LibraryFolderScanner}) then runs on a background pool, since a library can be
|
||||
* tens of thousands of directories deep.
|
||||
*
|
||||
* <p>{@code @DependsOn(LibraryService)}: without it, this bean's own {@code @PostConstruct} can run before
|
||||
* {@code LibraryService.start()} has opened a library — the two have no other relationship Spring's
|
||||
* bean-creation order would honour, and when it loses that race the routing datasource throws (no library
|
||||
* is open yet) the first time this class touches a repository.
|
||||
*/
|
||||
@Service
|
||||
@DependsOn("libraryService")
|
||||
public class LibraryFolderService {
|
||||
|
||||
private static final String LIBRARY_GROUP = "library";
|
||||
private static final String LEGACY_ROOT_PATH_KEY = "root-path";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LibraryFolderService.class);
|
||||
|
||||
private final LibraryFolderRepository repository;
|
||||
private final LibraryFolderMapper mapper;
|
||||
private final LibrarySubfolderRepository subfolderRepository;
|
||||
private final LibrarySubfolderMapper subfolderMapper;
|
||||
private final LibraryFolderScanner scanner;
|
||||
private final AppPreferences preferences;
|
||||
private final ApplicationEventPublisher publisher;
|
||||
private final TaskService taskService;
|
||||
|
||||
public LibraryFolderService(LibraryFolderRepository repository,
|
||||
LibraryFolderMapper mapper,
|
||||
LibrarySubfolderRepository subfolderRepository,
|
||||
LibrarySubfolderMapper subfolderMapper,
|
||||
LibraryFolderScanner scanner,
|
||||
AppPreferences preferences,
|
||||
ApplicationEventPublisher publisher,
|
||||
TaskService taskService) {
|
||||
this.repository = repository;
|
||||
this.mapper = mapper;
|
||||
this.subfolderRepository = subfolderRepository;
|
||||
this.subfolderMapper = subfolderMapper;
|
||||
this.scanner = scanner;
|
||||
this.preferences = preferences;
|
||||
this.publisher = publisher;
|
||||
this.taskService = taskService;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of the pre-database single root, if any. Runs only while the table is still
|
||||
* empty, so it never re-adds a folder the user has since removed.
|
||||
*/
|
||||
@PostConstruct
|
||||
void migrateLegacyRootPathIfPresent() {
|
||||
if (repository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
preferences.text(LIBRARY_GROUP, LEGACY_ROOT_PATH_KEY)
|
||||
.filter(root -> !root.isBlank())
|
||||
.ifPresent(root -> {
|
||||
log.info("Migrating legacy '{}.{}' preference into library_folder: {}",
|
||||
LIBRARY_GROUP, LEGACY_ROOT_PATH_KEY, root);
|
||||
add(Path.of(root));
|
||||
});
|
||||
}
|
||||
|
||||
public List<LibraryFolder> list() {
|
||||
return repository.findAll().stream().map(mapper::toDomain).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists {@code directory} as a new root, or returns the existing one if it was already added.
|
||||
* Publishes {@link LibraryFolderAddedEvent} before the subfolder scan starts, not after it finishes.
|
||||
*/
|
||||
public LibraryFolder add(Path directory) {
|
||||
String absolute = directory.toAbsolutePath().toString();
|
||||
|
||||
LibraryFolder folder = repository.findByPath(absolute)
|
||||
.map(mapper::toDomain)
|
||||
.orElseGet(() -> {
|
||||
LibraryFolderEntity saved = repository.save(
|
||||
LibraryFolderEntity.builder()
|
||||
.path(absolute)
|
||||
.addedAt(Instant.now())
|
||||
.build());
|
||||
LibraryFolder created = mapper.toDomain(saved);
|
||||
log.info("Added library folder '{}'", absolute);
|
||||
publisher.publishEvent(new LibraryFolderAddedEvent(created));
|
||||
scanSubfoldersInBackground(created);
|
||||
return created;
|
||||
});
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a root folder by path. The database cascades the delete to its {@code library_subfolder}
|
||||
* rows in the same statement.
|
||||
*/
|
||||
public void remove(Path directory) {
|
||||
String absolute = directory.toAbsolutePath().toString();
|
||||
repository.deleteByPath(absolute);
|
||||
log.info("Removed library folder '{}'", absolute);
|
||||
}
|
||||
|
||||
private void scanSubfoldersInBackground(LibraryFolder folder) {
|
||||
// folder is always already persisted by the time it reaches here (via add()), so it has a real id.
|
||||
Long libraryFolderId = Objects.requireNonNull(folder.id());
|
||||
taskService.execute(TaskType.BACKGROUND_SYNC, () -> {
|
||||
Instant now = Instant.now();
|
||||
List<LibrarySubfolderEntity> subfolders = scanner.scan(folder.path()).stream()
|
||||
.map(path -> subfolderMapper.toEntity(new LibrarySubfolder(null, libraryFolderId, path, now)))
|
||||
.toList();
|
||||
if (!subfolders.isEmpty()) {
|
||||
subfolderRepository.saveAll(subfolders);
|
||||
}
|
||||
log.debug("Scanned {} subfolder(s) of '{}'", subfolders.size(), folder.path());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Re-points the application's {@code DataSource} at another library.
|
||||
*
|
||||
@@ -12,7 +14,7 @@ public interface LibraryRouter {
|
||||
/**
|
||||
* The library currently served, or {@code null} before the first {@link #switchTo}.
|
||||
*/
|
||||
String current();
|
||||
@Nullable String current();
|
||||
|
||||
/**
|
||||
* Serves {@code library} from now on, creating its database file if it does not exist.
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.annotation.PreDestroy;
|
||||
import javafx.util.Subscription;
|
||||
import org.icroco.pholio.infra.config.ExecutorConfig;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -12,6 +13,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
@@ -47,7 +49,7 @@ public class LibraryService {
|
||||
private final Executor databaseExecutor;
|
||||
private final ApplicationEventPublisher publisher;
|
||||
|
||||
private Subscription subscription;
|
||||
private @Nullable Subscription subscription;
|
||||
|
||||
public LibraryService(AppPreferences preferences,
|
||||
Optional<LibraryRouter> router,
|
||||
@@ -99,7 +101,8 @@ public class LibraryService {
|
||||
* The library currently open, as recorded in the preference tree.
|
||||
*/
|
||||
public String currentLibrary() {
|
||||
return preferences.getValue(LIBRARY_GROUP, LAST_OPENED_KEY, String.class);
|
||||
// Always non-null after start() has run: it seeds LAST_OPENED_KEY before anything can read it.
|
||||
return Objects.requireNonNull(preferences.getValue(LIBRARY_GROUP, LAST_OPENED_KEY, String.class));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.icroco.pholio.domain.media.EmbeddedThumbnail;
|
||||
import org.icroco.pholio.domain.media.ImageFormat;
|
||||
import org.icroco.pholio.domain.media.MediaAccessException;
|
||||
import org.icroco.pholio.domain.media.ThumbnailAccessor;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
@@ -95,7 +96,7 @@ final class ExifThumbnailAccessor implements ThumbnailAccessor {
|
||||
+ "container is beyond what metadata-extractor can do. Check canEmbed(format) first.");
|
||||
}
|
||||
|
||||
private static ExifThumbnailDirectory thumbnailDirectoryOf(Path file) {
|
||||
private static @Nullable ExifThumbnailDirectory thumbnailDirectoryOf(Path file) {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
throw new MediaAccessException(file, "Not a readable file");
|
||||
}
|
||||
@@ -114,7 +115,7 @@ final class ExifThumbnailAccessor implements ThumbnailAccessor {
|
||||
/**
|
||||
* The requested range, or {@code null} when the file is shorter than the offsets claim.
|
||||
*/
|
||||
private static byte[] readRange(Path file, int offset, int length) {
|
||||
private static byte @Nullable [] readRange(Path file, int offset, int length) {
|
||||
try (SeekableByteChannel channel = Files.newByteChannel(file)) {
|
||||
if (channel.size() < (long) offset + length) {
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.icroco.pholio.infra.media;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.icroco.pholio.domain.media.ImageFormat;
|
||||
import org.icroco.pholio.domain.media.MediaMetaFactory;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -92,7 +93,7 @@ public class MediaFormatRegistry {
|
||||
/**
|
||||
* As {@link #formatOf(Path)}, from an extension with or without a leading dot, in any case.
|
||||
*/
|
||||
public Optional<ImageFormat> formatOfExtension(String extension) {
|
||||
public Optional<ImageFormat> formatOfExtension(@Nullable String extension) {
|
||||
if (extension == null || extension.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -107,7 +108,7 @@ public class MediaFormatRegistry {
|
||||
/**
|
||||
* The factory handling {@code format}, empty when no bean claims it.
|
||||
*/
|
||||
public Optional<MediaMetaFactory> factoryFor(ImageFormat format) {
|
||||
public Optional<MediaMetaFactory> factoryFor(@Nullable ImageFormat format) {
|
||||
return format == null ? Optional.empty() : Optional.ofNullable(factoriesByFormat.get(format));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.drew.metadata.jpeg.JpegDirectory;
|
||||
import com.drew.metadata.png.PngDirectory;
|
||||
import com.drew.metadata.webp.WebpDirectory;
|
||||
import org.icroco.pholio.domain.media.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -128,7 +129,7 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
return new int[]{ 0, 0 };
|
||||
}
|
||||
|
||||
private static Orientation orientationOf(ExifIFD0Directory ifd0) {
|
||||
private static Orientation orientationOf(@Nullable ExifIFD0Directory ifd0) {
|
||||
return Orientation.ofExifValue(integer(ifd0, ExifDirectoryBase.TAG_ORIENTATION));
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
* deliberately not a third fallback, because it changes whenever anything touches the file and would
|
||||
* quietly re-date a whole library.
|
||||
*/
|
||||
private static LocalDateTime captureTimeOf(ExifSubIFDDirectory subIfd, ExifIFD0Directory ifd0) {
|
||||
private static @Nullable LocalDateTime captureTimeOf(@Nullable ExifSubIFDDirectory subIfd, @Nullable ExifIFD0Directory ifd0) {
|
||||
LocalDateTime original = dateTime(subIfd, ExifDirectoryBase.TAG_DATETIME_ORIGINAL);
|
||||
if (original != null) {
|
||||
return original;
|
||||
@@ -151,12 +152,12 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
/**
|
||||
* {@code LensModel} where the body writes it, the older free-text {@code Lens} otherwise.
|
||||
*/
|
||||
private static String lensOf(ExifSubIFDDirectory subIfd) {
|
||||
private static @Nullable String lensOf(@Nullable ExifSubIFDDirectory subIfd) {
|
||||
String model = string(subIfd, ExifDirectoryBase.TAG_LENS_MODEL);
|
||||
return model != null ? model : string(subIfd, ExifDirectoryBase.TAG_LENS);
|
||||
}
|
||||
|
||||
private static GeoLocation locationOf(Metadata metadata) {
|
||||
private static @Nullable GeoLocation locationOf(Metadata metadata) {
|
||||
GpsDirectory gps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
|
||||
if (gps == null) {
|
||||
return null;
|
||||
@@ -195,7 +196,7 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static String string(Directory directory, int tag) {
|
||||
private static @Nullable String string(@Nullable Directory directory, int tag) {
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -203,11 +204,11 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static Integer integer(Directory directory, int tag) {
|
||||
private static @Nullable Integer integer(@Nullable Directory directory, int tag) {
|
||||
return directory == null ? null : directory.getInteger(tag);
|
||||
}
|
||||
|
||||
private static Double rational(Directory directory, int tag) {
|
||||
private static @Nullable Double rational(@Nullable Directory directory, int tag) {
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -215,7 +216,7 @@ final class MetadataExtractorReader implements MetadataReader {
|
||||
return value == null ? null : value.doubleValue();
|
||||
}
|
||||
|
||||
private static LocalDateTime dateTime(Directory directory, int tag) {
|
||||
private static @Nullable LocalDateTime dateTime(@Nullable Directory directory, int tag) {
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.zaxxer.hikari.HikariDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.icroco.pholio.infra.library.LibraryCatalog;
|
||||
import org.icroco.pholio.infra.library.LibraryRouter;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||
@@ -50,7 +51,7 @@ public class LibraryRoutingDataSource extends AbstractRoutingDataSource implemen
|
||||
private final Map<String, HikariDataSource> pools = new ConcurrentHashMap<>();
|
||||
private final Function<String, HikariDataSource> poolFactory;
|
||||
|
||||
private volatile String currentLibrary;
|
||||
private volatile @Nullable String currentLibrary;
|
||||
|
||||
LibraryRoutingDataSource(Function<String, HikariDataSource> poolFactory) {
|
||||
this.poolFactory = poolFactory;
|
||||
@@ -61,7 +62,7 @@ public class LibraryRoutingDataSource extends AbstractRoutingDataSource implemen
|
||||
}
|
||||
|
||||
@Override
|
||||
public String current() {
|
||||
public @Nullable String current() {
|
||||
return currentLibrary;
|
||||
}
|
||||
|
||||
@@ -91,7 +92,7 @@ public class LibraryRoutingDataSource extends AbstractRoutingDataSource implemen
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
protected @Nullable Object determineCurrentLookupKey() {
|
||||
// A per-call ScopedValue override, if one is ever needed, is consulted here first.
|
||||
return currentLibrary;
|
||||
}
|
||||
@@ -120,7 +121,7 @@ public class LibraryRoutingDataSource extends AbstractRoutingDataSource implemen
|
||||
currentLibrary = null;
|
||||
}
|
||||
|
||||
private static void close(String library, HikariDataSource pool) {
|
||||
private static void close(String library, @Nullable HikariDataSource pool) {
|
||||
if (pool == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.icroco.pholio.infra.persistence;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.icroco.pholio.infra.library.LibraryCatalog;
|
||||
import org.icroco.pholio.infra.support.AppDirectories;
|
||||
import org.slf4j.Logger;
|
||||
@@ -15,6 +16,7 @@ import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Replaces Spring Boot's autoconfigured {@code DataSource} with the library-routing one.
|
||||
@@ -60,7 +62,8 @@ public class PersistenceConfiguration {
|
||||
*/
|
||||
private static HikariDataSource pool(Environment environment, String library) {
|
||||
Path database = LibraryCatalog.databaseFile(library).toAbsolutePath();
|
||||
AppDirectories.ensureExists(database.getParent());
|
||||
// An absolute path always has a parent; toAbsolutePath() above is what guarantees it here.
|
||||
AppDirectories.ensureExists(Objects.requireNonNull(database.getParent()));
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
Binder.get(environment).bind(HIKARI_PREFIX, Bindable.ofInstance(config));
|
||||
@@ -76,6 +79,17 @@ public class PersistenceConfiguration {
|
||||
log.info("Opening library '{}' at {}", library, database);
|
||||
// The constructor starts the pool and validates one connection, so a database that cannot be
|
||||
// opened fails here — early enough for the caller to keep serving the previous library.
|
||||
return new HikariDataSource(config);
|
||||
HikariDataSource dataSource = new HikariDataSource(config);
|
||||
|
||||
// Migrated here, once per pool, rather than through spring.flyway.enabled: see the exclusion of
|
||||
// FlywayAutoConfiguration in application.yaml. Nothing publishes this pool until this method
|
||||
// returns, so no caller can observe an unmigrated schema.
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations(environment.getProperty("spring.flyway.locations", "classpath:db/migration"))
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import lombok.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
// Columns are named explicitly, matching the migration exactly: H2's dialect otherwise upper-cases a
|
||||
// quoted identifier it derives itself, while the quoted table name above (given literally) is left as
|
||||
// written — the mismatch makes an unannotated field invisible to a query at runtime, not at compile time.
|
||||
@Table("library_folder")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LibraryFolderEntity {
|
||||
|
||||
@Id
|
||||
@Column("id")
|
||||
private @Nullable Long id;
|
||||
|
||||
@Column("path")
|
||||
private String path;
|
||||
|
||||
@Column("added_at")
|
||||
private Instant addedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Mapper(componentModel = "spring", imports = Path.class)
|
||||
public interface LibraryFolderMapper {
|
||||
|
||||
@Mapping(target = "path", expression = "java(Path.of(entity.getPath()))")
|
||||
LibraryFolder toDomain(LibraryFolderEntity entity);
|
||||
|
||||
@Mapping(target = "id", ignore = true)
|
||||
@Mapping(target = "path", expression = "java(domain.path().toAbsolutePath().toString())")
|
||||
LibraryFolderEntity toEntity(LibraryFolder domain);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.springframework.data.repository.ListCrudRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LibraryFolderRepository extends ListCrudRepository<LibraryFolderEntity, Long> {
|
||||
|
||||
Optional<LibraryFolderEntity> findByPath(String path);
|
||||
|
||||
void deleteByPath(String path);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import lombok.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
// Columns are named explicitly — see LibraryFolderEntity for why an unannotated field would silently
|
||||
// resolve to an upper-cased identifier the migration's quoted, lower-case columns do not match.
|
||||
@Table("library_subfolder")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LibrarySubfolderEntity {
|
||||
|
||||
@Id
|
||||
@Column("id")
|
||||
private @Nullable Long id;
|
||||
|
||||
@Column("library_folder_id")
|
||||
private Long libraryFolderId;
|
||||
|
||||
@Column("path")
|
||||
private String path;
|
||||
|
||||
@Column("last_scanned_at")
|
||||
private Instant lastScannedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.icroco.pholio.domain.library.LibrarySubfolder;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Mapper(componentModel = "spring", imports = Path.class)
|
||||
public interface LibrarySubfolderMapper {
|
||||
|
||||
@Mapping(target = "path", expression = "java(Path.of(entity.getPath()))")
|
||||
LibrarySubfolder toDomain(LibrarySubfolderEntity entity);
|
||||
|
||||
@Mapping(target = "id", ignore = true)
|
||||
@Mapping(target = "path", expression = "java(domain.path().toAbsolutePath().toString())")
|
||||
LibrarySubfolderEntity toEntity(LibrarySubfolder domain);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.springframework.data.repository.ListCrudRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface LibrarySubfolderRepository extends ListCrudRepository<LibrarySubfolderEntity, Long> {
|
||||
|
||||
List<LibrarySubfolderEntity> findByLibraryFolderId(Long libraryFolderId);
|
||||
|
||||
void deleteByLibraryFolderId(Long libraryFolderId);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.icroco.pholio.infra.preferences;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import lombok.Getter;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@@ -81,7 +82,7 @@ public class AppPreferences {
|
||||
* @throws IllegalArgumentException if the preference is not declared, or {@code type} disagrees with
|
||||
* the declared {@link PreferenceType}
|
||||
*/
|
||||
public <T> T getValue(String group, String key, Class<T> type) {
|
||||
public <T> @Nullable T getValue(String group, String key, Class<T> type) {
|
||||
return type.cast(require(group, key, type).getValue());
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ public class AppPreferences {
|
||||
* and is written on every window move.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> void setValue(String group, String key, T value) {
|
||||
public <T> void setValue(String group, String key, @Nullable T value) {
|
||||
PreferenceItem<T> item = (PreferenceItem<T>) require(group, key, value == null ? null : value.getClass());
|
||||
item.setValue(value);
|
||||
}
|
||||
@@ -199,7 +200,7 @@ public class AppPreferences {
|
||||
return copy;
|
||||
}
|
||||
|
||||
private PreferenceItem<?> require(String group, String key, Class<?> requested) {
|
||||
private PreferenceItem<?> require(String group, String key, @Nullable Class<?> requested) {
|
||||
PreferenceItem<?> item = groups.getOrDefault(group, Map.of()).get(key);
|
||||
if (item == null) {
|
||||
throw new IllegalArgumentException("No preference declared for '" + group + "." + key + "'");
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import lombok.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -50,12 +51,12 @@ public class PreferenceItem<T> {
|
||||
* Message key for the displayed label, resolved through {@code I18nService}. A string that matches no
|
||||
* key is shown verbatim, so a quick throwaway preference can carry its label inline.
|
||||
*/
|
||||
private String label;
|
||||
private @Nullable String label;
|
||||
|
||||
/**
|
||||
* Value restored by "Reset". {@code null} is legitimate and means "unset".
|
||||
*/
|
||||
private T defaultValue;
|
||||
private @Nullable T defaultValue;
|
||||
|
||||
/**
|
||||
* When {@code false} the settings view builds no row for this item at all.
|
||||
@@ -70,7 +71,7 @@ public class PreferenceItem<T> {
|
||||
/**
|
||||
* Allowed values for a {@code STRING}; present turns the control into a {@code ComboBox}.
|
||||
*/
|
||||
private List<String> options;
|
||||
private @Nullable List<String> options;
|
||||
|
||||
/**
|
||||
* Bounds for a numeric spinner. Only {@code INT} consumes them today.
|
||||
@@ -78,9 +79,9 @@ public class PreferenceItem<T> {
|
||||
* <p>{@link Number} rather than {@link Double} so an integer bound round-trips as one: users hand-edit
|
||||
* this file, and a thumbnail size capped at {@code 512.0} pixels reads like a mistake.
|
||||
*/
|
||||
private Number min;
|
||||
private @Nullable Number min;
|
||||
|
||||
private Number max;
|
||||
private @Nullable Number max;
|
||||
|
||||
/**
|
||||
* The live value. Held in a property rather than a plain field so the settings view, the theme manager
|
||||
@@ -101,12 +102,12 @@ public class PreferenceItem<T> {
|
||||
@JsonProperty("value")
|
||||
@ToString.Include(name = "value")
|
||||
@EqualsAndHashCode.Include
|
||||
public T getValue() {
|
||||
public @Nullable T getValue() {
|
||||
return property.get();
|
||||
}
|
||||
|
||||
@JsonProperty("value")
|
||||
public void setValue(T value) {
|
||||
public void setValue(@Nullable T value) {
|
||||
property.set(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.icroco.pholio.infra.support.AppDirectories;
|
||||
import org.icroco.pholio.infra.support.Debouncer;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,6 +26,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
@@ -242,7 +244,8 @@ public class PreferenceService {
|
||||
|
||||
private void write() {
|
||||
try {
|
||||
AppDirectories.ensureExists(file.getParent());
|
||||
// file is always resolved from a directory (see the constructor), so it always has a parent.
|
||||
AppDirectories.ensureExists(Objects.requireNonNull(file.getParent()));
|
||||
Path temp = Files.createTempFile(file.getParent(), FILE_NAME, ".tmp");
|
||||
try (OutputStream out = Files.newOutputStream(temp)) {
|
||||
yaml.writeValue(out, preferences.getGroups());
|
||||
@@ -270,7 +273,7 @@ public class PreferenceService {
|
||||
* replaces it with a value of the declared type.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void setRaw(PreferenceItem<?> item, Object value) {
|
||||
private static void setRaw(PreferenceItem<?> item, @Nullable Object value) {
|
||||
((PreferenceItem<Object>) item).setValue(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.infra.preferences;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -50,7 +52,7 @@ public enum PreferenceType {
|
||||
* @throws IllegalArgumentException if {@code raw} carries a value that cannot be read as this type,
|
||||
* so a typo in the schema fails at startup rather than silently becoming a default
|
||||
*/
|
||||
public Object coerce(Object raw) {
|
||||
public @Nullable Object coerce(@Nullable Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.infra.preferences;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Persisted position, size and maximised state of a single window.
|
||||
*
|
||||
@@ -9,12 +11,11 @@ package org.icroco.pholio.infra.preferences;
|
||||
* <p>Per the project rules this is edited only by hand in {@code preferences.yaml} — the settings UI
|
||||
* deliberately exposes no geometry fields.
|
||||
*/
|
||||
public record WindowGeometry(
|
||||
Double x,
|
||||
Double y,
|
||||
Double width,
|
||||
Double height,
|
||||
boolean maximized) {
|
||||
public record WindowGeometry(@Nullable Double x,
|
||||
@Nullable Double y,
|
||||
@Nullable Double width,
|
||||
@Nullable Double height,
|
||||
boolean maximized) {
|
||||
|
||||
public static WindowGeometry unset() {
|
||||
return new WindowGeometry(null, null, null, null, false);
|
||||
@@ -25,14 +26,14 @@ public record WindowGeometry(
|
||||
}
|
||||
|
||||
public boolean hasSize() {
|
||||
return isUsable(width) && width > 0 && isUsable(height) && height > 0;
|
||||
return isUsable(width) && width != null && width > 0 && isUsable(height) && height != null && height > 0;
|
||||
}
|
||||
|
||||
public WindowGeometry withMaximized(boolean value) {
|
||||
return new WindowGeometry(x, y, width, height, value);
|
||||
}
|
||||
|
||||
private static boolean isUsable(Double value) {
|
||||
private static boolean isUsable(@Nullable Double value) {
|
||||
return value != null && !value.isNaN() && !value.isInfinite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.infra.support;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -84,7 +86,7 @@ public final class AppDirectories {
|
||||
return Os.LINUX;
|
||||
}
|
||||
|
||||
private static Path overrideRoot() {
|
||||
private static @Nullable Path overrideRoot() {
|
||||
String override = System.getProperty(HOME_OVERRIDE_PROPERTY);
|
||||
return (override == null || override.isBlank()) ? null : Paths.get(override);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.icroco.pholio.infra.support;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -98,7 +99,7 @@ public final class Debouncer implements AutoCloseable {
|
||||
|
||||
private static final class Pending {
|
||||
private final Runnable action;
|
||||
private volatile ScheduledFuture<?> future;
|
||||
private volatile @Nullable ScheduledFuture<?> future;
|
||||
|
||||
private Pending(Runnable action) {
|
||||
this.action = action;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.icroco.pholio.infra.task;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** The work behind a tracked, result-bearing submission to {@link TaskService}. */
|
||||
@FunctionalInterface
|
||||
public interface TaskBody<T> {
|
||||
T run(ProgressReporter progress) throws Exception;
|
||||
@Nullable T run(ProgressReporter progress) throws Exception;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.infra.task;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -8,5 +10,5 @@ import java.util.UUID;
|
||||
* shared sentinel value for "unchanged" would have made a legitimate {@code 0.0} or an empty message
|
||||
* ambiguous with "nothing to report".
|
||||
*/
|
||||
public record TaskProgressEvent(UUID taskId, Double progress, String message) {
|
||||
public record TaskProgressEvent(UUID taskId, @Nullable Double progress, @Nullable String message) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.icroco.pholio.infra.task;
|
||||
|
||||
import org.icroco.pholio.infra.config.ExecutorConfig;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -10,13 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* The one place background work gets submitted from, so neither the UI nor the CLI ever injects one of
|
||||
@@ -47,9 +42,9 @@ public class TaskService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
|
||||
|
||||
private final Map<TaskType, Executor> executors = new EnumMap<>(TaskType.class);
|
||||
private final Map<UUID, TrackedFuture<?>> running = new ConcurrentHashMap<>();
|
||||
private final ApplicationEventPublisher publisher;
|
||||
private final Map<TaskType, Executor> executors = new EnumMap<>(TaskType.class);
|
||||
private final Map<UUID, TrackedFuture<?>> running = new ConcurrentHashMap<>();
|
||||
private final ApplicationEventPublisher publisher;
|
||||
|
||||
public TaskService(@Qualifier(ExecutorConfig.DATABASE_EXECUTOR) Executor database,
|
||||
@Qualifier(ExecutorConfig.THUMBNAIL_EXECUTOR) Executor thumbnail,
|
||||
@@ -69,11 +64,11 @@ public class TaskService {
|
||||
* Submits result-bearing work, tracked from the moment this returns.
|
||||
*
|
||||
* @return a future completed with {@code body}'s result, exceptionally on failure, or cancelled if
|
||||
* {@link #cancel} — or the future's own {@code cancel(true)} — is called before it finishes
|
||||
* {@link #cancel} — or the future's own {@code cancel(true)} — is called before it finishes
|
||||
*/
|
||||
public <T> CompletableFuture<T> submit(TaskType type, String title, TaskBody<T> body) {
|
||||
UUID id = UUID.randomUUID();
|
||||
TrackedFuture<T> result = new TrackedFuture<>();
|
||||
UUID id = UUID.randomUUID();
|
||||
TrackedFuture<T> result = new TrackedFuture<>();
|
||||
ProgressReporter reporter = new PublishingProgressReporter(id);
|
||||
|
||||
FutureTask<T> futureTask = new FutureTask<>(() -> body.run(reporter)) {
|
||||
@@ -88,7 +83,7 @@ public class TaskService {
|
||||
|
||||
publisher.publishEvent(new TaskSubmittedEvent(id, title, type));
|
||||
try {
|
||||
executors.get(type).execute(futureTask);
|
||||
executorFor(type).execute(futureTask);
|
||||
}
|
||||
catch (RejectedExecutionException e) {
|
||||
running.remove(id);
|
||||
@@ -99,7 +94,9 @@ public class TaskService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** {@link #submit(TaskType, String, TaskBody)} for work with nothing to hand back. */
|
||||
/**
|
||||
* {@link #submit(TaskType, String, TaskBody)} for work with nothing to hand back.
|
||||
*/
|
||||
public CompletableFuture<Void> submit(TaskType type, String title, TaskAction action) {
|
||||
return submit(type, title, progress -> {
|
||||
action.run(progress);
|
||||
@@ -116,7 +113,18 @@ public class TaskService {
|
||||
* they would a direct {@code Executor.execute} rejection
|
||||
*/
|
||||
public void execute(TaskType type, Runnable work) {
|
||||
executors.get(type).execute(work);
|
||||
executorFor(type).execute(work);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every {@link TaskType} is registered in the constructor, so a missing entry is a wiring bug.
|
||||
*/
|
||||
private Executor executorFor(TaskType type) {
|
||||
Executor executor = executors.get(type);
|
||||
if (executor == null) {
|
||||
throw new IllegalStateException("No executor configured for " + type);
|
||||
}
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +137,9 @@ public class TaskService {
|
||||
return future != null && future.cancel(true);
|
||||
}
|
||||
|
||||
/** Cancels every tracked task still running. Called when the window closes, before the context shuts down. */
|
||||
/**
|
||||
* Cancels every tracked task still running. Called when the window closes, before the context shuts down.
|
||||
*/
|
||||
public void cancelAll() {
|
||||
running.values().forEach(future -> future.cancel(true));
|
||||
}
|
||||
@@ -162,7 +172,7 @@ public class TaskService {
|
||||
*/
|
||||
private static final class TrackedFuture<T> extends CompletableFuture<T> {
|
||||
|
||||
private FutureTask<T> delegate;
|
||||
private @Nullable FutureTask<T> delegate;
|
||||
|
||||
void attach(FutureTask<T> delegate) {
|
||||
this.delegate = delegate;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@NullMarked
|
||||
package org.icroco.pholio;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
@@ -5,11 +5,14 @@ import javafx.application.HostServices;
|
||||
import javafx.stage.Stage;
|
||||
import org.icroco.pholio.LaunchMode;
|
||||
import org.icroco.pholio.PholioBootstrap;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationContextInitializer;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Bridges the JavaFX and Spring lifecycles.
|
||||
*
|
||||
@@ -28,7 +31,7 @@ public class PholioFxApplication extends Application {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PholioFxApplication.class);
|
||||
|
||||
private ConfigurableApplicationContext context;
|
||||
private @Nullable ConfigurableApplicationContext context;
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
@@ -52,7 +55,8 @@ public class PholioFxApplication extends Application {
|
||||
|
||||
@Override
|
||||
public void start(Stage primaryStage) {
|
||||
context.getBean(StageManager.class).showMainWindow(primaryStage);
|
||||
// JavaFX always calls init() before start(), so context is set by the time this runs.
|
||||
Objects.requireNonNull(context).getBean(StageManager.class).showMainWindow(primaryStage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.icroco.pholio.ui.event.ViewType;
|
||||
import org.icroco.pholio.ui.selection.ViewportSelection;
|
||||
import org.icroco.pholio.ui.view.GalleryView;
|
||||
import org.icroco.pholio.ui.view.ModulePlaceholderView;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.context.event.EventListener;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Owns the main viewport and is the only component that mounts or unmounts a view.
|
||||
@@ -41,15 +43,15 @@ public class ViewSwitcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ViewSwitcher.class);
|
||||
|
||||
private final ApplicationContext context;
|
||||
private final ViewportSelection viewportSelection;
|
||||
private final StackPane container = new StackPane();
|
||||
private final Map<ViewType, Class<? extends Node>> viewTypes = new EnumMap<>(ViewType.class);
|
||||
private final ObjectProperty<ViewType> currentType = new SimpleObjectProperty<>(this, "currentType");
|
||||
private final @Nullable ApplicationContext context;
|
||||
private final ViewportSelection viewportSelection;
|
||||
private final StackPane container = new StackPane();
|
||||
private final Map<ViewType, Class<? extends Node>> viewTypes = new EnumMap<>(ViewType.class);
|
||||
private final ObjectProperty<ViewType> currentType = new SimpleObjectProperty<>(this, "currentType");
|
||||
|
||||
private Node currentView;
|
||||
private @Nullable Node currentView;
|
||||
|
||||
public ViewSwitcher(ApplicationContext context, ViewportSelection viewportSelection) {
|
||||
public ViewSwitcher(@Nullable ApplicationContext context, ViewportSelection viewportSelection) {
|
||||
this.context = context;
|
||||
this.viewportSelection = viewportSelection;
|
||||
container.getStyleClass().add("main-viewport");
|
||||
@@ -65,7 +67,9 @@ public class ViewSwitcher {
|
||||
viewTypes.put(ViewType.EXPORT, ModulePlaceholderView.class);
|
||||
}
|
||||
|
||||
/** The node to place in the centre of the shell. */
|
||||
/**
|
||||
* The node to place in the centre of the shell.
|
||||
*/
|
||||
public Pane container() {
|
||||
return container;
|
||||
}
|
||||
@@ -74,7 +78,9 @@ public class ViewSwitcher {
|
||||
return currentType;
|
||||
}
|
||||
|
||||
/** Convenience for the initial navigation at startup. */
|
||||
/**
|
||||
* Convenience for the initial navigation at startup.
|
||||
*/
|
||||
public void navigate(ViewType target) {
|
||||
onNavigate(new NavigateToViewEvent(target));
|
||||
}
|
||||
@@ -97,7 +103,8 @@ public class ViewSwitcher {
|
||||
|
||||
disposeCurrent();
|
||||
|
||||
Node view = context.getBean(viewType);
|
||||
// Only reached when a navigation actually mounts a view; only tests that never navigate pass a null context.
|
||||
Node view = Objects.requireNonNull(context).getBean(viewType);
|
||||
if (view instanceof NavigationAware aware) {
|
||||
aware.onNavigate(event);
|
||||
}
|
||||
@@ -122,7 +129,9 @@ public class ViewSwitcher {
|
||||
currentView = null;
|
||||
}
|
||||
|
||||
/** Disposes whatever is mounted. Called when the window closes. */
|
||||
/**
|
||||
* Disposes whatever is mounted. Called when the window closes.
|
||||
*/
|
||||
public void dispose() {
|
||||
FxUtils.onFxThread(this::disposeCurrent);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import javafx.scene.input.KeyCombination;
|
||||
import javafx.stage.Stage;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A scene graph inspector, opened on F12.
|
||||
*
|
||||
@@ -42,13 +45,14 @@ public class DevTools implements Disposable {
|
||||
private static final KeyCombination SHORTCUT = new KeyCodeCombination(KeyCode.F12);
|
||||
private static final String APPLICATION_NAME = "Pholio";
|
||||
|
||||
private final boolean enabled;
|
||||
private final HostServices hostServices;
|
||||
private final boolean enabled;
|
||||
private final @Nullable HostServices hostServices;
|
||||
|
||||
private Connector connector;
|
||||
private Stage toolStage;
|
||||
private @Nullable Connector connector;
|
||||
private @Nullable Stage toolStage;
|
||||
|
||||
public DevTools(@Value("${pholio.dev-tools.enabled:false}") boolean enabled, HostServices hostServices) {
|
||||
public DevTools(@Value("${pholio.dev-tools.enabled:false}") boolean enabled,
|
||||
@Nullable HostServices hostServices) {
|
||||
this.enabled = enabled;
|
||||
this.hostServices = hostServices;
|
||||
}
|
||||
@@ -103,24 +107,28 @@ public class DevTools implements Disposable {
|
||||
// reattach, and WindowMonitor then re-adds its highlight nodes to a root that already holds them —
|
||||
// "duplicate children added", thrown out of the shutdown sequence before the window geometry is saved.
|
||||
// Remove this note if the upstream typo is fixed and the version is raised.
|
||||
connector = new LocalConnector(primaryStage, APPLICATION_NAME);
|
||||
ToolPane pane = new ToolPane(connector, new Preferences(hostServices));
|
||||
Connector localConnector = new LocalConnector(primaryStage, APPLICATION_NAME);
|
||||
connector = localConnector;
|
||||
// Only reached once the inspector is actually opened; real callers always inject real HostServices,
|
||||
// and only tests that never open the inspector pass null.
|
||||
ToolPane pane = new ToolPane(localConnector, new Preferences(Objects.requireNonNull(hostServices)));
|
||||
|
||||
Scene scene = new Scene(pane, GUI.DEFAULT_STAGE_WIDTH, GUI.DEFAULT_STAGE_HEIGHT);
|
||||
// Its own user agent stylesheet, so the inspector keeps its appearance whichever theme the
|
||||
// application is wearing — and so it is not itself restyled by the CSS being debugged.
|
||||
scene.setUserAgentStylesheet(GUI.USER_AGENT_STYLESHEET);
|
||||
|
||||
toolStage = new Stage();
|
||||
toolStage.setTitle(APPLICATION_NAME + " — scene graph");
|
||||
toolStage.setScene(scene);
|
||||
Stage localToolStage = new Stage();
|
||||
toolStage = localToolStage;
|
||||
localToolStage.setTitle(APPLICATION_NAME + " — scene graph");
|
||||
localToolStage.setScene(scene);
|
||||
// The connector walks the live scene graph, so it can only start once the tool window exists. Cleared
|
||||
// after the first run: hiding and reshowing the window must not attach a second set of listeners.
|
||||
toolStage.setOnShown(event -> {
|
||||
toolStage.setOnShown(null);
|
||||
connector.start();
|
||||
localToolStage.setOnShown(event -> {
|
||||
localToolStage.setOnShown(null);
|
||||
localConnector.start();
|
||||
});
|
||||
toolStage.show();
|
||||
localToolStage.show();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.icroco.pholio.ui.event;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Request to show another view.
|
||||
*
|
||||
@@ -15,7 +17,7 @@ package org.icroco.pholio.ui.event;
|
||||
* @param payload optional context for the destination (a photo id, a filter, an import batch); may be
|
||||
* {@code null}
|
||||
*/
|
||||
public record NavigateToViewEvent(ViewType target, Object payload) {
|
||||
public record NavigateToViewEvent(ViewType target, @Nullable Object payload) {
|
||||
|
||||
public NavigateToViewEvent(ViewType target) {
|
||||
this(target, null);
|
||||
|
||||
@@ -7,6 +7,7 @@ import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.MessageSource;
|
||||
@@ -18,6 +19,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Single source of translated text, with live language switching.
|
||||
@@ -75,7 +77,7 @@ public class I18nService {
|
||||
* Switches language and persists the choice. Every existing binding updates; no restart, no view
|
||||
* rebuild.
|
||||
*/
|
||||
public void setLocale(Locale newLocale) {
|
||||
public void setLocale(@Nullable Locale newLocale) {
|
||||
Locale resolved = supportedOrDefault(newLocale);
|
||||
if (resolved.equals(locale.get())) {
|
||||
return;
|
||||
@@ -126,8 +128,9 @@ public class I18nService {
|
||||
return fallback;
|
||||
}
|
||||
// The four-argument overload returns the default instead of throwing, so no exception is used for
|
||||
// control flow on a path that runs for every row of the settings view.
|
||||
return messageSource.getMessage(code, null, fallback, locale.get());
|
||||
// control flow on a path that runs for every row of the settings view. The null only happens when
|
||||
// the default message itself is null, which fallback never is.
|
||||
return Objects.requireNonNullElse(messageSource.getMessage(code, null, fallback, locale.get()), fallback);
|
||||
}
|
||||
|
||||
/** Localised human-readable file size, e.g. {@code 4,2 MB} in French. */
|
||||
@@ -163,14 +166,14 @@ public class I18nService {
|
||||
return candidate.getDisplayLanguage(candidate);
|
||||
}
|
||||
|
||||
private Locale resolveInitialLocale(String persisted) {
|
||||
private Locale resolveInitialLocale(@Nullable String persisted) {
|
||||
if (persisted != null && !persisted.isBlank()) {
|
||||
return supportedOrDefault(Locale.forLanguageTag(persisted));
|
||||
}
|
||||
return supportedOrDefault(Locale.getDefault());
|
||||
}
|
||||
|
||||
private static Locale supportedOrDefault(Locale candidate) {
|
||||
private static Locale supportedOrDefault(@Nullable Locale candidate) {
|
||||
if (candidate == null) {
|
||||
return SUPPORTED_LOCALES.getFirst();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
import javafx.scene.Node;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Whether the view currently mounted in the viewport has a selection.
|
||||
@@ -39,7 +40,7 @@ public class ViewportSelection {
|
||||
*
|
||||
* @param view the newly mounted view, or {@code null} when the viewport is emptied
|
||||
*/
|
||||
public void follow(Node view) {
|
||||
public void follow(@Nullable Node view) {
|
||||
hasSelection.unbind();
|
||||
if (view instanceof SelectionSource source) {
|
||||
hasSelection.bind(source.hasSelection());
|
||||
|
||||
@@ -4,13 +4,18 @@ import atlantafx.base.theme.Styles;
|
||||
import atlantafx.base.theme.Tweaks;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeView;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import javafx.stage.DirectoryChooser;
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.icroco.pholio.infra.library.LibraryChangedEvent;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderAddedEvent;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderService;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.icroco.pholio.infra.task.TaskType;
|
||||
import org.icroco.pholio.ui.common.FxUtils;
|
||||
import org.icroco.pholio.ui.common.SubscriptionScope;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
@@ -19,18 +24,25 @@ import org.kordamp.ikonli.feather.Feather;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The Photothèque drawer: every folder and subfolder of the library.
|
||||
* The Photothèque drawer: every root folder the user has added to the library, and their subfolders.
|
||||
*
|
||||
* <p>The root is the configured library directory and the branches are loaded as they are opened — see
|
||||
* {@link FolderTreeItem}. Nothing is walked ahead of time, so opening the drawer costs one directory
|
||||
* listing whatever the size of the library.
|
||||
* <p>Roots are loaded from {@link LibraryFolderService} and shown under an invisible super-root — several
|
||||
* folders, not one — with each real root's branches loaded lazily as they are opened, exactly as before;
|
||||
* see {@link FolderTreeItem}. Nothing is walked ahead of time, so opening the drawer costs one directory
|
||||
* listing per expanded root, whatever the size of the library.
|
||||
*
|
||||
* <p>The root follows {@code library.root-path} live: choosing a directory from the gallery's picker
|
||||
* repopulates the tree with no restart, because both read the same observable preference.
|
||||
* <p>Adding a root is the "+" icon next to the drawer title ({@link #titleAccessory()}), revealed on hover
|
||||
* by CSS. Removing one is the "-" icon on the row itself, revealed the same way, and only offered on a
|
||||
* root's own row — a subfolder cannot be removed independently of its root.
|
||||
*
|
||||
* <p><strong>Selecting a folder does nothing yet.</strong> There is no index to filter, so wiring an event
|
||||
* would mean publishing something nobody listens to; the selection is logged and that is all. Filtering the
|
||||
@@ -41,29 +53,27 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LibraryFolderTree.class);
|
||||
|
||||
/**
|
||||
* Group and key of the library root, as declared in {@code preferences.yaml}.
|
||||
*/
|
||||
private static final String LIBRARY_GROUP = "library";
|
||||
private static final String ROOT_PATH_KEY = "root-path";
|
||||
|
||||
private final AppPreferences preferences;
|
||||
private final TaskService taskService;
|
||||
private final I18nService i18n;
|
||||
private final LibraryFolderService libraryFolderService;
|
||||
private final TaskService taskService;
|
||||
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final TreeView<Path> tree = new TreeView<>();
|
||||
private final Label notConfigured = new Label();
|
||||
private final Button addFolder = new Button();
|
||||
|
||||
public LibraryFolderTree(I18nService i18n,
|
||||
AppPreferences preferences,
|
||||
LibraryFolderService libraryFolderService,
|
||||
TaskService taskService) {
|
||||
this.preferences = preferences;
|
||||
this.i18n = i18n;
|
||||
this.libraryFolderService = libraryFolderService;
|
||||
this.taskService = taskService;
|
||||
|
||||
getStyleClass().add("library-folder-tree");
|
||||
|
||||
tree.setShowRoot(true);
|
||||
tree.setCellFactory(view -> new FolderCell());
|
||||
tree.setShowRoot(false);
|
||||
tree.setRoot(new TreeItem<>());
|
||||
tree.setCellFactory(view -> new FolderCell(this::removeFolder));
|
||||
// The tree fills the drawer, which already has its own border; EDGE_TO_EDGE drops the control's
|
||||
// so the two do not stack into a double line.
|
||||
tree.getStyleClass().add(Tweaks.EDGE_TO_EDGE);
|
||||
@@ -75,12 +85,15 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
|
||||
StackPane.setAlignment(notConfigured, Pos.CENTER);
|
||||
|
||||
getChildren().addAll(tree, notConfigured);
|
||||
updateEmptyState();
|
||||
|
||||
// subscribe(Consumer) delivers the current value immediately, so this both renders the initial tree
|
||||
// and follows a root chosen later from the gallery. Nothing is written back here, which is what
|
||||
// makes the immediate delivery harmless.
|
||||
scope.add(preferences.property(LIBRARY_GROUP, ROOT_PATH_KEY, String.class)
|
||||
.subscribe(root -> FxUtils.onFxThread(() -> applyRoot(root))));
|
||||
addFolder.getStyleClass().addAll("drawer-title-accessory", Styles.BUTTON_ICON, Styles.FLAT, Styles.SMALL);
|
||||
addFolder.setGraphic(FontIcon.of(Feather.PLUS, 14));
|
||||
addFolder.setTooltip(new Tooltip());
|
||||
addFolder.getTooltip().textProperty().bind(i18n.binding("library.addFolder.tooltip"));
|
||||
addFolder.setOnAction(event -> chooseFolder());
|
||||
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,23 +106,81 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Node> titleAccessory() {
|
||||
return Optional.of(addFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the tree at {@code root}, or shows the "no library" notice when there is none.
|
||||
*
|
||||
* <p>A configured directory is not checked for existence: that is a system call on the FX thread for a
|
||||
* case the tree already handles — a missing root simply lists nothing. Only a blank preference means
|
||||
* "no library", and that is the one the notice is about.
|
||||
* Picks a new root folder and hands it to {@link LibraryFolderService}; the row itself is added once
|
||||
* {@link #onFolderAdded} hears the folder was actually persisted, not synchronously here.
|
||||
*/
|
||||
private void applyRoot(String root) {
|
||||
boolean configured = root != null && !root.isBlank();
|
||||
private void chooseFolder() {
|
||||
DirectoryChooser chooser = new DirectoryChooser();
|
||||
chooser.setTitle(i18n.get("library.chooseFolder.dialogTitle"));
|
||||
|
||||
tree.setRoot(null);
|
||||
if (configured) {
|
||||
FolderTreeItem rootItem = new FolderTreeItem(Path.of(root), taskService);
|
||||
rootItem.setExpanded(true);
|
||||
tree.setRoot(rootItem);
|
||||
File selected = chooser.showDialog(getScene() != null ? getScene().getWindow() : null);
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
Path chosen = selected.toPath();
|
||||
taskService.execute(TaskType.BACKGROUND_SYNC, () -> libraryFolderService.add(chosen));
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onFolderAdded(LibraryFolderAddedEvent event) {
|
||||
FxUtils.onFxThread(() -> appendFolder(event.folder()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The database now behind {@link #tree} belongs to a different library — its folders are gone, and
|
||||
* whatever this drawer was showing is for a library that is no longer open. Reload from scratch rather
|
||||
* than trying to diff against what a single event can carry.
|
||||
*/
|
||||
@EventListener
|
||||
public void onLibraryChanged(LibraryChangedEvent event) {
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the tree and repopulates it from {@link LibraryFolderService}. The clear happens in the same
|
||||
* FX-thread callback as the repopulation, not a separate one dispatched from wherever the caller runs
|
||||
* — two independently scheduled {@code Platform.runLater} calls have no ordering guarantee between
|
||||
* them, and a clear arriving after the folders it was meant to precede would flash the empty state.
|
||||
*/
|
||||
private void reload() {
|
||||
taskService.execute(TaskType.BACKGROUND_SYNC, () -> {
|
||||
List<LibraryFolder> folders = libraryFolderService.list();
|
||||
FxUtils.onFxThread(() -> {
|
||||
tree.getRoot().getChildren().clear();
|
||||
folders.forEach(this::appendFolder);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void appendFolder(LibraryFolder folder) {
|
||||
tree.getRoot().getChildren().add(new FolderTreeItem(folder.path(), taskService));
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a root's row once {@link LibraryFolderService} confirms the deletion. Triggered only from
|
||||
* this row's own "-" button, so there is nothing else that needs telling — unlike an add, which can
|
||||
* arrive from elsewhere, a remove always originates here.
|
||||
*/
|
||||
private void removeFolder(TreeItem<Path> item) {
|
||||
Path path = item.getValue();
|
||||
taskService.execute(TaskType.BACKGROUND_SYNC, () -> {
|
||||
libraryFolderService.remove(path);
|
||||
FxUtils.onFxThread(() -> {
|
||||
tree.getRoot().getChildren().remove(item);
|
||||
updateEmptyState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void updateEmptyState() {
|
||||
boolean configured = !tree.getRoot().getChildren().isEmpty();
|
||||
tree.setVisible(configured);
|
||||
tree.setManaged(configured);
|
||||
notConfigured.setVisible(!configured);
|
||||
@@ -123,6 +194,14 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes {@code item} the same way its own "-" button would. For tests; the button is inside a
|
||||
* {@code TreeCell} graphic, not reachable without a fully rendered scene.
|
||||
*/
|
||||
void removeForTest(TreeItem<Path> item) {
|
||||
removeFolder(item);
|
||||
}
|
||||
|
||||
private void onFolderSelected(TreeItem<Path> item) {
|
||||
if (item != null) {
|
||||
log.debug("Library folder selected: {}", item.getValue());
|
||||
@@ -133,32 +212,71 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
|
||||
public void dispose() {
|
||||
scope.close();
|
||||
notConfigured.textProperty().unbind();
|
||||
addFolder.getTooltip().textProperty().unbind();
|
||||
addFolder.setOnAction(null);
|
||||
tree.setRoot(null);
|
||||
tree.setCellFactory(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* One folder row: an icon and the folder's name.
|
||||
*
|
||||
* <p>The root shows its full path instead — it is the only row whose parent directory is not on screen,
|
||||
* so its name alone would not say which library is being displayed.
|
||||
* One folder row: an icon, the folder's name, and — for a root only — a right-aligned actions area
|
||||
* ("label .... -") holding the remove button. The actions box exists on every row so future icons
|
||||
* (rescan, reveal in file manager, …) can be added at any depth without touching the layout again; a
|
||||
* subfolder's box just stays empty for now.
|
||||
*/
|
||||
private static final class FolderCell extends TreeCell<Path> {
|
||||
|
||||
private final FontIcon icon = FontIcon.of(Feather.FOLDER, 14);
|
||||
private final FontIcon icon = FontIcon.of(Feather.FOLDER, 14);
|
||||
private final Label label = new Label();
|
||||
private final HBox actions = new HBox(4);
|
||||
private final HBox row;
|
||||
|
||||
private final Consumer<TreeItem<Path>> onRemove;
|
||||
|
||||
private FolderCell(Consumer<TreeItem<Path>> onRemove) {
|
||||
this.onRemove = onRemove;
|
||||
|
||||
actions.getStyleClass().add("folder-actions");
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
|
||||
row = new HBox(6, icon, label, spacer, actions);
|
||||
row.setAlignment(Pos.CENTER_LEFT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Path item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
actions.getChildren().clear();
|
||||
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
Path name = item.getFileName();
|
||||
boolean isRoot = getTreeItem() != null && getTreeItem().getParent() == null;
|
||||
setText(isRoot || name == null ? item.toString() : name.toString());
|
||||
setGraphic(icon);
|
||||
|
||||
Path name = item.getFileName();
|
||||
label.setText(name == null ? item.toString() : name.toString());
|
||||
setGraphic(row);
|
||||
|
||||
boolean isRoot = getTreeItem() != null && getTreeItem().getParent() != null
|
||||
&& getTreeItem().getParent().getParent() == null;
|
||||
if (isRoot) {
|
||||
setTooltip(new Tooltip(item.toString()));
|
||||
actions.getChildren().add(removeButton());
|
||||
} else {
|
||||
setTooltip(null);
|
||||
}
|
||||
}
|
||||
|
||||
private Button removeButton() {
|
||||
Button remove = new Button();
|
||||
remove.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT, Styles.SMALL);
|
||||
remove.setGraphic(FontIcon.of(Feather.MINUS, 12));
|
||||
remove.setOnAction(event -> onRemove.accept(getTreeItem()));
|
||||
return remove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import javafx.scene.Node;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
import org.icroco.pholio.ui.common.FxUtils;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Shows dialogs inside the scenegraph instead of in OS windows.
|
||||
*
|
||||
@@ -25,12 +28,12 @@ public class ModalService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ModalService.class);
|
||||
|
||||
private final ApplicationContext context;
|
||||
private final @Nullable ApplicationContext context;
|
||||
private final ModalPane modalPane = new ModalPane();
|
||||
|
||||
private Node currentContent;
|
||||
private @Nullable Node currentContent;
|
||||
|
||||
public ModalService(ApplicationContext context) {
|
||||
public ModalService(@Nullable ApplicationContext context) {
|
||||
this.context = context;
|
||||
modalPane.setAlignment(Pos.CENTER);
|
||||
// Non-persistent: clicking the scrim or pressing Escape dismisses, which is what users expect of
|
||||
@@ -60,7 +63,8 @@ public class ModalService {
|
||||
public void show(Class<? extends Node> contentType, boolean persistent) {
|
||||
FxUtils.onFxThread(() -> {
|
||||
releaseContent();
|
||||
Node content = context.getBean(contentType);
|
||||
// Only reached when a modal is actually shown; only tests that never show one pass a null context.
|
||||
Node content = Objects.requireNonNull(context).getBean(contentType);
|
||||
currentContent = content;
|
||||
modalPane.setPersistent(persistent);
|
||||
modalPane.show(content);
|
||||
|
||||
@@ -5,18 +5,21 @@ import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.icroco.pholio.ui.i18n.I18nService;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The panel that opens to the right of the icon rail, titled with the destination it is open on.
|
||||
@@ -42,6 +45,7 @@ public class NavigationDrawer extends VBox implements Disposable {
|
||||
|
||||
private final Map<NavigationDestination, NavigationDrawerSection> sections;
|
||||
private final Label title = new Label();
|
||||
private final HBox header = new HBox(title);
|
||||
private final Label placeholder = new Label();
|
||||
private final StackPane body = new StackPane();
|
||||
|
||||
@@ -52,13 +56,14 @@ public class NavigationDrawer extends VBox implements Disposable {
|
||||
getStyleClass().add("navigation-drawer");
|
||||
|
||||
title.getStyleClass().add(Styles.TITLE_3);
|
||||
title.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(title, Priority.ALWAYS);
|
||||
|
||||
placeholder.getStyleClass().add(Styles.TEXT_MUTED);
|
||||
placeholder.textProperty().bind(i18n.binding("nav.drawer.empty"));
|
||||
placeholder.setWrapText(true);
|
||||
|
||||
VBox header = new VBox(title);
|
||||
header.getStyleClass().add("drawer-header");
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
header.setPadding(new Insets(2, 10, 2, 10));
|
||||
|
||||
body.setAlignment(Pos.TOP_LEFT);
|
||||
@@ -78,6 +83,9 @@ public class NavigationDrawer extends VBox implements Disposable {
|
||||
title.textProperty().bind(i18n.binding(destination.messageKey()));
|
||||
|
||||
NavigationDrawerSection section = sections.get(destination);
|
||||
header.getChildren().setAll(section == null
|
||||
? List.of(title)
|
||||
: Stream.concat(Stream.of(title), section.titleAccessory().stream()).toList());
|
||||
body.getChildren().setAll(section == null ? placeholder : section.content());
|
||||
}
|
||||
|
||||
@@ -91,10 +99,18 @@ public class NavigationDrawer extends VBox implements Disposable {
|
||||
/**
|
||||
* The node currently under the title. For tests.
|
||||
*/
|
||||
Node bodyContent() {
|
||||
@Nullable Node bodyContent() {
|
||||
return body.getChildren().isEmpty() ? null : body.getChildren().getFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything currently in the header row — the title, plus the active section's accessory if it has
|
||||
* one. For tests.
|
||||
*/
|
||||
List<Node> headerChildren() {
|
||||
return List.copyOf(header.getChildren());
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes the discovered sections, refusing two claims on one destination.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.icroco.pholio.ui.shell;
|
||||
import javafx.scene.Node;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The widget shown in the navigation drawer for one destination.
|
||||
*
|
||||
@@ -29,4 +31,14 @@ public interface NavigationDrawerSection extends Disposable {
|
||||
* position — survives leaving the section and coming back.
|
||||
*/
|
||||
Node content();
|
||||
|
||||
/**
|
||||
* An optional small control shown next to the drawer's shared title while this section is active —
|
||||
* e.g. an "add" button. Rendered inside the same header row as the title, so a purely-CSS
|
||||
* {@code :hover} rule on the header can reveal it. Absent by default: only sections that need one
|
||||
* override this.
|
||||
*/
|
||||
default Optional<Node> titleAccessory() {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.icroco.pholio.ui.common.SubscriptionScope;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.icroco.pholio.ui.event.ViewType;
|
||||
import org.icroco.pholio.ui.i18n.I18nService;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -100,13 +101,13 @@ public class NavigationRail extends BorderPane implements Disposable {
|
||||
* The destination the drawer is open on, kept across a close. Null only for a module with no
|
||||
* destinations, where there is nothing to be active.
|
||||
*/
|
||||
private Entry active;
|
||||
private @Nullable Entry active;
|
||||
|
||||
/**
|
||||
* The module {@link #entries} was last built for. Guards {@link #rebuild} against redoing the same
|
||||
* work if the property fires again with an unchanged value.
|
||||
*/
|
||||
private ViewType currentModule;
|
||||
private @Nullable ViewType currentModule;
|
||||
|
||||
public NavigationRail(I18nService i18n,
|
||||
AppPreferences preferences,
|
||||
@@ -156,7 +157,7 @@ public class NavigationRail extends BorderPane implements Disposable {
|
||||
* the same choice {@link InspectorPanel} makes for an empty selection, and for the same reason. An
|
||||
* invisible-but-managed rail would keep reserving 44px of a viewport that has nothing to put there.
|
||||
*/
|
||||
private void rebuild(ViewType module) {
|
||||
private void rebuild(@Nullable ViewType module) {
|
||||
ViewType effective = module == null ? ViewType.GALLERY : module;
|
||||
if (effective == currentModule) {
|
||||
return;
|
||||
|
||||
@@ -194,7 +194,7 @@ public class SettingsView extends BorderPane implements Disposable {
|
||||
|
||||
private Row addRow(GridPane grid, int index, String group, String key, PreferenceItem<?> item) {
|
||||
Label label = new Label();
|
||||
label.textProperty().bind(i18n.bindingOr(item.getLabel(), key));
|
||||
label.textProperty().bind(i18n.bindingOr(Objects.requireNonNullElse(item.getLabel(), key), key));
|
||||
scope.addTeardown(() -> label.textProperty().unbind());
|
||||
|
||||
Region control = controlFor(group, key, item);
|
||||
@@ -279,7 +279,7 @@ public class SettingsView extends BorderPane implements Disposable {
|
||||
// caps the popup and scrolls it.
|
||||
ComboBox<String> choice = new ComboBox<>();
|
||||
choice.getItems().setAll(item.getOptions());
|
||||
choice.setConverter(optionConverter(item.getLabel()));
|
||||
choice.setConverter(optionConverter(Objects.requireNonNullElse(item.getLabel(), key)));
|
||||
choice.setPrefWidth(200);
|
||||
choice.setVisibleRowCount(12);
|
||||
bind(choice.valueProperty(), value);
|
||||
@@ -433,7 +433,7 @@ public class SettingsView extends BorderPane implements Disposable {
|
||||
@SuppressWarnings("unchecked")
|
||||
ComboBox<String> choice = (ComboBox<String>) box;
|
||||
String selected = choice.getValue();
|
||||
choice.setConverter(optionConverter(row.item.getLabel()));
|
||||
choice.setConverter(optionConverter(Objects.requireNonNullElse(row.item.getLabel(), row.key)));
|
||||
// Re-setting the value is what forces the button cell to re-render; replacing the converter
|
||||
// alone leaves the previous text in place.
|
||||
choice.setValue(selected);
|
||||
@@ -494,7 +494,7 @@ public class SettingsView extends BorderPane implements Disposable {
|
||||
}
|
||||
|
||||
private boolean matches(String needle, I18nService i18n) {
|
||||
return contains(i18n.resolveOr(item.getLabel(), key), needle)
|
||||
return contains(i18n.resolveOr(Objects.requireNonNullElse(item.getLabel(), key), key), needle)
|
||||
|| contains(key, needle)
|
||||
|| contains(group, needle);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.icroco.pholio.ui.common.FxUtils;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.icroco.pholio.ui.event.TaskFinishedEvent;
|
||||
import org.icroco.pholio.ui.i18n.I18nService;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.kordamp.ikonli.feather.Feather;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
import org.slf4j.Logger;
|
||||
@@ -116,7 +117,7 @@ public class ToastLayer extends VBox implements Disposable {
|
||||
: event.title();
|
||||
}
|
||||
|
||||
private void dismiss(Notification toast, PauseTransition dwell) {
|
||||
private void dismiss(Notification toast, @Nullable PauseTransition dwell) {
|
||||
getChildren().remove(toast);
|
||||
if (dwell != null) {
|
||||
pending.remove(dwell);
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.icroco.pholio.ui.theme;
|
||||
|
||||
import atlantafx.base.theme.*;
|
||||
import com.dlsc.atlantafx.themes.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.function.Supplier;
|
||||
@@ -73,7 +74,7 @@ public enum AppTheme {
|
||||
* {@code atlantafx-base}; were that ever to break, an eager field would turn one unusable theme into
|
||||
* an {@link ExceptionInInitializerError} that takes the whole enum — and the application — with it.
|
||||
*/
|
||||
private volatile Theme resolved;
|
||||
private volatile @Nullable Theme resolved;
|
||||
|
||||
AppTheme(Supplier<Theme> factory) {
|
||||
this.factory = factory;
|
||||
@@ -127,7 +128,7 @@ public enum AppTheme {
|
||||
/**
|
||||
* Parses a persisted value, falling back to {@link #DEFAULT} for anything unrecognised.
|
||||
*/
|
||||
public static AppTheme parse(String value) {
|
||||
public static AppTheme parse(@Nullable String value) {
|
||||
if (value != null) {
|
||||
String name = value.trim().toUpperCase(Locale.ROOT);
|
||||
for (AppTheme theme : values()) {
|
||||
|
||||
@@ -8,7 +8,9 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.DirectoryChooser;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderService;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.icroco.pholio.infra.task.TaskType;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
import org.icroco.pholio.ui.common.SubscriptionScope;
|
||||
import org.icroco.pholio.ui.common.UiView;
|
||||
@@ -19,6 +21,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Main viewport: the thumbnail grid over the library.
|
||||
@@ -32,21 +35,19 @@ public class GalleryView extends StackPane implements Disposable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GalleryView.class);
|
||||
|
||||
/** Group and key of the library root, as declared in {@code preferences.yaml}. */
|
||||
private static final String LIBRARY_GROUP = "library";
|
||||
private static final String ROOT_PATH_KEY = "root-path";
|
||||
private final I18nService i18n;
|
||||
private final LibraryFolderService libraryFolderService;
|
||||
private final TaskService taskService;
|
||||
|
||||
private final I18nService i18n;
|
||||
private final AppPreferences preferences;
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final Label title = new Label();
|
||||
private final Label subtitle = new Label();
|
||||
private final Button chooseRoot = new Button();
|
||||
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final Label title = new Label();
|
||||
private final Label subtitle = new Label();
|
||||
private final Button chooseRoot = new Button();
|
||||
|
||||
public GalleryView(I18nService i18n, AppPreferences preferences) {
|
||||
public GalleryView(I18nService i18n, LibraryFolderService libraryFolderService, TaskService taskService) {
|
||||
this.i18n = i18n;
|
||||
this.preferences = preferences;
|
||||
this.libraryFolderService = libraryFolderService;
|
||||
this.taskService = taskService;
|
||||
|
||||
getStyleClass().add("empty-state");
|
||||
|
||||
@@ -68,7 +69,7 @@ public class GalleryView extends StackPane implements Disposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the library root.
|
||||
* Picks a library folder to add.
|
||||
*
|
||||
* <p>A native {@link DirectoryChooser} is used deliberately, and is not a violation of the
|
||||
* "no OS dialogs" rule: that rule is about application dialogs, which must stay themed and in-scene.
|
||||
@@ -77,19 +78,15 @@ public class GalleryView extends StackPane implements Disposable {
|
||||
*/
|
||||
private void chooseLibraryRoot() {
|
||||
DirectoryChooser chooser = new DirectoryChooser();
|
||||
chooser.setTitle(i18n.get("settings.library.rootPath"));
|
||||
|
||||
preferences.text(LIBRARY_GROUP, ROOT_PATH_KEY)
|
||||
.map(File::new)
|
||||
.filter(File::isDirectory)
|
||||
.ifPresent(chooser::setInitialDirectory);
|
||||
chooser.setTitle(i18n.get("library.chooseFolder.dialogTitle"));
|
||||
|
||||
File selected = chooser.showDialog(getScene() != null ? getScene().getWindow() : null);
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
log.info("Library root set to {}", selected);
|
||||
preferences.setValue(LIBRARY_GROUP, ROOT_PATH_KEY, selected.getAbsolutePath());
|
||||
Path chosen = selected.toPath();
|
||||
log.info("Adding library folder {}", chosen);
|
||||
taskService.execute(TaskType.BACKGROUND_SYNC, () -> libraryFolderService.add(chosen));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.icroco.pholio.ui.window;
|
||||
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -26,7 +27,8 @@ public final class ScreenBoundsValidator {
|
||||
*
|
||||
* @param minVisibleFraction fraction of the window's area that must be visible, in {@code (0, 1]}
|
||||
*/
|
||||
public static boolean isVisibleEnough(Rectangle2D window, List<Rectangle2D> screens, double minVisibleFraction) {
|
||||
public static boolean isVisibleEnough(@Nullable Rectangle2D window, @Nullable List<Rectangle2D> screens,
|
||||
double minVisibleFraction) {
|
||||
if (window == null || screens == null || screens.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import org.icroco.pholio.infra.preferences.WindowGeometry;
|
||||
import org.icroco.pholio.infra.preferences.WindowGeometryPreferences;
|
||||
import org.icroco.pholio.infra.support.Debouncer;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -42,7 +40,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
@UiComponent
|
||||
public class WindowStateManager {
|
||||
|
||||
/** Window id of the primary application window. */
|
||||
/**
|
||||
* Window id of the primary application window.
|
||||
*/
|
||||
public static final String MAIN_WINDOW = "main";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WindowStateManager.class);
|
||||
@@ -55,8 +55,8 @@ public class WindowStateManager {
|
||||
private static final Duration CAPTURE_DELAY = Duration.ofMillis(250);
|
||||
|
||||
private final WindowGeometryPreferences geometries;
|
||||
private final PholioProperties properties;
|
||||
private final Map<String, Tracker> trackers = new ConcurrentHashMap<>();
|
||||
private final PholioProperties properties;
|
||||
private final Map<String, Tracker> trackers = new ConcurrentHashMap<>();
|
||||
|
||||
public WindowStateManager(WindowGeometryPreferences geometries, PholioProperties properties) {
|
||||
this.geometries = geometries;
|
||||
@@ -74,7 +74,9 @@ public class WindowStateManager {
|
||||
tracker.attach();
|
||||
}
|
||||
|
||||
/** Writes {@code windowId}'s current geometry immediately. Used from the close handler. */
|
||||
/**
|
||||
* Writes {@code windowId}'s current geometry immediately. Used from the close handler.
|
||||
*/
|
||||
public void captureNow(String windowId) {
|
||||
Tracker tracker = trackers.get(windowId);
|
||||
if (tracker != null) {
|
||||
@@ -82,7 +84,9 @@ public class WindowStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops tracking a window and flushes its pending geometry. For secondary windows being closed. */
|
||||
/**
|
||||
* Stops tracking a window and flushes its pending geometry. For secondary windows being closed.
|
||||
*/
|
||||
public void unregister(String windowId) {
|
||||
Tracker tracker = trackers.remove(windowId);
|
||||
if (tracker != null) {
|
||||
@@ -104,17 +108,23 @@ public class WindowStateManager {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/** Per-window state: its own debouncer, listeners, and remembered non-maximised bounds. */
|
||||
/**
|
||||
* Per-window state: its own debouncer, listeners, and remembered non-maximised bounds.
|
||||
*/
|
||||
private final class Tracker implements AutoCloseable {
|
||||
|
||||
private final String windowId;
|
||||
private final Stage stage;
|
||||
private final String windowId;
|
||||
private final Stage stage;
|
||||
private final Debouncer debouncer;
|
||||
|
||||
/** Bounds to persist. Updated only while the window is neither maximised nor iconified. */
|
||||
private Rectangle2D restoredBounds;
|
||||
/**
|
||||
* Bounds to persist. Updated only while the window is neither maximised nor iconified.
|
||||
*/
|
||||
private @Nullable Rectangle2D restoredBounds;
|
||||
|
||||
/** Suppresses capture while {@link #restore()} is mutating the stage. */
|
||||
/**
|
||||
* Suppresses capture while {@link #restore()} is mutating the stage.
|
||||
*/
|
||||
private boolean restoring;
|
||||
|
||||
private boolean attached;
|
||||
@@ -133,8 +143,9 @@ public class WindowStateManager {
|
||||
// hasSize()/hasPosition() are there to test.
|
||||
WindowGeometry saved = geometries.of(windowId);
|
||||
|
||||
double width = saved.hasSize() ? saved.width() : defaults.defaultWidth();
|
||||
double height = saved.hasSize() ? saved.height() : defaults.defaultHeight();
|
||||
// hasSize() guarantees width and height are non-null.
|
||||
double width = saved.hasSize() ? Objects.requireNonNull(saved.width()) : defaults.defaultWidth();
|
||||
double height = saved.hasSize() ? Objects.requireNonNull(saved.height()) : defaults.defaultHeight();
|
||||
|
||||
stage.setMinWidth(defaults.minWidth());
|
||||
stage.setMinHeight(defaults.minHeight());
|
||||
@@ -142,10 +153,12 @@ public class WindowStateManager {
|
||||
stage.setHeight(height);
|
||||
|
||||
Optional<Rectangle2D> usablePosition = Optional.of(saved)
|
||||
.filter(WindowGeometry::hasPosition)
|
||||
.map(geometry -> new Rectangle2D(geometry.x(), geometry.y(), width, height))
|
||||
.filter(rect -> ScreenBoundsValidator.isVisibleEnough(
|
||||
rect, connectedScreenBounds(), defaults.minVisibleFraction()));
|
||||
.filter(WindowGeometry::hasPosition)
|
||||
// hasPosition() guarantees x and y are non-null.
|
||||
.map(geometry -> new Rectangle2D(Objects.requireNonNull(geometry.x()),
|
||||
Objects.requireNonNull(geometry.y()), width, height))
|
||||
.filter(rect -> ScreenBoundsValidator.isVisibleEnough(
|
||||
rect, connectedScreenBounds(), defaults.minVisibleFraction()));
|
||||
|
||||
if (usablePosition.isPresent()) {
|
||||
stage.setX(usablePosition.get().getMinX());
|
||||
@@ -158,7 +171,7 @@ public class WindowStateManager {
|
||||
stage.setY(centred.getMinY());
|
||||
if (saved.hasPosition()) {
|
||||
log.info("Saved position for window '{}' is not on any connected screen; "
|
||||
+ "centring on the primary display", windowId);
|
||||
+ "centring on the primary display", windowId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +180,8 @@ public class WindowStateManager {
|
||||
if (saved.maximized()) {
|
||||
stage.setMaximized(true);
|
||||
}
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,17 @@ spring:
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
autoconfigure:
|
||||
exclude:
|
||||
# Boot's FlywayAutoConfiguration would build one Flyway bean against the single routing DataSource
|
||||
# at context-refresh time, before any library is open — LibraryRoutingDataSource.determineTargetDataSource
|
||||
# requires a library to already be selected. Each library is its own H2 file needing its own schema, so
|
||||
# migration instead runs once per pool, inside PersistenceConfiguration.pool(...).
|
||||
#
|
||||
# Package as of Boot 4.1, where Flyway's autoconfiguration moved out of spring-boot-autoconfigure into
|
||||
# its own spring-boot-flyway module.
|
||||
- org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration
|
||||
flyway:
|
||||
# Enabled from the persistence phase onwards, once db/migration contains the schema.
|
||||
enabled: false
|
||||
locations: classpath:db/migration
|
||||
messages:
|
||||
basename: messages
|
||||
|
||||
@@ -123,6 +123,30 @@
|
||||
-fx-padding: 0 0 6 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A section's title accessory (e.g. the "+" that adds a library folder): invisible until the header row
|
||||
* itself is hovered, so it does not compete with the title for attention on every visit to the drawer.
|
||||
*/
|
||||
.navigation-drawer .drawer-header .drawer-title-accessory {
|
||||
-fx-opacity: 0;
|
||||
}
|
||||
|
||||
.navigation-drawer .drawer-header:hover .drawer-title-accessory {
|
||||
-fx-opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Per-row action icons (currently just "remove a root folder"): same hover convention as the title
|
||||
* accessory above, scoped to the row instead of the header.
|
||||
*/
|
||||
.navigation-drawer .tree-cell .folder-actions {
|
||||
-fx-opacity: 0;
|
||||
}
|
||||
|
||||
.navigation-drawer .tree-cell:hover .folder-actions {
|
||||
-fx-opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tweaks.EDGE_TO_EDGE drops the tree's border, but not its opaque background: without this the tree
|
||||
* paints a lighter rectangle over the drawer surface.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Identifiers are quoted so H2 stores them exactly as written (lower snake_case); left unquoted, H2 folds
|
||||
-- them to upper case by default, and Spring Data JDBC's generated SQL quotes lower snake_case identifiers,
|
||||
-- so an unquoted table here would be invisible to it ("library_folder" != LIBRARY_FOLDER).
|
||||
CREATE TABLE "library_folder"
|
||||
(
|
||||
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"path" VARCHAR(2048) NOT NULL,
|
||||
"added_at" TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "uk_library_folder_path" UNIQUE ("path")
|
||||
);
|
||||
|
||||
CREATE TABLE "library_subfolder"
|
||||
(
|
||||
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"library_folder_id" BIGINT NOT NULL REFERENCES "library_folder" ("id") ON DELETE CASCADE,
|
||||
"path" VARCHAR(2048) NOT NULL,
|
||||
"last_scanned_at" TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "uk_library_subfolder_path" UNIQUE ("library_folder_id", "path")
|
||||
);
|
||||
|
||||
CREATE INDEX "ix_library_subfolder_folder" ON "library_subfolder" ("library_folder_id");
|
||||
@@ -2,14 +2,12 @@
|
||||
# and for Locale.ENGLISH itself. French lives in messages_fr.properties.
|
||||
app.name=Pholio
|
||||
app.title=Pholio — Photo library manager
|
||||
|
||||
# --- Modules (header bar) ---
|
||||
module.import=Import
|
||||
module.cull=Cull
|
||||
module.reorganize=Reorganize
|
||||
module.maintenance=Maintenance
|
||||
module.export=Export
|
||||
|
||||
# --- Navigation rail ---
|
||||
nav.library=Library
|
||||
nav.albums=Albums
|
||||
@@ -20,7 +18,10 @@ nav.rejected=Rejected
|
||||
nav.filters=Quick filters
|
||||
# Shown in the drawer for destinations that have no widget yet.
|
||||
nav.drawer.empty=Nothing here yet
|
||||
|
||||
# --- Library folders (Photothèque drawer) ---
|
||||
library.addFolder.tooltip=Add a folder…
|
||||
library.chooseFolder.dialogTitle=Choose a folder to add
|
||||
library.removeFolder.tooltip=Remove this folder
|
||||
# --- Header bar actions ---
|
||||
action.search.prompt=Search the library…
|
||||
action.settings=Preferences
|
||||
@@ -33,30 +34,25 @@ action.cancel=Cancel
|
||||
action.apply=Apply
|
||||
action.reset=Reset
|
||||
action.applyAndSave=Apply & save
|
||||
|
||||
# --- Inspector ---
|
||||
inspector.details.title=Details
|
||||
# --- Background tasks (popover anchored on the status bar) ---
|
||||
tasks.title=Background tasks
|
||||
tasks.empty=No running task
|
||||
tasks.cancel=Cancel this task
|
||||
|
||||
# --- Toasts ---
|
||||
toast.task.succeeded={0} finished
|
||||
toast.task.failed={0} failed
|
||||
toast.task.untitled=Background task
|
||||
|
||||
# --- Status bar ---
|
||||
status.library.photos={0} photos
|
||||
status.library.notConfigured=No library configured
|
||||
status.tasks.idle=Idle
|
||||
status.tasks.running={0} task(s) running
|
||||
|
||||
# --- Gallery ---
|
||||
gallery.empty.title=Your library is empty
|
||||
gallery.empty.subtitle=Set a root directory, then import or scan your photos.
|
||||
gallery.chooseRoot=Choose a root directory…
|
||||
|
||||
# --- Settings window ---
|
||||
# Group titles follow the settings.group.<group> convention, where <group> is a top-level key of
|
||||
# preferences.yaml. Row labels are whatever `label` that file declares; a label matching no key here is
|
||||
@@ -94,7 +90,6 @@ settings.window.height=Window height
|
||||
settings.window.maximized=Window maximised
|
||||
settings.note.windowGeometry=Window position and size are saved automatically and can only be changed \
|
||||
by editing preferences.yaml.
|
||||
|
||||
# Option labels for enumerated preferences, keyed <label>.<option in lower case>. A missing key falls
|
||||
# back to the raw option value, so a new option is usable before it is translated.
|
||||
settings.appearance.language.fr=Français
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# Reference bundle. Every user-visible string in the application must have a key here.
|
||||
app.name=Pholio
|
||||
app.title=Pholio — Gestion de photothèque
|
||||
|
||||
# --- Modules (header bar) ---
|
||||
module.import=Importer
|
||||
module.cull=Trier
|
||||
module.reorganize=Réorganiser
|
||||
module.maintenance=Maintenance
|
||||
module.export=Exporter
|
||||
|
||||
# --- Navigation rail ---
|
||||
nav.library=Photothèque
|
||||
nav.albums=Albums
|
||||
@@ -19,7 +17,10 @@ nav.rejected=Rejetées
|
||||
nav.filters=Filtres rapides
|
||||
# Affiché dans le tiroir pour les destinations qui n'ont pas encore de widget.
|
||||
nav.drawer.empty=Rien ici pour l'instant
|
||||
|
||||
# --- Dossiers de la photothèque (tiroir) ---
|
||||
library.addFolder.tooltip=Ajouter un dossier…
|
||||
library.chooseFolder.dialogTitle=Choisir un dossier à ajouter
|
||||
library.removeFolder.tooltip=Retirer ce dossier
|
||||
# --- Header bar actions ---
|
||||
action.search.prompt=Rechercher dans la photothèque…
|
||||
action.settings=Préférences
|
||||
@@ -32,30 +33,25 @@ action.cancel=Annuler
|
||||
action.apply=Appliquer
|
||||
action.reset=Réinitialiser
|
||||
action.applyAndSave=Appliquer & enregistrer
|
||||
|
||||
# --- Inspector ---
|
||||
inspector.details.title=Détails
|
||||
# --- Tâches de fond (ancrées sous l'inspecteur) ---
|
||||
tasks.title=Tâches de fond
|
||||
tasks.empty=Aucune tâche en cours
|
||||
tasks.cancel=Annuler cette tâche
|
||||
|
||||
# --- Notifications éphémères ---
|
||||
toast.task.succeeded={0} terminé
|
||||
toast.task.failed=Échec : {0}
|
||||
toast.task.untitled=Tâche de fond
|
||||
|
||||
# --- Status bar ---
|
||||
status.library.photos={0} photos
|
||||
status.library.notConfigured=Aucune photothèque configurée
|
||||
status.tasks.idle=Inactif
|
||||
status.tasks.running={0} tâche(s) en cours
|
||||
|
||||
# --- Gallery ---
|
||||
gallery.empty.title=Votre photothèque est vide
|
||||
gallery.empty.subtitle=Définissez un répertoire racine, puis importez ou analysez vos photos.
|
||||
gallery.chooseRoot=Choisir un répertoire racine…
|
||||
|
||||
# --- Fenêtre de préférences ---
|
||||
# Les titres de groupe suivent la convention settings.group.<groupe>, où <groupe> est une clé de premier
|
||||
# niveau de preferences.yaml. Les libellés de ligne sont ceux que ce fichier déclare dans `label` ; un
|
||||
@@ -93,7 +89,6 @@ settings.window.height=Hauteur de la fenêtre
|
||||
settings.window.maximized=Fenêtre maximisée
|
||||
settings.note.windowGeometry=La position et la taille des fenêtres sont enregistrées automatiquement et \
|
||||
ne sont modifiables que dans le fichier preferences.yaml.
|
||||
|
||||
# Libellés des options énumérées, clé <label>.<option en minuscules>. Une clé absente laisse place à la
|
||||
# valeur brute, de sorte qu'une nouvelle option est utilisable avant d'être traduite.
|
||||
settings.appearance.language.fr=Français
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The recursive walk {@link LibraryFolderService} runs in the background after adding a root — no Spring
|
||||
* context needed, just a real directory tree.
|
||||
*/
|
||||
class LibraryFolderScannerTest {
|
||||
|
||||
private final LibraryFolderScanner scanner = new LibraryFolderScanner();
|
||||
|
||||
@Test
|
||||
void findsEveryVisibleSubdirectoryAtEveryDepth(@TempDir Path root) throws IOException {
|
||||
Files.createDirectories(root.resolve("2024/summer"));
|
||||
Files.createDirectories(root.resolve("2024/winter"));
|
||||
Files.createDirectories(root.resolve("2025"));
|
||||
Files.writeString(root.resolve("2024/photo.jpg"), "not really");
|
||||
|
||||
assertThat(scanner.scan(root)).containsExactlyInAnyOrder(
|
||||
root.resolve("2024"),
|
||||
root.resolve("2024/summer"),
|
||||
root.resolve("2024/winter"),
|
||||
root.resolve("2025"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesHiddenDirectories(@TempDir Path root) throws IOException {
|
||||
Files.createDirectories(root.resolve("2024"));
|
||||
Files.createDirectories(root.resolve(".cache"));
|
||||
|
||||
assertThat(scanner.scan(root)).containsExactly(root.resolve("2024"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnreadableDirectoryDoesNotAbortTheRestOfTheScan(@TempDir Path root) throws IOException {
|
||||
Path locked = root.resolve("locked");
|
||||
Files.createDirectories(locked.resolve("hidden-from-us"));
|
||||
Files.createDirectories(root.resolve("visible"));
|
||||
|
||||
boolean revoked = locked.toFile().setReadable(false);
|
||||
// Root frequently ignores POSIX permissions; skip the assertion on a platform/user where that
|
||||
// happened rather than fail on an environment quirk unrelated to the scanner's own logic.
|
||||
if (!revoked || Files.isReadable(locked)) {
|
||||
assertThat(scanner.scan(root)).contains(root.resolve("visible"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
assertThat(scanner.scan(root)).containsExactlyInAnyOrder(locked, root.resolve("visible"));
|
||||
}
|
||||
finally {
|
||||
locked.toFile().setReadable(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNothingForALeafDirectory(@TempDir Path root) {
|
||||
assertThat(scanner.scan(root)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.icroco.pholio.infra.library;
|
||||
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.Profiles;
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.icroco.pholio.domain.library.LibrarySubfolder;
|
||||
import org.icroco.pholio.infra.persistence.folder.LibraryFolderMapper;
|
||||
import org.icroco.pholio.infra.persistence.folder.LibraryFolderRepository;
|
||||
import org.icroco.pholio.infra.persistence.folder.LibrarySubfolderMapper;
|
||||
import org.icroco.pholio.infra.persistence.folder.LibrarySubfolderRepository;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.preferences.PreferencesFixture;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* {@link LibraryFolderService} against the real schema — the {@code cli} profile loads the ordinary
|
||||
* headless context, routing off, so the fixed in-memory URL in {@code src/test/resources/application.yaml}
|
||||
* is migrated by Boot's own {@code FlywayAutoConfiguration} the normal way; only the production
|
||||
* per-library-pool path needs the manual migration in {@code PersistenceConfiguration}.
|
||||
*
|
||||
* <p>The service under test is built by hand, not autowired, so every {@code TaskService} dispatch runs
|
||||
* with {@code Runnable::run} — the background subfolder scan then finishes before {@code add} returns,
|
||||
* with nothing to await.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles(Profiles.CLI)
|
||||
class LibraryFolderServiceTest {
|
||||
|
||||
@Autowired
|
||||
private LibraryFolderRepository repository;
|
||||
@Autowired
|
||||
private LibraryFolderMapper mapper;
|
||||
@Autowired
|
||||
private LibrarySubfolderRepository subfolderRepository;
|
||||
@Autowired
|
||||
private LibrarySubfolderMapper subfolderMapper;
|
||||
@Autowired
|
||||
private LibraryFolderScanner scanner;
|
||||
|
||||
private AppPreferences preferences;
|
||||
private List<Object> events;
|
||||
private LibraryFolderService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
preferences = PreferencesFixture.fromBundledSchema();
|
||||
events = new ArrayList<>();
|
||||
|
||||
Executor inert = Runnable::run;
|
||||
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, events::add);
|
||||
|
||||
service = new LibraryFolderService(repository, mapper, subfolderRepository, subfolderMapper, scanner,
|
||||
preferences, events::add, taskService);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
subfolderRepository.deleteAll();
|
||||
repository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addPersistsANewFolderAndPublishesTheEvent(@TempDir Path folder) {
|
||||
LibraryFolder added = service.add(folder);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(added.id()).isNotNull();
|
||||
softly.assertThat(added.path()).isEqualTo(folder.toAbsolutePath());
|
||||
softly.assertThat(repository.findByPath(folder.toAbsolutePath().toString())).isPresent();
|
||||
softly.assertThat(events).containsExactly(new LibraryFolderAddedEvent(added));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void addIsIdempotentForTheSamePath(@TempDir Path folder) {
|
||||
LibraryFolder first = service.add(folder);
|
||||
LibraryFolder second = service.add(folder);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(second.id()).isEqualTo(first.id());
|
||||
softly.assertThat(repository.count()).isEqualTo(1);
|
||||
softly.assertThat(events).as("no second event for an already-known folder").hasSize(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReturnsEveryPersistedFolder(@TempDir Path first, @TempDir Path second) {
|
||||
service.add(first);
|
||||
service.add(second);
|
||||
|
||||
assertThat(service.list()).extracting(LibraryFolder::path)
|
||||
.containsExactlyInAnyOrder(first.toAbsolutePath(), second.toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addScansEverySubdirectoryRecursivelyExcludingHidden(@TempDir Path root) throws IOException {
|
||||
Files.createDirectories(root.resolve("2024/summer"));
|
||||
Files.createDirectories(root.resolve("2025"));
|
||||
Files.createDirectories(root.resolve(".cache"));
|
||||
|
||||
LibraryFolder added = service.add(root);
|
||||
|
||||
List<Path> scanned = subfolderRepository.findByLibraryFolderId(Objects.requireNonNull(added.id())).stream()
|
||||
.map(subfolderMapper::toDomain)
|
||||
.map(LibrarySubfolder::path)
|
||||
.toList();
|
||||
|
||||
assertThat(scanned).containsExactlyInAnyOrder(
|
||||
root.resolve("2024").toAbsolutePath(),
|
||||
root.resolve("2024/summer").toAbsolutePath(),
|
||||
root.resolve("2025").toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeDeletesTheFolderAndCascadesItsSubfolders(@TempDir Path root) throws IOException {
|
||||
Files.createDirectories(root.resolve("2024"));
|
||||
LibraryFolder added = service.add(root);
|
||||
|
||||
service.remove(root);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(repository.findAll()).isEmpty();
|
||||
softly.assertThat(subfolderRepository.findByLibraryFolderId(Objects.requireNonNull(added.id()))).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateLegacyRootPathIfPresentSeedsExactlyOnceWhenTableIsEmpty(@TempDir Path legacyRoot) {
|
||||
preferences.setValue("library", "root-path", legacyRoot.toString());
|
||||
|
||||
service.migrateLegacyRootPathIfPresent();
|
||||
service.migrateLegacyRootPathIfPresent();
|
||||
|
||||
assertThat(repository.findAll()).hasSize(1);
|
||||
assertThat(repository.findByPath(legacyRoot.toAbsolutePath().toString())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateLegacyRootPathIfPresentDoesNothingWhenThePreferenceIsBlank() {
|
||||
service.migrateLegacyRootPathIfPresent();
|
||||
|
||||
assertThat(repository.findAll()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.preferences.PreferencesFixture;
|
||||
import org.icroco.pholio.infra.support.PholioHome;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -184,11 +185,11 @@ class LibraryServiceTest {
|
||||
|
||||
private final List<String> opened = new ArrayList<>();
|
||||
|
||||
private String failOn;
|
||||
private String current;
|
||||
private @Nullable String failOn;
|
||||
private @Nullable String current;
|
||||
|
||||
@Override
|
||||
public String current() {
|
||||
public @Nullable String current() {
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,16 +177,19 @@ class MediaFormatRegistryTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullAway") // deliberate: the registry is not supposed to touch these
|
||||
public MetadataReader reader() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullAway") // deliberate: the registry is not supposed to touch these
|
||||
public MetadataWriter writer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullAway") // deliberate: the registry is not supposed to touch these
|
||||
public ThumbnailAccessor thumbnails() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
class LibraryFolderMapperTest {
|
||||
|
||||
private final LibraryFolderMapper mapper = new LibraryFolderMapperImpl();
|
||||
|
||||
@Test
|
||||
void roundTripsBetweenDomainAndEntity(@TempDir Path folder) {
|
||||
Instant addedAt = Instant.now();
|
||||
LibraryFolder domain = LibraryFolder.builder().id(7L).path(folder).addedAt(addedAt).build();
|
||||
|
||||
LibraryFolderEntity entity = mapper.toEntity(domain);
|
||||
LibraryFolder back = mapper.toDomain(LibraryFolderEntity.builder()
|
||||
.id(9L)
|
||||
.path(entity.getPath())
|
||||
.addedAt(entity.getAddedAt())
|
||||
.build());
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(entity.getId()).as("the entity id is assigned by the database, not copied in").isNull();
|
||||
softly.assertThat(entity.getPath()).isEqualTo(folder.toAbsolutePath().toString());
|
||||
softly.assertThat(back.path()).isEqualTo(folder.toAbsolutePath());
|
||||
softly.assertThat(back.addedAt()).isEqualTo(addedAt);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRelativePathBecomesAbsoluteOnTheEntity() {
|
||||
LibraryFolder domain = LibraryFolder.builder()
|
||||
.id(1L)
|
||||
.path(Path.of("relative", "folder"))
|
||||
.addedAt(Instant.now())
|
||||
.build();
|
||||
|
||||
LibraryFolderEntity entity = mapper.toEntity(domain);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(Path.of(entity.getPath())).isAbsolute();
|
||||
softly.assertThat(entity.getPath()).endsWith(Path.of("relative", "folder").toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package org.icroco.pholio.infra.persistence.folder;
|
||||
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.domain.library.LibrarySubfolder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
class LibrarySubfolderMapperTest {
|
||||
|
||||
private final LibrarySubfolderMapper mapper = new LibrarySubfolderMapperImpl();
|
||||
|
||||
@Test
|
||||
void roundTripsBetweenDomainAndEntity(@TempDir Path folder) {
|
||||
Instant scannedAt = Instant.now();
|
||||
LibrarySubfolder domain = new LibrarySubfolder(3L, 42L, folder, scannedAt);
|
||||
|
||||
LibrarySubfolderEntity entity = mapper.toEntity(domain);
|
||||
LibrarySubfolder back = mapper.toDomain(LibrarySubfolderEntity.builder()
|
||||
.id(9L)
|
||||
.libraryFolderId(entity.getLibraryFolderId())
|
||||
.path(entity.getPath())
|
||||
.lastScannedAt(entity.getLastScannedAt())
|
||||
.build());
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(entity.getId()).as("the entity id is assigned by the database, not copied in").isNull();
|
||||
softly.assertThat(entity.getLibraryFolderId()).isEqualTo(42L);
|
||||
softly.assertThat(entity.getPath()).isEqualTo(folder.toAbsolutePath().toString());
|
||||
softly.assertThat(back.path()).isEqualTo(folder.toAbsolutePath());
|
||||
softly.assertThat(back.libraryFolderId()).isEqualTo(42L);
|
||||
softly.assertThat(back.lastScannedAt()).isEqualTo(scannedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.icroco.pholio.infra.preferences;
|
||||
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -76,7 +78,7 @@ class PreferenceItemTest {
|
||||
softly.assertThat(PreferenceType.STRING_LIST.coerce(List.of())).isEqualTo(List.of());
|
||||
softly.assertThat(PreferenceType.STRING_LIST.coerce(null)).isNull();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> coerced = (List<String>) PreferenceType.STRING_LIST.coerce(List.of("a"));
|
||||
List<String> coerced = (List<String>) Objects.requireNonNull(PreferenceType.STRING_LIST.coerce(List.of("a")));
|
||||
softly.assertThatThrownBy(() -> coerced.add("b"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
});
|
||||
@@ -197,7 +199,7 @@ class PreferenceItemTest {
|
||||
assertThatThrownBy(item::normalise).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static PreferenceItem<Object> item(PreferenceType type, Object defaultValue) {
|
||||
private static PreferenceItem<Object> item(PreferenceType type, @Nullable Object defaultValue) {
|
||||
PreferenceItem<Object> item = new PreferenceItem<>();
|
||||
item.setType(type);
|
||||
item.setLabel("settings.test");
|
||||
|
||||
@@ -3,9 +3,11 @@ package org.icroco.pholio.ui;
|
||||
import javafx.application.Platform;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.ui.i18n.I18nService;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -26,7 +28,7 @@ public final class FxTestToolkit {
|
||||
|
||||
private static final long TIMEOUT_SECONDS = 20;
|
||||
|
||||
private static Boolean available;
|
||||
private static @Nullable Boolean available;
|
||||
|
||||
private FxTestToolkit() {
|
||||
}
|
||||
@@ -62,7 +64,8 @@ public final class FxTestToolkit {
|
||||
if (failure.get() != null) {
|
||||
throw failure.get();
|
||||
}
|
||||
return result.get();
|
||||
// work.get() ran and populated result before the latch counted down, unless it threw (handled above).
|
||||
return Objects.requireNonNull(result.get());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,7 @@ public final class FxTestToolkit {
|
||||
public static void runOnFxThread(Runnable work) {
|
||||
onFxThread(() -> {
|
||||
work.run();
|
||||
return null;
|
||||
return true; // dummy non-null result, discarded by the caller
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -105,7 +106,7 @@ class PreferenceSchemaCouplingTest {
|
||||
// Row labels, in contrast, are checked for every group: an invisible item still has to carry
|
||||
// a resolvable label for the day it is switched on.
|
||||
preferences.getGroups().forEach((group, items) -> items.forEach((key, item) -> softly
|
||||
.assertThat(messages.getMessage(item.getLabel(), null, item.getLabel(), locale))
|
||||
.assertThat(messages.getMessage(Objects.requireNonNull(item.getLabel()), null, item.getLabel(), locale))
|
||||
.as("label of %s.%s in %s", group, key, locale)
|
||||
.isNotEqualTo(item.getLabel())));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.icroco.pholio.ui.FxTestToolkit;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -115,7 +117,7 @@ class DevToolsTest {
|
||||
devTools.install(stage);
|
||||
stage.show();
|
||||
});
|
||||
FxTestToolkit.runOnFxThread(stage.getScene().getAccelerators().get(F12));
|
||||
FxTestToolkit.runOnFxThread(Objects.requireNonNull(stage.getScene().getAccelerators().get(F12)));
|
||||
|
||||
assertThatCode(() -> FxTestToolkit.runOnFxThread(devTools::dispose)).doesNotThrowAnyException();
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@ package org.icroco.pholio.ui.shell;
|
||||
|
||||
import javafx.scene.control.TreeItem;
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.domain.library.LibraryFolder;
|
||||
import org.icroco.pholio.infra.library.LibraryChangedEvent;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderAddedEvent;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderService;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.preferences.PreferencesFixture;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.icroco.pholio.ui.FxTestToolkit;
|
||||
import org.icroco.pholio.ui.i18n.I18nService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -13,35 +18,37 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.runOnFxThread;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* The library drawer's folder tree, over a real directory.
|
||||
* The library drawer's folder tree, over real directories.
|
||||
*
|
||||
* <p>{@code LibraryFolderService} is mocked — persistence is {@link LibraryFolderService}'s own concern,
|
||||
* covered by {@code LibraryFolderServiceTest} — but the directories it reports are real, so
|
||||
* {@link FolderTreeItem}'s lazy subfolder loading still runs unmodified underneath.
|
||||
*
|
||||
* <p>The {@code TaskService} handed in runs its {@code BACKGROUND_SYNC} work with {@code Runnable::run}, so
|
||||
* listings happen inline and the assertions need no waiting. That indirection is why {@code TaskService} is
|
||||
* a constructor parameter at all: with the production pools the test would have to poll, and a lazy-loading
|
||||
* tree is exactly the kind of thing whose timing makes flaky tests.
|
||||
*
|
||||
* <p>It also counts calls, which is how laziness is proven — not by observing what is displayed, but by
|
||||
* observing that the directories nobody opened were never read.
|
||||
* every dispatch — the initial folder load, a directory listing, an add or a remove — happens inline and the
|
||||
* assertions need no waiting.
|
||||
*/
|
||||
class LibraryFolderTreeTest {
|
||||
|
||||
private static final String LIBRARY_GROUP = "library";
|
||||
private static final String ROOT_PATH_KEY = "root-path";
|
||||
|
||||
@TempDir
|
||||
private Path library;
|
||||
|
||||
private AppPreferences preferences;
|
||||
private AtomicInteger listings;
|
||||
private LibraryFolderTree section;
|
||||
private I18nService i18n;
|
||||
private LibraryFolderService libraryFolderService;
|
||||
private AtomicInteger backgroundRuns;
|
||||
private TaskService taskService;
|
||||
private LibraryFolderTree section;
|
||||
|
||||
@BeforeEach
|
||||
void buildLibrary() throws IOException {
|
||||
@@ -52,17 +59,20 @@ class LibraryFolderTreeTest {
|
||||
Files.createDirectories(library.resolve(".cache"));
|
||||
Files.writeString(library.resolve("photo.jpg"), "not really");
|
||||
|
||||
preferences = PreferencesFixture.fromBundledSchema();
|
||||
preferences.setValue(LIBRARY_GROUP, ROOT_PATH_KEY, library.toString());
|
||||
AppPreferences preferences = PreferencesFixture.fromBundledSchema();
|
||||
i18n = FxTestToolkit.i18n(preferences);
|
||||
|
||||
listings = new AtomicInteger();
|
||||
libraryFolderService = mock(LibraryFolderService.class);
|
||||
when(libraryFolderService.list()).thenReturn(List.of(folder(1L, library)));
|
||||
|
||||
backgroundRuns = new AtomicInteger();
|
||||
Executor counting = command -> {
|
||||
listings.incrementAndGet();
|
||||
backgroundRuns.incrementAndGet();
|
||||
command.run();
|
||||
};
|
||||
Executor inert = Runnable::run;
|
||||
TaskService taskService = new TaskService(inert, inert, inert, inert, counting, event -> { });
|
||||
section = onFxThread(() -> new LibraryFolderTree(FxTestToolkit.i18n(preferences), preferences, taskService));
|
||||
taskService = new TaskService(inert, inert, inert, inert, counting, event -> {});
|
||||
section = onFxThread(() -> new LibraryFolderTree(i18n, libraryFolderService, taskService));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,18 +83,32 @@ class LibraryFolderTreeTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersTheAddFolderButtonAsATitleAccessory() {
|
||||
assertThat(section.titleAccessory()).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootsTheTreeAtTheConfiguredDirectory() {
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(root().getValue()).isEqualTo(library);
|
||||
softly.assertThat(root().isExpanded()).as("the first level is worth showing straight away").isTrue();
|
||||
softly.assertThat(firstRoot().getValue()).isEqualTo(library);
|
||||
softly.assertThat(section.tree().isVisible()).isTrue();
|
||||
softly.assertThat(section.tree().isShowRoot()).as("only real folders are shown, not the sentinel").isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsOneTopLevelItemPerConfiguredFolder(@TempDir Path other) {
|
||||
LibraryFolderTree multiRoot = buildSection(List.of(folder(1L, library), folder(2L, other)));
|
||||
|
||||
List<TreeItem<Path>> roots = childrenOf(invisibleRootOf(multiRoot));
|
||||
|
||||
assertThat(roots).extracting(TreeItem::getValue).containsExactly(library, other);
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsSubdirectoriesSortedAndNothingElse() {
|
||||
assertThat(namesOf(childrenOf(root()))).containsExactly("2024", "2025");
|
||||
assertThat(namesOf(childrenOf(firstRoot()))).containsExactly("2024", "2025");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,12 +116,16 @@ class LibraryFolderTreeTest {
|
||||
*/
|
||||
@Test
|
||||
void readsOnlyTheDirectoriesThatWereOpened() {
|
||||
int baseline = backgroundRuns.get(); // the initial folder-list load has already run, during setup
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
List<TreeItem<Path>> years = childrenOf(root());
|
||||
softly.assertThat(listings.get()).as("only the root was read").isEqualTo(1);
|
||||
List<TreeItem<Path>> years = childrenOf(firstRoot());
|
||||
softly.assertThat(backgroundRuns.get() - baseline).as("only the root was read").isEqualTo(1);
|
||||
|
||||
List<TreeItem<Path>> months = childrenOf(years.getFirst());
|
||||
softly.assertThat(listings.get()).as("one more, for the folder just opened").isEqualTo(2);
|
||||
softly.assertThat(backgroundRuns.get() - baseline)
|
||||
.as("one more, for the folder just opened")
|
||||
.isEqualTo(2);
|
||||
softly.assertThat(namesOf(months)).containsExactly("été");
|
||||
});
|
||||
}
|
||||
@@ -107,7 +135,7 @@ class LibraryFolderTreeTest {
|
||||
*/
|
||||
@Test
|
||||
void leafStatusIsKnownOnlyOnceAFolderHasBeenRead() {
|
||||
List<TreeItem<Path>> years = childrenOf(root());
|
||||
List<TreeItem<Path>> years = childrenOf(firstRoot());
|
||||
TreeItem<Path> empty = years.get(1);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
@@ -118,43 +146,89 @@ class LibraryFolderTreeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutARootItSaysSoInsteadOfShowingAnEmptyTree() {
|
||||
FxTestToolkit.runOnFxThread(() -> preferences.setValue(LIBRARY_GROUP, ROOT_PATH_KEY, ""));
|
||||
void withoutAnyFolderItSaysSoInsteadOfShowingAnEmptyTree() {
|
||||
LibraryFolderTree empty = buildSection(List.of());
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(section.tree().getRoot()).isNull();
|
||||
softly.assertThat(section.tree().isVisible()).isFalse();
|
||||
softly.assertThat(section.tree().isManaged()).isFalse();
|
||||
softly.assertThat(section.getChildren().stream().anyMatch(node -> node.isVisible() && node != section.tree()))
|
||||
softly.assertThat(childrenOf(invisibleRootOf(empty))).isEmpty();
|
||||
softly.assertThat(empty.tree().isVisible()).isFalse();
|
||||
softly.assertThat(empty.tree().isManaged()).isFalse();
|
||||
softly.assertThat(empty.getChildren().stream().anyMatch(node -> node.isVisible() && node != empty.tree()))
|
||||
.as("the notice is shown in its place")
|
||||
.isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Choosing a directory from the gallery must repopulate the tree, not require a restart.
|
||||
* The "+" button dispatches through the service and does not touch the tree itself — the row only
|
||||
* appears once {@link LibraryFolderAddedEvent} confirms the folder was actually persisted.
|
||||
*/
|
||||
@Test
|
||||
void followsANewRootChosenElsewhere(@TempDir Path other) throws IOException {
|
||||
Files.createDirectories(other.resolve("archive"));
|
||||
void appendsARowOnceTheServiceConfirmsAnAddedFolder(@TempDir Path other) {
|
||||
LibraryFolder added = folder(2L, other);
|
||||
|
||||
FxTestToolkit.runOnFxThread(() -> preferences.setValue(LIBRARY_GROUP, ROOT_PATH_KEY, other.toString()));
|
||||
runOnFxThread(() -> section.onFolderAdded(new LibraryFolderAddedEvent(added)));
|
||||
|
||||
List<TreeItem<Path>> roots = childrenOf(invisibleRootOf(section));
|
||||
assertThat(roots).extracting(TreeItem::getValue).containsExactly(library, other);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "-" button (only ever offered on a root's own row) removes through the service and drops the
|
||||
* row — {@link LibraryFolderTree#removeForTest} stands in for the button click, which needs a rendered
|
||||
* scene this test suite does not build.
|
||||
*/
|
||||
@Test
|
||||
void removingARootCallsTheServiceAndDropsItsRow() {
|
||||
TreeItem<Path> root = firstRoot();
|
||||
|
||||
runOnFxThread(() -> section.removeForTest(root));
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(root().getValue()).isEqualTo(other);
|
||||
softly.assertThat(namesOf(childrenOf(root()))).containsExactly("archive");
|
||||
softly.assertThat(childrenOf(invisibleRootOf(section))).isEmpty();
|
||||
softly.assertThat(section.tree().isVisible()).isFalse();
|
||||
});
|
||||
verify(libraryFolderService).remove(library);
|
||||
}
|
||||
|
||||
/**
|
||||
* A different library means a different database — whatever this drawer was showing belongs to a
|
||||
* library that is no longer open, so a switch must reload from {@link LibraryFolderService}, not just
|
||||
* append to what was already there.
|
||||
*/
|
||||
@Test
|
||||
void switchingLibraryReplacesTheTreeWithTheNewLibrarysFolders(@TempDir Path other) {
|
||||
when(libraryFolderService.list()).thenReturn(List.of(folder(9L, other)));
|
||||
|
||||
runOnFxThread(() -> section.onLibraryChanged(new LibraryChangedEvent("christophe", "holidays")));
|
||||
|
||||
List<TreeItem<Path>> roots = childrenOf(invisibleRootOf(section));
|
||||
assertThat(roots).extracting(TreeItem::getValue).containsExactly(other);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disposeReleasesTheTree() {
|
||||
FxTestToolkit.runOnFxThread(section::dispose);
|
||||
runOnFxThread(section::dispose);
|
||||
|
||||
assertThat(section.tree().getRoot()).isNull();
|
||||
}
|
||||
|
||||
private TreeItem<Path> root() {
|
||||
return onFxThread(() -> section.tree().getRoot());
|
||||
private LibraryFolderTree buildSection(List<LibraryFolder> folders) {
|
||||
LibraryFolderService service = mock(LibraryFolderService.class);
|
||||
when(service.list()).thenReturn(folders);
|
||||
return onFxThread(() -> new LibraryFolderTree(i18n, service, taskService));
|
||||
}
|
||||
|
||||
private static LibraryFolder folder(long id, Path path) {
|
||||
return LibraryFolder.builder().id(id).path(path).addedAt(Instant.now()).build();
|
||||
}
|
||||
|
||||
private TreeItem<Path> firstRoot() {
|
||||
return onFxThread(() -> section.tree().getRoot().getChildren().getFirst());
|
||||
}
|
||||
|
||||
private static TreeItem<Path> invisibleRootOf(LibraryFolderTree tree) {
|
||||
return onFxThread(() -> tree.tree().getRoot());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.icroco.pholio.ui.shell;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
@@ -11,6 +12,8 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -40,16 +43,10 @@ class NavigationDrawerTest {
|
||||
NavigationDrawer drawer = drawerOf();
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
onFxThread(() -> {
|
||||
drawer.show(NavigationDestination.LIBRARY);
|
||||
return null;
|
||||
});
|
||||
FxTestToolkit.runOnFxThread(() -> drawer.show(NavigationDestination.LIBRARY));
|
||||
softly.assertThat(drawer.titleText()).isEqualTo(i18n.get("nav.library"));
|
||||
|
||||
onFxThread(() -> {
|
||||
drawer.show(NavigationDestination.MAP);
|
||||
return null;
|
||||
});
|
||||
FxTestToolkit.runOnFxThread(() -> drawer.show(NavigationDestination.MAP));
|
||||
softly.assertThat(drawer.titleText()).isEqualTo(i18n.get("nav.map"));
|
||||
});
|
||||
}
|
||||
@@ -75,7 +72,8 @@ class NavigationDrawerTest {
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(drawer.bodyContent()).isInstanceOf(Label.class);
|
||||
softly.assertThat(((Label) drawer.bodyContent()).getText()).isEqualTo(i18n.get("nav.drawer.empty"));
|
||||
softly.assertThat(((Label) Objects.requireNonNull(drawer.bodyContent())).getText())
|
||||
.isEqualTo(i18n.get("nav.drawer.empty"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,16 +106,36 @@ class NavigationDrawerTest {
|
||||
.hasMessageContaining(SecondStub.class.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* The "+" button next to "Phototèque" is this mechanism in production; a section with no accessory
|
||||
* (every other destination today) must not leave a stale one behind when it takes over the header.
|
||||
*/
|
||||
@Test
|
||||
void showsASectionsTitleAccessoryNextToTheTitleAndDropsItWhenGone() {
|
||||
Button accessory = new Button();
|
||||
StubSection library = new StubSectionWithAccessory(NavigationDestination.LIBRARY, accessory);
|
||||
StubSection tags = new StubSection(NavigationDestination.TAGS);
|
||||
NavigationDrawer drawer = drawerOf(library, tags);
|
||||
|
||||
show(drawer, NavigationDestination.LIBRARY);
|
||||
List<Node> withAccessory = onFxThread(drawer::headerChildren);
|
||||
|
||||
show(drawer, NavigationDestination.TAGS);
|
||||
List<Node> withoutAccessory = onFxThread(drawer::headerChildren);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(withAccessory).contains(accessory);
|
||||
softly.assertThat(withoutAccessory).doesNotContain(accessory);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void disposingTheDrawerDisposesItsSections() {
|
||||
StubSection albums = new StubSection(NavigationDestination.ALBUMS);
|
||||
StubSection tags = new StubSection(NavigationDestination.TAGS);
|
||||
NavigationDrawer drawer = drawerOf(albums, tags);
|
||||
|
||||
onFxThread(() -> {
|
||||
drawer.dispose();
|
||||
return null;
|
||||
});
|
||||
FxTestToolkit.runOnFxThread(drawer::dispose);
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(albums.disposed).isTrue();
|
||||
@@ -163,6 +181,25 @@ class NavigationDrawerTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link StubSection} that also claims a title accessory, for {@link
|
||||
* #showsASectionsTitleAccessoryNextToTheTitleAndDropsItWhenGone()}.
|
||||
*/
|
||||
private static final class StubSectionWithAccessory extends StubSection {
|
||||
|
||||
private final Node accessory;
|
||||
|
||||
StubSectionWithAccessory(NavigationDestination destination, Node accessory) {
|
||||
super(destination);
|
||||
this.accessory = accessory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Node> titleAccessory() {
|
||||
return Optional.of(accessory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Named subclasses, so the conflict message can be asserted to name both culprits.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
|
||||
@@ -156,7 +157,7 @@ class NavigationRailResizeTest {
|
||||
}
|
||||
|
||||
private double persistedWidth() {
|
||||
return preferences.getValue("ui", "left-panel-width", Double.class);
|
||||
return Objects.requireNonNull(preferences.getValue("ui", "left-panel-width", Double.class));
|
||||
}
|
||||
|
||||
private void press(double x) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package org.icroco.pholio.ui.shell;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.layout.Pane;
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.infra.library.LibraryFolderService;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.preferences.PreferencesFixture;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.icroco.pholio.ui.FxTestToolkit;
|
||||
import org.icroco.pholio.ui.ViewSwitcher;
|
||||
import org.icroco.pholio.ui.event.ViewType;
|
||||
@@ -18,6 +20,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
|
||||
@@ -44,7 +47,9 @@ class NavigationRailTest {
|
||||
|
||||
private AppPreferences preferences;
|
||||
private I18nService i18n;
|
||||
@SuppressWarnings("NullAway.Init") // set inside the onFxThread lambda in buildRail(), which runs synchronously
|
||||
private NavigationDrawer drawer;
|
||||
@SuppressWarnings("NullAway.Init") // set inside the onFxThread lambda in buildRail(), which runs synchronously
|
||||
private ViewSwitcher viewSwitcher;
|
||||
private NavigationRail rail;
|
||||
private List<Button> destinations;
|
||||
@@ -298,7 +303,8 @@ class NavigationRailTest {
|
||||
private ViewSwitcher navigableViewSwitcher() {
|
||||
return onFxThread(() -> {
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
when(context.getBean(GalleryView.class)).thenReturn(new GalleryView(i18n, preferences));
|
||||
when(context.getBean(GalleryView.class))
|
||||
.thenReturn(new GalleryView(i18n, mock(LibraryFolderService.class), mock(TaskService.class)));
|
||||
when(context.getBean(ModulePlaceholderView.class)).thenReturn(new ModulePlaceholderView(i18n));
|
||||
return new ViewSwitcher(context, new ViewportSelection());
|
||||
});
|
||||
@@ -309,7 +315,7 @@ class NavigationRailTest {
|
||||
}
|
||||
|
||||
private boolean persisted() {
|
||||
return preferences.getValue("ui", "left-panel-expanded", Boolean.class);
|
||||
return Objects.requireNonNull(preferences.getValue("ui", "left-panel-expanded", Boolean.class));
|
||||
}
|
||||
|
||||
private void click(int index) {
|
||||
|
||||
@@ -19,7 +19,9 @@ spring:
|
||||
username: sa
|
||||
password: ""
|
||||
flyway:
|
||||
enabled: false
|
||||
# Routing is off above, so Boot's normal single-DataSource Flyway autoconfiguration applies cleanly
|
||||
# here — only the production per-library-pool path needs the manual migration in PersistenceConfiguration.
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
Reference in New Issue
Block a user