├── src ├── main │ ├── resources │ │ ├── .gitignore │ │ ├── personal │ │ │ └── .gitignore │ │ ├── config.properties │ │ ├── README.md │ │ ├── path-example.properties │ │ ├── dbx-example.properties │ │ ├── drive-example.properties │ │ ├── sftp-example.properties │ │ └── s3-example.properties │ └── java │ │ └── io │ │ └── huangsam │ │ └── photohaul │ │ ├── resolution │ │ ├── ResolutionException.java │ │ ├── PhotoResolver.java │ │ └── PhotoFunction.java │ │ ├── traversal │ │ ├── PhotoCollector.java │ │ ├── PathRuleSet.java │ │ ├── PathWalker.java │ │ └── PathRule.java │ │ ├── migration │ │ ├── MigrationException.java │ │ ├── MigratorMode.java │ │ ├── state │ │ │ ├── StateFileStorage.java │ │ │ ├── PathStateStorage.java │ │ │ ├── FileState.java │ │ │ └── MigrationStateFile.java │ │ ├── Migrator.java │ │ ├── S3Migrator.java │ │ ├── DropboxMigrator.java │ │ ├── PathMigrator.java │ │ ├── SftpMigrator.java │ │ ├── GoogleDriveMigrator.java │ │ ├── DeltaMigrator.java │ │ └── MigratorFactory.java │ │ ├── Main.java │ │ ├── Application.java │ │ ├── Settings.java │ │ ├── model │ │ └── Photo.java │ │ └── deduplication │ │ └── PhotoDeduplicator.java └── test │ ├── resources │ ├── static │ │ ├── sample.txt │ │ ├── salad.jpg │ │ ├── school.png │ │ └── bauerlite.jpg │ └── temp │ │ └── .gitignore │ └── java │ └── io │ └── huangsam │ └── photohaul │ ├── TestHelper.java │ ├── resolution │ ├── TestResolutionAbstract.java │ ├── TestPhotoFunction.java │ └── TestPhotoResolver.java │ ├── migration │ ├── TestMigrationAbstract.java │ ├── TestS3Migrator.java │ ├── TestPathMigrator.java │ ├── TestDropboxMigrator.java │ ├── TestSftpMigrator.java │ ├── state │ │ ├── TestPathStateStorage.java │ │ ├── TestFileState.java │ │ └── TestMigrationStateFile.java │ ├── TestMigratorFactory.java │ ├── TestGoogleDriveMigrator.java │ └── TestDeltaMigrator.java │ ├── traversal │ ├── TestPathWalker.java │ ├── TestPathRuleSet.java │ └── TestPathRule.java │ ├── model │ └── TestPhoto.java │ ├── TestSettings.java │ ├── deduplication │ └── TestPhotoDeduplicator.java │ └── integration │ └── TestDeduplicationIntegration.java ├── settings.gradle ├── .idea ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── compiler.xml ├── vcs.xml ├── misc.xml ├── gradle.xml └── checkstyle-idea.xml ├── images ├── validate-step-1.png ├── validate-step-2.png ├── validate-step-3.png └── sunny-bunny-tidy-up.webp ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── codecov.yml ├── .gitignore ├── config └── checkstyle │ └── checkstyle.xml ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── CREDITS.md ├── gradlew.bat ├── README.md ├── AGENTS.md ├── USERGUIDE.md └── gradlew /src/main/resources/.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'photohaul' 2 | -------------------------------------------------------------------------------- /src/test/resources/static/sample.txt: -------------------------------------------------------------------------------- 1 | Hello world 2 | -------------------------------------------------------------------------------- /src/main/resources/personal/.gitignore: -------------------------------------------------------------------------------- 1 | *.properties 2 | -------------------------------------------------------------------------------- /src/test/resources/temp/.gitignore: -------------------------------------------------------------------------------- 1 | *.jpg 2 | *.jpeg 3 | *.png 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /images/validate-step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/images/validate-step-1.png -------------------------------------------------------------------------------- /images/validate-step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/images/validate-step-2.png -------------------------------------------------------------------------------- /images/validate-step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/images/validate-step-3.png -------------------------------------------------------------------------------- /images/sunny-bunny-tidy-up.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/images/sunny-bunny-tidy-up.webp -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/static/salad.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/src/test/resources/static/salad.jpg -------------------------------------------------------------------------------- /src/test/resources/static/school.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/src/test/resources/static/school.png -------------------------------------------------------------------------------- /src/test/resources/static/bauerlite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huangsam/photohaul/HEAD/src/test/resources/static/bauerlite.jpg -------------------------------------------------------------------------------- /src/main/resources/config.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=PATH 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/resources/README.md: -------------------------------------------------------------------------------- 1 | # Configuration guide 2 | 3 | Edit `config.properties` for Photohaul settings. 4 | 5 | Refer to the `*-example.properties` files as templates. 6 | 7 | Copy your configuration files to the `personal` subfolder to avoid accidental commits. 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # https://docs.codecov.com/docs/common-recipe-list 2 | # https://docs.codecov.com/docs/commit-status 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | informational: true 8 | patch: 9 | default: 10 | informational: true 11 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/resolution/ResolutionException.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | /** 4 | * This resembles an issue with methods called in {@link PhotoResolver}. 5 | */ 6 | public class ResolutionException extends RuntimeException { 7 | public ResolutionException(String message) { 8 | super(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/resources/path-example.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=PATH 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | 7 | # Target path on local to upload photos 8 | path.target=Dummy/Target 9 | 10 | # Can be [MOVE, COPY, DRY_RUN] 11 | path.action=DRY_RUN 12 | 13 | # Enable delta migration to skip unchanged files (default: false) 14 | # When enabled, only new or modified files will be migrated 15 | # delta.enabled=true 16 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/TestHelper.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.file.Path; 6 | 7 | public final class TestHelper { 8 | private static final Path TEST_RESOURCES = Path.of("src/test/resources"); 9 | 10 | @NotNull 11 | public static Path getStaticResources() { 12 | return TEST_RESOURCES.resolve("static"); 13 | } 14 | 15 | @NotNull 16 | public static Path getTempResources() { 17 | return TEST_RESOURCES.resolve("temp"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/dbx-example.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=DROPBOX 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | 7 | # Target path on Dropbox to upload photos 8 | dbx.target=/Demo/Target 9 | 10 | # Anything you want it to be! Here is an example below 11 | dbx.clientId=Photohaul/1.0 12 | 13 | # Generated from the App Console 14 | dbx.accessToken=Replace this with real token 15 | 16 | # Enable delta migration to skip unchanged files (default: false) 17 | # When enabled, only new or modified files will be migrated 18 | # State file is stored locally at source path for cloud destinations 19 | # delta.enabled=true 20 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/traversal/PhotoCollector.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jspecify.annotations.NonNull; 5 | 6 | import java.nio.file.Path; 7 | import java.util.Collection; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | 10 | public class PhotoCollector { 11 | private final ConcurrentHashMap photoIndex = new ConcurrentHashMap<>(); 12 | 13 | public @NonNull Collection getPhotos() { 14 | return photoIndex.values(); 15 | } 16 | 17 | public void addPhoto(@NonNull Path path) { 18 | photoIndex.put(path, new Photo(path)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/resources/drive-example.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=GOOGLE_DRIVE 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | 7 | # Target ID on Google to upload photos 8 | drive.target=Replace this with folder ID from browser URL 9 | 10 | # Credentials JSON file name for service account 11 | drive.credentialFile=Replace this with JSON file name 12 | 13 | # Canonical application name 14 | drive.appName=Photohaul 15 | 16 | # Enable delta migration to skip unchanged files (default: false) 17 | # When enabled, only new or modified files will be migrated 18 | # State file is stored locally at source path for cloud destinations 19 | # delta.enabled=true 20 | -------------------------------------------------------------------------------- /src/main/resources/sftp-example.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=SFTP 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | 7 | # SFTP server host 8 | sftp.host=sftp.example.com 9 | 10 | # SFTP server port (default 22) 11 | sftp.port=22 12 | 13 | # SFTP username 14 | sftp.username=your_username 15 | 16 | # SFTP password 17 | sftp.password=your_password 18 | 19 | # Target directory on SFTP server 20 | sftp.target=/photos 21 | 22 | # Enable delta migration to skip unchanged files (default: false) 23 | # When enabled, only new or modified files will be migrated 24 | # State file is stored locally at source path for cloud destinations 25 | # delta.enabled=true 26 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/MigrationException.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | /** 4 | * This resembles an issue with methods called in {@link MigratorFactory} 5 | * and {@link Migrator}. 6 | * 7 | *

To provide more context on the issue's origin, we provide {@code mode} as 8 | * an additional constructor field. 9 | */ 10 | public class MigrationException extends RuntimeException { 11 | private final MigratorMode mode; 12 | 13 | public MigrationException(String message, MigratorMode mode) { 14 | super(message); 15 | this.mode = mode; 16 | } 17 | 18 | public MigratorMode getMode() { 19 | return mode; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/s3-example.properties: -------------------------------------------------------------------------------- 1 | # Migrator mode 2 | migrator.mode=S3 3 | 4 | # Source path on local to visit photos 5 | path.source=Dummy/Source 6 | 7 | # Target bucket on S3 to upload photos 8 | s3.bucket=your-bucket-name 9 | 10 | # AWS access key ID 11 | s3.accessKey=Replace with your access key 12 | 13 | # AWS secret access key 14 | s3.secretKey=Replace with your secret key 15 | 16 | # AWS region (optional, defaults to us-east-1) 17 | s3.region=us-east-1 18 | 19 | # Enable delta migration to skip unchanged files (default: false) 20 | # When enabled, only new or modified files will be migrated 21 | # State file is stored locally at source path for cloud destinations 22 | # delta.enabled=true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store 43 | -------------------------------------------------------------------------------- /.idea/checkstyle-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10.20.1 5 | JavaOnly 6 | 14 | 15 | -------------------------------------------------------------------------------- /config/checkstyle/checkstyle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/MigratorMode.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | /** 4 | * This mode determines whether photos are migrated between local folders or 5 | * migrated to a cloud service. 6 | * 7 | *

Please add the necessary fields to your properties file based on 8 | * the selected mode. Check sample files in {@code src/main/resources} 9 | * for reference. 10 | */ 11 | public enum MigratorMode { 12 | /** Migrate photos to a local directory path. */ 13 | PATH, 14 | 15 | /** Migrate photos to a Dropbox account. */ 16 | DROPBOX, 17 | 18 | /** Migrate photos to a Google Drive account. */ 19 | GOOGLE_DRIVE, 20 | 21 | /** Migrate photos to an SFTP server. */ 22 | SFTP, 23 | 24 | /** Migrate photos to an Amazon S3 bucket. */ 25 | S3 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | java-build: 14 | name: Java LTS 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | java-version: [21, 22, 23, 24, 25] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v5 24 | - name: Set up Java LTS 25 | uses: actions/setup-java@v5 26 | with: 27 | java-version: ${{ matrix.java-version }} 28 | distribution: 'zulu' 29 | cache: 'gradle' 30 | - name: Build source with Gradle 31 | run: gradle build 32 | - name: Upload coverage data to Codecov 33 | uses: codecov/codecov-action@v5 34 | env: 35 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/traversal/PathRuleSet.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | import java.util.List; 8 | import java.util.function.Predicate; 9 | 10 | public record PathRuleSet(List> rules) { 11 | @NotNull 12 | public static PathRuleSet getDefault() { 13 | return new PathRuleSet(List.of( 14 | Files::isRegularFile, 15 | PathRule.isPublic(), 16 | PathRule.validExtensions().or(PathRule.isImageContent()), 17 | PathRule.minimumBytes(100L))); 18 | } 19 | 20 | public boolean matches(Path path) { 21 | return rules.stream().allMatch(rule -> rule.test(path)); 22 | } 23 | 24 | public int size() { 25 | return rules.size(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/resolution/TestResolutionAbstract.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jspecify.annotations.NonNull; 5 | 6 | import java.nio.file.Path; 7 | import java.util.List; 8 | 9 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 10 | 11 | public abstract class TestResolutionAbstract { 12 | private static final Photo BAUER_PHOTO = buildBauerPhoto(); 13 | 14 | private static @NonNull Photo buildBauerPhoto() { 15 | Path photoPath = getStaticResources().resolve("bauerlite.jpg"); 16 | return new Photo(photoPath); 17 | } 18 | 19 | @NonNull Photo getBauerPhoto() { 20 | return BAUER_PHOTO; 21 | } 22 | 23 | @NonNull PhotoResolver getPhotoResolver() { 24 | return new PhotoResolver(List.of(Photo::make, PhotoFunction.yearTaken())); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/Main.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul; 2 | 3 | import io.huangsam.photohaul.deduplication.PhotoDeduplicator; 4 | import io.huangsam.photohaul.migration.MigratorFactory; 5 | import io.huangsam.photohaul.resolution.PhotoResolver; 6 | import io.huangsam.photohaul.traversal.PathRuleSet; 7 | import io.huangsam.photohaul.traversal.PhotoCollector; 8 | 9 | public class Main { 10 | public static void main(String[] args) { 11 | Settings settings = Settings.getDefault(); 12 | PhotoCollector photoCollector = new PhotoCollector(); 13 | PathRuleSet pathRuleSet = PathRuleSet.getDefault(); 14 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 15 | PhotoResolver photoResolver = PhotoResolver.getDefault(); 16 | MigratorFactory migratorFactory = new MigratorFactory(); 17 | 18 | Application app = new Application(settings, photoCollector, pathRuleSet, deduplicator, photoResolver, migratorFactory); 19 | app.run(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Samuel Huang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Photo credits 2 | 3 | Ensure that all photos used in this project are credited properly. 4 | 5 | ## README photos 6 | 7 | `images/sunny-bunny-tidy-up.webp` 8 | 9 | - Domain: 10 | - Link: 11 | - Author: Sunny Bunnies 12 | - Author ID: [SunnyBunniesOfficial](https://giphy.com/SunnyBunniesOfficial) 13 | 14 | ## Test photos 15 | 16 | `src/test/resources/static/school.png` 17 | 18 | - Domain: 19 | - Link: 20 | - Author Name: Freepik 21 | - Author ID: [freepik](https://www.flaticon.com/authors/freepik) 22 | 23 | `src/test/resources/static/salad.jpg` 24 | 25 | - Domain: 26 | - Link: 27 | - Author Name: Nadine Primeau 28 | - Author ID: [nadineprimeau](https://unsplash.com/@nadineprimeau) 29 | 30 | `src/test/resources/static/bauerlite.jpg` 31 | 32 | - Domain: 33 | - Author: Samuel Huang 34 | - Author ID: [huangsam](https://github.com/huangsam) 35 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestMigrationAbstract.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.traversal.PhotoCollector; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jspecify.annotations.NonNull; 6 | 7 | import java.nio.file.Path; 8 | import java.util.List; 9 | 10 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 11 | 12 | public abstract class TestMigrationAbstract { 13 | void run(@NonNull Migrator migrator) { 14 | run(migrator, List.of("bauerlite.jpg", "salad.jpg")); 15 | } 16 | 17 | void run(@NotNull Migrator migrator, @NonNull List names) { 18 | PhotoCollector photoCollector = getPathCollector(getStaticResources(), names); 19 | migrator.migratePhotos(photoCollector.getPhotos()); 20 | } 21 | 22 | @NotNull 23 | private PhotoCollector getPathCollector(@NonNull Path path, @NotNull List names) { 24 | PhotoCollector photoCollector = new PhotoCollector(); 25 | for (String name : names) { 26 | photoCollector.addPhoto(path.resolve(name)); 27 | } 28 | return photoCollector; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/state/StateFileStorage.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.jspecify.annotations.Nullable; 4 | 5 | import java.io.IOException; 6 | 7 | /** 8 | * Interface for reading and writing migration state files. 9 | * 10 | *

Implementations of this interface provide storage-specific operations 11 | * for managing the migration state file at different destinations 12 | * (local filesystem, S3, Dropbox, Google Drive, SFTP). 13 | */ 14 | public interface StateFileStorage { 15 | /** 16 | * Read the content of the state file. 17 | * 18 | * @param fileName the name of the state file 19 | * @return the content of the state file, or null if it doesn't exist 20 | * @throws IOException if an I/O error occurs during reading 21 | */ 22 | @Nullable String readStateFile(String fileName) throws IOException; 23 | 24 | /** 25 | * Write content to the state file. 26 | * 27 | * @param fileName the name of the state file 28 | * @param content the content to write 29 | * @throws IOException if an I/O error occurs during writing 30 | */ 31 | void writeStateFile(String fileName, String content) throws IOException; 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/traversal/TestPathWalker.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.List; 6 | 7 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 8 | import static org.junit.jupiter.api.Assertions.assertFalse; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | 11 | public class TestPathWalker { 12 | @Test 13 | void testTraverseWithPhotos() { 14 | PhotoCollector photoCollector = new PhotoCollector(); 15 | PathRuleSet pathRuleSet = new PathRuleSet(List.of(PathRule.validExtensions())); 16 | PathWalker pathWalker = new PathWalker(getStaticResources(), pathRuleSet); 17 | pathWalker.traverse(photoCollector); 18 | assertFalse(photoCollector.getPhotos().isEmpty()); 19 | } 20 | 21 | @Test 22 | void testTraverseWithNoPhotos() { 23 | PhotoCollector photoCollector = new PhotoCollector(); 24 | PathRuleSet pathRuleSet = new PathRuleSet(List.of(PathRule.minimumBytes(100_000_000L))); 25 | PathWalker pathWalker = new PathWalker(getStaticResources(), pathRuleSet); 26 | pathWalker.traverse(photoCollector); 27 | assertTrue(photoCollector.getPhotos().isEmpty()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/Migrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import java.util.Collection; 7 | 8 | /** 9 | * Defines the contract for a photo migration service. 10 | * 11 | *

This contract allows consumers to migrate photos without knowing whether 12 | * the operations happen locally or in the cloud. 13 | */ 14 | public interface Migrator extends AutoCloseable { 15 | /** 16 | * Migrate a collection of photos to a specific target. 17 | * 18 | *

For each photo record, do the following: 19 | * 20 | *

25 | * 26 | * @param photos collection of photo records 27 | */ 28 | void migratePhotos(@NotNull Collection photos); 29 | 30 | /** 31 | * Get success count. 32 | * 33 | * @return number of successful migrations 34 | */ 35 | long getSuccessCount(); 36 | 37 | /** 38 | * Get failure count. 39 | * 40 | * @return number of failed migrations 41 | */ 42 | long getFailureCount(); 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/traversal/PathWalker.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.slf4j.Logger; 5 | 6 | import java.io.IOException; 7 | import java.nio.file.Files; 8 | import java.nio.file.Path; 9 | import java.util.stream.Stream; 10 | 11 | import static org.slf4j.LoggerFactory.getLogger; 12 | 13 | /** 14 | * This class is responsible for traversing a source directory and filtering 15 | * files based on a given rule set. 16 | */ 17 | public record PathWalker(Path sourceRoot, PathRuleSet pathRuleSet) { 18 | private static final Logger LOG = getLogger(PathWalker.class); 19 | 20 | /** 21 | * Traverse source directory recursively, passing relevant files to 22 | * the photo collector for aggregation purposes. 23 | * 24 | * @param photoCollector collector to process matching files 25 | */ 26 | public void traverse(@NotNull PhotoCollector photoCollector) { 27 | LOG.debug("Start traversal of {}", sourceRoot); 28 | try (Stream sourceStream = Files.walk(sourceRoot)) { 29 | sourceStream.parallel().filter(pathRuleSet::matches).forEach(photoCollector::addPhoto); 30 | } catch (IOException e) { 31 | LOG.error("Abort traversal of {}: {}", sourceRoot, e.getMessage()); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/model/TestPhoto.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.model; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jspecify.annotations.NonNull; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.nio.file.Path; 8 | 9 | import static org.junit.jupiter.api.Assertions.assertEquals; 10 | import static org.junit.jupiter.api.Assertions.assertNotNull; 11 | import static org.junit.jupiter.api.Assertions.assertNull; 12 | 13 | public class TestPhoto { 14 | private static final Photo FAKE_PHOTO = getPhoto("someFolder/foobar.jpg"); 15 | private static final Photo REAL_PHOTO = getPhoto("src/test/resources/static/bauerlite.jpg"); 16 | 17 | @Test 18 | void testRealPhotoNameIsBauer() { 19 | assertEquals("bauerlite.jpg", REAL_PHOTO.name()); 20 | } 21 | 22 | @Test 23 | void testRealPhotoModifiedAtIsNotNull() { 24 | assertNotNull(REAL_PHOTO.modifiedAt()); 25 | } 26 | 27 | @Test 28 | void testFakePhotoNameIsFoobar() { 29 | assertEquals("foobar.jpg", FAKE_PHOTO.name()); 30 | } 31 | 32 | @Test 33 | void testFakePhotoModifiedAtIsNull() { 34 | assertNull(FAKE_PHOTO.modifiedAt()); 35 | } 36 | 37 | @NotNull 38 | private static Photo getPhoto(@NonNull String pathName) { 39 | return new Photo(Path.of(pathName)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/resolution/PhotoResolver.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jspecify.annotations.NonNull; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.function.Function; 10 | 11 | public record PhotoResolver(List> photoFunctions) { 12 | @NotNull 13 | public static PhotoResolver getDefault() { 14 | return new PhotoResolver(List.of(PhotoFunction.yearTaken())); 15 | } 16 | 17 | public @NonNull List resolveList(@NonNull Photo photo) { 18 | List list = new ArrayList<>(); 19 | for (Function fn : photoFunctions) { 20 | String out = fn.apply(photo); 21 | if (out == null) { 22 | throw new ResolutionException("Got null while resolving " + photo.name()); 23 | } 24 | list.add(out); 25 | } 26 | return list; 27 | } 28 | 29 | public @NonNull String resolveString(@NonNull Photo photo, @NonNull String delimiter) { 30 | return String.join(delimiter, resolveList(photo)); 31 | } 32 | 33 | public @NonNull String resolveString(@NonNull Photo photo) { 34 | return resolveString(photo, "/"); 35 | } 36 | 37 | public int size() { 38 | return photoFunctions.size(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/resolution/TestPhotoFunction.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | import static org.junit.jupiter.api.Assertions.assertNotNull; 7 | 8 | public class TestPhotoFunction extends TestResolutionAbstract { 9 | @Test 10 | void testAperture() { 11 | assertNotNull(PhotoFunction.aperture().apply(getBauerPhoto())); 12 | } 13 | 14 | @Test 15 | void testFlash() { 16 | assertNotNull(PhotoFunction.flash().apply(getBauerPhoto())); 17 | } 18 | 19 | @Test 20 | void testFocalLength() { 21 | assertNotNull(PhotoFunction.focalLength().apply(getBauerPhoto())); 22 | } 23 | 24 | @Test 25 | void testMake() { 26 | assertNotNull(PhotoFunction.make().apply(getBauerPhoto())); 27 | } 28 | 29 | @Test 30 | void testModel() { 31 | assertNotNull(PhotoFunction.model().apply(getBauerPhoto())); 32 | } 33 | 34 | @Test 35 | void testShutterSpeed() { 36 | assertNotNull(PhotoFunction.shutterSpeed().apply(getBauerPhoto())); 37 | } 38 | 39 | @Test 40 | void testYearModified() { 41 | assertNotNull(PhotoFunction.yearModified().apply(getBauerPhoto())); 42 | } 43 | 44 | @Test 45 | void testYearTaken() { 46 | assertEquals("2023", PhotoFunction.yearTaken().apply(getBauerPhoto())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/traversal/TestPathRuleSet.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | import java.util.List; 8 | 9 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 10 | import static org.junit.jupiter.api.Assertions.assertFalse; 11 | import static org.junit.jupiter.api.Assertions.assertTrue; 12 | 13 | public class TestPathRuleSet { 14 | @Test 15 | void testMatchesNoPredicatePass() { 16 | Path samplePath = getStaticResources().resolve("sample.txt"); 17 | PathRuleSet pathRuleSet = new PathRuleSet(List.of()); 18 | assertTrue(pathRuleSet.matches(samplePath)); 19 | } 20 | 21 | @Test 22 | void testMatchesOnePredicatePass() { 23 | Path samplePath = getStaticResources().resolve("sample.txt"); 24 | PathRuleSet pathRuleSet = new PathRuleSet(List.of(Files::isRegularFile)); 25 | assertTrue(pathRuleSet.matches(samplePath)); 26 | } 27 | 28 | @Test 29 | void testMatchesOnePredicateFail() { 30 | Path samplePath = getStaticResources().resolve("sample.txt"); 31 | PathRuleSet pathRuleSet = new PathRuleSet(List.of(PathRule.validExtensions())); 32 | assertFalse(pathRuleSet.matches(samplePath)); 33 | } 34 | 35 | @Test 36 | void testDefaultRuleSetIsNotEmpty() { 37 | assertTrue(PathRuleSet.getDefault().size() > 0); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/state/PathStateStorage.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jspecify.annotations.NonNull; 5 | import org.jspecify.annotations.Nullable; 6 | 7 | import java.io.IOException; 8 | import java.nio.charset.StandardCharsets; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | 12 | /** 13 | * StateFileStorage implementation for local filesystem. 14 | * 15 | *

Stores the state file at the specified root path on the local filesystem. 16 | */ 17 | public class PathStateStorage implements StateFileStorage { 18 | private final @NonNull Path rootPath; 19 | 20 | /** 21 | * Creates a PathStateStorage for the given root path. 22 | * 23 | * @param rootPath the root directory where state files will be stored 24 | */ 25 | public PathStateStorage(@NotNull Path rootPath) { 26 | this.rootPath = rootPath; 27 | } 28 | 29 | @Override 30 | public @Nullable String readStateFile(@NonNull String fileName) throws IOException { 31 | Path statePath = rootPath.resolve(fileName); 32 | if (!Files.exists(statePath)) { 33 | return null; 34 | } 35 | return Files.readString(statePath, StandardCharsets.UTF_8); 36 | } 37 | 38 | @Override 39 | public void writeStateFile(@NonNull String fileName, @NonNull String content) throws IOException { 40 | Files.createDirectories(rootPath); 41 | Path statePath = rootPath.resolve(fileName); 42 | Files.writeString(statePath, content, StandardCharsets.UTF_8); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/traversal/PathRule.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.io.IOException; 6 | import java.nio.file.Files; 7 | import java.nio.file.Path; 8 | import java.util.List; 9 | import java.util.function.Predicate; 10 | 11 | public class PathRule { 12 | private static final List ALLOW_LIST = List.of( 13 | "jpg", "jpeg", "png", "gif", "cr2", "nef", "arw"); 14 | 15 | @NotNull 16 | public static Predicate validExtensions() { 17 | return path -> { 18 | String pathName = path.toString().toLowerCase(); 19 | return ALLOW_LIST.stream().anyMatch(pathName::endsWith); 20 | }; 21 | } 22 | 23 | @NotNull 24 | public static Predicate isImageContent() { 25 | return path -> { 26 | try { 27 | String contentType = Files.probeContentType(path); 28 | return contentType != null && contentType.startsWith("image/"); 29 | } catch (IOException e) { 30 | return false; 31 | } 32 | }; 33 | } 34 | 35 | @NotNull 36 | public static Predicate minimumBytes(long minThreshold) { 37 | return path -> { 38 | try { 39 | return Files.size(path) >= minThreshold; 40 | } catch (IOException e) { 41 | return false; 42 | } 43 | }; 44 | } 45 | 46 | @NotNull 47 | public static Predicate isPublic() { 48 | return path -> !path.getFileName().toString().startsWith("."); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/resolution/PhotoFunction.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import java.nio.file.attribute.FileTime; 7 | import java.time.LocalDateTime; 8 | import java.time.ZoneId; 9 | import java.util.function.Function; 10 | 11 | public class PhotoFunction { 12 | @NotNull 13 | public static Function aperture() { 14 | return Photo::aperture; 15 | } 16 | 17 | @NotNull 18 | public static Function flash() { 19 | return Photo::flash; 20 | } 21 | 22 | @NotNull 23 | public static Function focalLength() { 24 | return Photo::focalLength; 25 | } 26 | 27 | @NotNull 28 | public static Function make() { 29 | return Photo::make; 30 | } 31 | 32 | @NotNull 33 | public static Function model() { 34 | return Photo::model; 35 | } 36 | 37 | @NotNull 38 | public static Function shutterSpeed() { 39 | return Photo::shutterSpeed; 40 | } 41 | 42 | @NotNull 43 | public static Function yearModified() { 44 | return photo -> { 45 | FileTime modifiedTime = photo.modifiedAt(); 46 | return (modifiedTime == null) 47 | ? null 48 | : String.valueOf(modifiedTime.toInstant().atZone(ZoneId.systemDefault()).getYear()); 49 | }; 50 | } 51 | 52 | @NotNull 53 | public static Function yearTaken() { 54 | return photo -> { 55 | LocalDateTime takenTime = photo.takenAt(); 56 | return (takenTime == null) 57 | ? null 58 | : String.valueOf(takenTime.getYear()); 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/state/FileState.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.util.Objects; 6 | 7 | /** 8 | * Represents the state of a migrated file. 9 | * 10 | *

This class captures the essential metadata used to determine if a file 11 | * has changed since its last migration: the path, file size in bytes, 12 | * and last modified timestamp in milliseconds since epoch. 13 | */ 14 | public record FileState( 15 | @NotNull String path, 16 | long size, 17 | long lastModifiedMillis 18 | ) { 19 | /** 20 | * Constructs a FileState with validation. 21 | * 22 | * @param path path of the file 23 | * @param size file size in bytes 24 | * @param lastModifiedMillis last modified timestamp in milliseconds since epoch 25 | * @throws IllegalArgumentException if path is blank, size is negative, or lastModifiedMillis is negative 26 | */ 27 | public FileState { 28 | Objects.requireNonNull(path, "Path cannot be null"); 29 | if (path.isBlank()) { 30 | throw new IllegalArgumentException("Path cannot be blank"); 31 | } 32 | if (size < 0) { 33 | throw new IllegalArgumentException("Size cannot be negative"); 34 | } 35 | if (lastModifiedMillis < 0) { 36 | throw new IllegalArgumentException("Last modified timestamp cannot be negative"); 37 | } 38 | } 39 | 40 | /** 41 | * Check if this file state matches another file's current state. 42 | * 43 | * @param other the other file state to compare 44 | * @return true if size and lastModifiedMillis match 45 | */ 46 | public boolean matches(@NotNull FileState other) { 47 | return this.size == other.size && this.lastModifiedMillis == other.lastModifiedMillis; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/resolution/TestPhotoResolver.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.resolution; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.List; 6 | 7 | import static org.junit.jupiter.api.Assertions.assertEquals; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | 11 | public class TestPhotoResolver extends TestResolutionAbstract { 12 | @Test 13 | void testResolveListOnMakeYear() { 14 | List resolvedList = getPhotoResolver().resolveList(getBauerPhoto()); 15 | assertEquals(2, resolvedList.size()); 16 | assertEquals("Canon", resolvedList.getFirst()); 17 | assertEquals("2023", resolvedList.get(1)); 18 | } 19 | 20 | @Test 21 | void testResolveStringOnMakeYearWithDefaultDelimiter() { 22 | String resolvedString = getPhotoResolver().resolveString(getBauerPhoto()); 23 | assertEquals("Canon/2023", resolvedString); 24 | } 25 | 26 | @Test 27 | void testResolveStringOnMakeYearWithCustomDelimiter() { 28 | String resolvedString = getPhotoResolver().resolveString(getBauerPhoto(), " - "); 29 | assertEquals("Canon - 2023", resolvedString); 30 | } 31 | 32 | @Test 33 | void testDefaultResolverIsNotEmpty() { 34 | PhotoResolver defaultResolver = PhotoResolver.getDefault(); 35 | assertTrue(defaultResolver.size() > 0); 36 | } 37 | 38 | @Test 39 | void testEmptyResolverIsEmpty() { 40 | PhotoResolver emptyResolver = new PhotoResolver(List.of()); 41 | List resolvedList = emptyResolver.resolveList(getBauerPhoto()); 42 | assertTrue(resolvedList.isEmpty()); 43 | } 44 | 45 | @Test 46 | void testResolverThrowsException() { 47 | PhotoResolver faultyResolver = new PhotoResolver(List.of(photo -> null)); 48 | assertThrows(ResolutionException.class, () -> faultyResolver.resolveList(getBauerPhoto())); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/traversal/TestPathRule.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.traversal; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.nio.file.Path; 6 | 7 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 8 | import static org.junit.jupiter.api.Assertions.assertFalse; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | 11 | public class TestPathRule { 12 | private static final Path REAL_PATH = getStaticResources().resolve("bauerlite.jpg"); 13 | private static final Path FAKE_PATH = getStaticResources().resolve("bauerlite.foo"); 14 | private static final Path HIDDEN_PATH = getStaticResources().resolve(".bauerlite.hidden"); 15 | 16 | @Test 17 | void testIsValidExtensionWithRealIsTrue() { 18 | assertTrue(PathRule.validExtensions().test(REAL_PATH)); 19 | } 20 | 21 | @Test 22 | void testIsValidExtensionWithFakeIsFalse() { 23 | assertFalse(PathRule.validExtensions().test(FAKE_PATH)); 24 | } 25 | 26 | @Test 27 | void testIsImageContentWithRealIsTrue() { 28 | assertTrue(PathRule.isImageContent().test(REAL_PATH)); 29 | } 30 | 31 | @Test 32 | void testIsImageContentWithFakeIsFalse() { 33 | assertFalse(PathRule.isImageContent().test(FAKE_PATH)); 34 | } 35 | 36 | @Test 37 | void testIsMinimumBytesWithRealIsTrue() { 38 | assertTrue(PathRule.minimumBytes(100L).test(REAL_PATH)); 39 | } 40 | 41 | @Test 42 | void testIsMinimumBytesWithFakeIsFalse() { 43 | assertFalse(PathRule.minimumBytes(100L).test(FAKE_PATH)); 44 | } 45 | 46 | @Test 47 | void testIsPublicWithRealIsTrue() { 48 | assertTrue(PathRule.isPublic().test(REAL_PATH)); 49 | } 50 | 51 | @Test 52 | void testIsPublicWithFakeIsTrue() { 53 | assertTrue(PathRule.isPublic().test(FAKE_PATH)); 54 | } 55 | 56 | @Test 57 | void testIsPublicWithHiddenIsFalse() { 58 | assertFalse(PathRule.isPublic().test(HIDDEN_PATH)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/Application.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul; 2 | 3 | import io.huangsam.photohaul.deduplication.PhotoDeduplicator; 4 | import io.huangsam.photohaul.migration.MigrationException; 5 | import io.huangsam.photohaul.migration.MigratorFactory; 6 | import io.huangsam.photohaul.migration.MigratorMode; 7 | import io.huangsam.photohaul.model.Photo; 8 | import io.huangsam.photohaul.resolution.PhotoResolver; 9 | import io.huangsam.photohaul.migration.Migrator; 10 | import io.huangsam.photohaul.traversal.PathRuleSet; 11 | import io.huangsam.photohaul.traversal.PathWalker; 12 | import io.huangsam.photohaul.traversal.PhotoCollector; 13 | import org.slf4j.Logger; 14 | 15 | import java.util.Collection; 16 | 17 | import static org.slf4j.LoggerFactory.getLogger; 18 | 19 | public record Application(Settings settings, 20 | PhotoCollector photoCollector, PathRuleSet pathRuleSet, 21 | PhotoDeduplicator deduplicator, PhotoResolver photoResolver, 22 | MigratorFactory migratorFactory) { 23 | private static final Logger LOG = getLogger(Application.class); 24 | 25 | public void run() { 26 | PathWalker pathWalker = new PathWalker(settings.getSourcePath(), pathRuleSet); 27 | pathWalker.traverse(photoCollector); 28 | 29 | Collection uniquePhotos = deduplicator.deduplicate(photoCollector.getPhotos()); 30 | 31 | MigratorMode migratorMode = settings.getMigratorMode(); 32 | 33 | try (Migrator migrator = migratorFactory.make(migratorMode, settings, photoResolver)) { 34 | migrator.migratePhotos(uniquePhotos); 35 | LOG.info("Finish with success={} failure={}", migrator.getSuccessCount(), migrator.getFailureCount()); 36 | } catch (MigrationException e) { 37 | LOG.error("Cannot migrate with mode {}: {}", e.getMode(), e.getMessage()); 38 | } catch (Exception e) { 39 | LOG.error("Error during migration or closing migrator: {}", e.getMessage()); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | sshj = "0.40.0" 3 | dropbox-sdk = "7.0.0" 4 | google-api-client = "2.8.1" 5 | google-api-drive = "v3-rev20251210-2.0.0" 6 | google-auth-library = "1.41.0" 7 | gson = "2.13.2" 8 | jetbrains-annotations = "26.0.2-1" 9 | junit-bom = "6.0.1" 10 | logback-classic = "1.5.22" 11 | metadata-extractor = "2.19.0" 12 | mockito = "5.21.0" 13 | slf4j-api = "2.0.17" 14 | task-tree = "4.0.1" 15 | aws-sdk-s3 = "2.40.8" 16 | 17 | [libraries] 18 | sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" } 19 | dropbox-sdk = { module = "com.dropbox.core:dropbox-core-sdk", version.ref = "dropbox-sdk" } 20 | google-api-client = { module = "com.google.api-client:google-api-client", version.ref = "google-api-client" } 21 | google-api-drive = { module = "com.google.apis:google-api-services-drive", version.ref = "google-api-drive" } 22 | google-auth-library = { module = "com.google.auth:google-auth-library-oauth2-http", version.ref = "google-auth-library" } 23 | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } 24 | jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } 25 | junit-bom = { module = "org.junit:junit-bom", version.ref = "junit-bom" } 26 | junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } 27 | junit-launcher = { module = "org.junit.platform:junit-platform-launcher" } 28 | logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-classic" } 29 | metadata-extractor = { module = "com.drewnoakes:metadata-extractor", version.ref = "metadata-extractor"} 30 | mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } 31 | mockito-junit = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } 32 | slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } 33 | aws-sdk-s3 = { module = "software.amazon.awssdk:s3", version.ref = "aws-sdk-s3" } 34 | 35 | [plugins] 36 | task-tree = { id = "com.dorongold.task-tree", version.ref = "task-tree" } 37 | 38 | [bundles] 39 | mockito-all = ["mockito-core", "mockito-junit"] 40 | google-all = ["google-api-client", "google-api-drive", "google-auth-library", "gson"] 41 | aws-all = ["aws-sdk-s3"] 42 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/S3Migrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.jspecify.annotations.NonNull; 8 | import org.slf4j.Logger; 9 | import software.amazon.awssdk.services.s3.S3Client; 10 | import software.amazon.awssdk.services.s3.model.PutObjectRequest; 11 | 12 | import java.util.Collection; 13 | 14 | import static org.slf4j.LoggerFactory.getLogger; 15 | 16 | public class S3Migrator implements Migrator { 17 | private static final Logger LOG = getLogger(S3Migrator.class); 18 | 19 | private final @NonNull String bucketName; 20 | private final PhotoResolver photoResolver; 21 | private final S3Client s3Client; 22 | 23 | private long successCount = 0L; 24 | private long failureCount = 0L; 25 | 26 | public S3Migrator(@NotNull String bucket, PhotoResolver resolver, S3Client client) { 27 | bucketName = bucket; 28 | photoResolver = resolver; 29 | s3Client = client; 30 | } 31 | 32 | @Override 33 | public void migratePhotos(@NotNull Collection photos) { 34 | LOG.debug("Start S3 migration to bucket {}", bucketName); 35 | photos.forEach(photo -> { 36 | String key = getTargetKey(photo); 37 | LOG.trace("Upload {} to s3://{}/{}", photo.name(), bucketName, key); 38 | try { 39 | PutObjectRequest request = PutObjectRequest.builder() 40 | .bucket(bucketName) 41 | .key(key) 42 | .build(); 43 | s3Client.putObject(request, photo.path()); 44 | successCount++; 45 | } catch (Exception e) { 46 | LOG.error("Cannot upload {}: {}", photo.name(), e.getMessage()); 47 | failureCount++; 48 | } 49 | }); 50 | } 51 | 52 | @Override 53 | public long getSuccessCount() { 54 | return successCount; 55 | } 56 | 57 | @Override 58 | public long getFailureCount() { 59 | return failureCount; 60 | } 61 | 62 | @Override 63 | public void close() throws Exception { 64 | s3Client.close(); 65 | } 66 | 67 | @NotNull 68 | private String getTargetKey(@NonNull Photo photo) { 69 | try { 70 | return photoResolver.resolveString(photo) + "/" + photo.name(); 71 | } catch (ResolutionException e) { 72 | return "Other/" + photo.name(); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestS3Migrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | import software.amazon.awssdk.services.s3.S3Client; 11 | 12 | import software.amazon.awssdk.services.s3.model.PutObjectRequest; 13 | 14 | import java.nio.file.Path; 15 | 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.mockito.Mockito.times; 19 | import static org.mockito.Mockito.verify; 20 | import static org.mockito.Mockito.when; 21 | 22 | @ExtendWith(MockitoExtension.class) 23 | public class TestS3Migrator extends TestMigrationAbstract { 24 | @Mock 25 | S3Client s3ClientMock; 26 | 27 | @Mock 28 | PhotoResolver photoResolverMock; 29 | 30 | @Test 31 | void testMigratePhotosSuccess() throws Exception { 32 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 33 | 34 | Migrator migrator = new S3Migrator("test-bucket", photoResolverMock, s3ClientMock); 35 | run(migrator); 36 | 37 | verify(s3ClientMock, times(2)).putObject(any(PutObjectRequest.class), any(Path.class)); 38 | 39 | assertEquals(2, migrator.getSuccessCount()); 40 | assertEquals(0, migrator.getFailureCount()); 41 | 42 | migrator.close(); 43 | verify(s3ClientMock).close(); 44 | } 45 | 46 | @Test 47 | void testMigratePhotosUploadFailure() throws Exception { 48 | when(s3ClientMock.putObject(any(PutObjectRequest.class), any(Path.class))).thenThrow(new RuntimeException("Upload failed")); 49 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 50 | 51 | Migrator migrator = new S3Migrator("test-bucket", photoResolverMock, s3ClientMock); 52 | run(migrator); 53 | 54 | verify(s3ClientMock, times(2)).putObject(any(PutObjectRequest.class), any(Path.class)); 55 | 56 | assertEquals(0, migrator.getSuccessCount()); 57 | assertEquals(2, migrator.getFailureCount()); 58 | 59 | migrator.close(); 60 | verify(s3ClientMock).close(); 61 | } 62 | 63 | @Test 64 | void testMigratePhotosWithResolutionException() throws Exception { 65 | when(photoResolverMock.resolveString(any(Photo.class))).thenThrow(new ResolutionException("Resolution failed")); 66 | 67 | Migrator migrator = new S3Migrator("test-bucket", photoResolverMock, s3ClientMock); 68 | run(migrator); 69 | 70 | verify(s3ClientMock, times(2)).putObject(any(PutObjectRequest.class), any(Path.class)); 71 | 72 | assertEquals(2, migrator.getSuccessCount()); 73 | assertEquals(0, migrator.getFailureCount()); 74 | 75 | migrator.close(); 76 | verify(s3ClientMock).close(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestPathMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.nio.file.Path; 13 | import java.util.List; 14 | 15 | import static io.huangsam.photohaul.TestHelper.getTempResources; 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.mockito.Mockito.when; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | public class TestPathMigrator extends TestMigrationAbstract { 22 | @Mock 23 | PhotoResolver photoResolverMock; 24 | 25 | @Test 26 | void testMigratePhotosDryRunAllSuccess() throws Exception { 27 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 28 | 29 | Migrator migrator = getPathMover(getTempResources(), photoResolverMock, PathMigrator.Action.DRY_RUN); 30 | run(migrator); 31 | 32 | assertEquals(2, migrator.getSuccessCount()); 33 | assertEquals(0, migrator.getFailureCount()); 34 | 35 | migrator.close(); // No-op 36 | } 37 | 38 | @Test 39 | void testMigratePhotosCopyAllSuccess() throws Exception { 40 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 41 | 42 | Migrator migrator = getPathMover(getTempResources(), photoResolverMock, PathMigrator.Action.COPY); 43 | run(migrator); 44 | 45 | assertEquals(2, migrator.getSuccessCount()); 46 | assertEquals(0, migrator.getFailureCount()); 47 | 48 | migrator.close(); // No-op 49 | } 50 | 51 | @Test 52 | void testMigratePhotosMoveAllFailure() throws Exception { 53 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 54 | 55 | Migrator migrator = getPathMover(getTempResources(), photoResolverMock, PathMigrator.Action.MOVE); 56 | run(migrator, List.of("foobar.jpg")); 57 | 58 | assertEquals(0, migrator.getSuccessCount()); 59 | assertEquals(1, migrator.getFailureCount()); 60 | 61 | migrator.close(); // No-op 62 | } 63 | 64 | @Test 65 | void testMigratePhotosWithResolutionException() throws Exception { 66 | when(photoResolverMock.resolveString(any(Photo.class))).thenThrow(new ResolutionException("Resolution failed")); 67 | 68 | Migrator migrator = getPathMover(getTempResources(), photoResolverMock, PathMigrator.Action.COPY); 69 | run(migrator); 70 | 71 | assertEquals(2, migrator.getSuccessCount()); 72 | assertEquals(0, migrator.getFailureCount()); 73 | 74 | migrator.close(); // No-op 75 | } 76 | 77 | @NotNull 78 | private static PathMigrator getPathMover(Path destination, PhotoResolver resolver, PathMigrator.Action action) { 79 | return new PathMigrator(destination, resolver, action); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/DropboxMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import com.dropbox.core.DbxException; 4 | import com.dropbox.core.v2.DbxClientV2; 5 | import com.dropbox.core.v2.files.DbxUserFilesRequests; 6 | import com.dropbox.core.v2.files.ListFolderErrorException; 7 | import io.huangsam.photohaul.model.Photo; 8 | import io.huangsam.photohaul.resolution.PhotoResolver; 9 | import io.huangsam.photohaul.resolution.ResolutionException; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jspecify.annotations.NonNull; 12 | import org.slf4j.Logger; 13 | 14 | import java.io.IOException; 15 | import java.io.InputStream; 16 | import java.nio.file.Files; 17 | import java.util.Collection; 18 | 19 | import static org.slf4j.LoggerFactory.getLogger; 20 | 21 | public class DropboxMigrator implements Migrator { 22 | private static final Logger LOG = getLogger(DropboxMigrator.class); 23 | 24 | private final @NonNull String targetRoot; 25 | private final PhotoResolver photoResolver; 26 | private final DbxClientV2 dropboxClient; 27 | 28 | private long successCount = 0L; 29 | private long failureCount = 0L; 30 | 31 | public DropboxMigrator(@NotNull String target, PhotoResolver resolver, DbxClientV2 client) { 32 | if (!target.startsWith("/")) { 33 | throw new IllegalArgumentException("Target must begin with a '/' character"); 34 | } 35 | targetRoot = target; 36 | photoResolver = resolver; 37 | dropboxClient = client; 38 | } 39 | 40 | @Override 41 | public void migratePhotos(@NotNull Collection photos) { 42 | LOG.debug("Start Dropbox migration to {}", targetRoot); 43 | DbxUserFilesRequests requests = dropboxClient.files(); 44 | photos.forEach(photo -> { 45 | String targetPath = getTargetPath(photo); 46 | LOG.trace("Move {} to {}", photo.name(), targetPath); 47 | try (InputStream in = Files.newInputStream(photo.path())) { 48 | try { 49 | requests.listFolder(targetPath); 50 | } catch (ListFolderErrorException e) { 51 | requests.createFolderV2(targetPath); 52 | } 53 | requests.uploadBuilder(targetPath + "/" + photo.name()).uploadAndFinish(in); 54 | successCount++; 55 | } catch (IOException | DbxException e) { 56 | LOG.error("Cannot move {}: {}", photo.name(), e.getMessage()); 57 | failureCount++; 58 | } 59 | }); 60 | } 61 | 62 | @Override 63 | public long getSuccessCount() { 64 | return successCount; 65 | } 66 | 67 | @Override 68 | public long getFailureCount() { 69 | return failureCount; 70 | } 71 | 72 | @Override 73 | public void close() throws Exception { 74 | // No-op: DbxClientV2 does not provide a close method 75 | } 76 | 77 | @NotNull 78 | private String getTargetPath(Photo photo) { 79 | try { 80 | return targetRoot + "/" + photoResolver.resolveString(photo); 81 | } catch (ResolutionException e) { 82 | return targetRoot + "/Other"; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Photohaul 2 | 3 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/huangsam/photohaul/ci.yml)](https://github.com/huangsam/photohaul/actions) 4 | [![Code Coverage](https://img.shields.io/codecov/c/github/huangsam/photohaul)](https://codecov.io/gh/huangsam/photohaul) 5 | [![License](https://img.shields.io/github/license/huangsam/photohaul)](https://github.com/huangsam/photohaul/blob/main/LICENSE) 6 | [![GitHub Release](https://img.shields.io/github/v/release/huangsam/photohaul)](https://github.com/huangsam/photohaul/releases/latest) 7 | 8 | Effortless photo management. 9 | 10 | - Reorganize 10K+ photos (30 GB) in seconds! 11 | - Migrate photos locally and to the cloud (Dropbox, Google Drive, SFTP, S3) 12 | - Customize folder structures based on date, camera, and more 13 | - Filter photos by file type, size, and other criteria 14 | - Detect photo duplicates using modern hash techniques 15 | - Skip unchanged files for faster subsequent runs 16 | 17 | Say goodbye to photo clutter - 👋 + 🚀 18 | 19 | ![Sunny Bunny Tidy Up](images/sunny-bunny-tidy-up.webp) 20 | 21 | ## Motivation 22 | 23 | As an avid photographer, I use Adobe Lightroom to organize my edited SLR 24 | photos with custom file names and folder structures. This has worked well 25 | for me since 2015, when I started getting serious about photography. 26 | 27 | I want to apply those same patterns to old photos, so that it is easier 28 | for me to access my precious memories. However, I struggle to apply the 29 | same organization to my vast collection of older photos. I cannot apply 30 | Lightroom settings to previously exported images and writing custom 31 | scripts seems daunting. 32 | 33 | I also want to migrate my photos over to a NAS or a cloud provider like 34 | Google Drive, but it involves endless rounds of manual drag-and-drop 35 | operations. I keep thinking to myself - is there a solution out there 36 | that "just works" for my workflow? 37 | 38 | ## Value 39 | 40 | Photohaul addresses the pain points above by providing a central hub for 41 | photographers to filter, organize, and migrate photos to local storage 42 | and cloud services. The folder structure for photos can be based on info 43 | such as year taken and camera make. 44 | 45 | ## Getting started 46 | 47 | For detailed instructions: [link](USERGUIDE.md) 48 | 49 | **Install prerequisites:** 50 | 51 | - Java 21 or later 52 | - Gradle 9 or later 53 | 54 | **Build application:** 55 | 56 | - Run `./gradlew build` in your terminal 57 | 58 | **Configure settings:** 59 | 60 | - Set `PathRuleSet` to filter by extension, file size, etc. 61 | - Set `MigratorMode` to `PATH` / `DROPBOX` / `GOOGLE_DRIVE` / `SFTP` / `S3` 62 | - Set `PhotoResolver` to adjust folder structure 63 | - Fill config file. Refer to examples in [src/main/resources](src/main/resources) 64 | 65 | **Run application:** 66 | 67 | - Run `./gradlew run` in your terminal 68 | - Optional: override with `-Dphotohaul.config=personal/path.properties` (classpath) or an absolute/relative filesystem path 69 | (e.g., `-Dphotohaul.config=./src/main/resources/personal/path.properties`). 70 | 71 | ```text 72 | > Task :run 73 | 08:05:14.518 [main] INFO io.huangsam.photohaul.Settings -- Use config file from photohaul.config: personal/path.properties 74 | 08:05:14.520 [main] INFO io.huangsam.photohaul.Settings -- Loaded settings from classpath: personal/path.properties 75 | 08:05:14.526 [main] DEBUG io.huangsam.photohaul.traversal.PathWalker -- Start traversal of /Users/samhuang/Pictures/Dummy PNG 76 | 08:05:14.536 [main] INFO io.huangsam.photohaul.deduplication.PhotoDeduplicator -- Deduplication complete: 6 unique photos, 0 duplicates removed 77 | 08:05:14.536 [main] DEBUG io.huangsam.photohaul.migration.PathMigrator -- Start path migration to /Users/samhuang/Pictures/Dummy FIN 78 | 08:05:14.627 [main] INFO io.huangsam.photohaul.Application -- Finish with success=6 failure=0 79 | 80 | BUILD SUCCESSFUL in 535ms 81 | 3 actionable tasks: 1 executed, 2 up-to-date 82 | ``` 83 | 84 | **That's it!** Sit back and rediscover your memories! 😎 + 🍹 + 🌴 85 | 86 | You're welcome 🙏 87 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/PathMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.jspecify.annotations.NonNull; 8 | import org.slf4j.Logger; 9 | 10 | import java.io.IOException; 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | import java.nio.file.StandardCopyOption; 14 | import java.util.Collection; 15 | 16 | import static org.slf4j.LoggerFactory.getLogger; 17 | 18 | public class PathMigrator implements Migrator { 19 | private static final Logger LOG = getLogger(PathMigrator.class); 20 | 21 | private final Path targetRoot; 22 | private final PhotoResolver photoResolver; 23 | private final Action migratorAction; 24 | 25 | private long successCount = 0L; 26 | private long failureCount = 0L; 27 | 28 | public PathMigrator(Path target, PhotoResolver resolver, Action action) { 29 | targetRoot = target; 30 | photoResolver = resolver; 31 | migratorAction = action; 32 | } 33 | 34 | @Override 35 | public final void migratePhotos(@NotNull Collection photos) { 36 | LOG.debug("Start path migration to {}", targetRoot); 37 | photos.forEach(photo -> { 38 | Path targetPath = getTargetPath(photo); 39 | LOG.trace("Move {} to {}", photo.name(), targetPath); 40 | try { 41 | migratePhoto(targetPath, photo); 42 | successCount++; 43 | } catch (IOException e) { 44 | LOG.error("Cannot move {}: {}", photo.name(), e.getMessage()); 45 | failureCount++; 46 | } 47 | }); 48 | } 49 | 50 | @Override 51 | public final long getSuccessCount() { 52 | return successCount; 53 | } 54 | 55 | @Override 56 | public final long getFailureCount() { 57 | return failureCount; 58 | } 59 | 60 | @Override 61 | public void close() throws Exception { 62 | // No-op: no resources to close 63 | } 64 | 65 | @NotNull 66 | private Path getTargetPath(Photo photo) { 67 | try { 68 | return targetRoot.resolve(photoResolver.resolveString(photo)); 69 | } catch (ResolutionException e) { 70 | return targetRoot.resolve("Other"); 71 | } 72 | } 73 | 74 | private void migratePhoto(@NonNull Path target, @NonNull Photo photo) throws IOException { 75 | Path photoLocation = target.resolve(photo.name()); 76 | if (migratorAction == Action.DRY_RUN) { 77 | LOG.info("Dry-run {} to {}", photo.path(), photoLocation); 78 | return; 79 | } 80 | Files.createDirectories(target); 81 | switch (migratorAction) { 82 | case MOVE -> Files.move(photo.path(), photoLocation, StandardCopyOption.REPLACE_EXISTING); 83 | case COPY -> Files.copy(photo.path(), photoLocation, StandardCopyOption.REPLACE_EXISTING); 84 | } 85 | } 86 | 87 | /** 88 | * This action can be a {@code mv}, {@code cp} or {@code echo} in Linux speak. 89 | * The {@code echo} op is good to try before settling on other actions. 90 | */ 91 | public enum Action { 92 | /** 93 | * Move the photo from its original location to the target path. 94 | * Permanently removes the photo from its original location. 95 | */ 96 | MOVE, 97 | 98 | /** 99 | * Copy the photo from its original location to the target path. 100 | * The original photo remains untouched. 101 | */ 102 | COPY, 103 | 104 | /** 105 | * Perform a dry run of the migration process. 106 | * No files are actually moved or copied. 107 | * Logs information about where each photo would be placed. 108 | */ 109 | DRY_RUN 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/SftpMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import net.schmizz.sshj.SSHClient; 7 | import net.schmizz.sshj.sftp.SFTPClient; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.jspecify.annotations.NonNull; 10 | import org.slf4j.Logger; 11 | 12 | import java.io.IOException; 13 | import java.nio.file.Path; 14 | import java.nio.file.Paths; 15 | import java.util.function.Supplier; 16 | 17 | import static org.slf4j.LoggerFactory.getLogger; 18 | 19 | public class SftpMigrator implements Migrator { 20 | private static final Logger LOG = getLogger(SftpMigrator.class); 21 | 22 | private final @NonNull String host; 23 | private final int port; 24 | private final @NonNull String username; 25 | private final @NonNull String password; 26 | private final @NonNull String targetRoot; 27 | private final PhotoResolver photoResolver; 28 | private final Supplier sshClientSupplier; 29 | 30 | private long successCount = 0L; 31 | private long failureCount = 0L; 32 | 33 | public SftpMigrator(@NotNull String host, int port, @NotNull String username, @NotNull String password, 34 | @NotNull String target, PhotoResolver resolver) { 35 | this(host, port, username, password, target, resolver, SSHClient::new); 36 | } 37 | 38 | // For testing 39 | SftpMigrator(@NotNull String host, int port, @NotNull String username, @NotNull String password, 40 | @NotNull String target, PhotoResolver resolver, Supplier sshClientSupplier) { 41 | this.host = host; 42 | this.port = port; 43 | this.username = username; 44 | this.password = password; 45 | this.targetRoot = target; 46 | this.photoResolver = resolver; 47 | this.sshClientSupplier = sshClientSupplier; 48 | } 49 | 50 | @Override 51 | public void migratePhotos(@NotNull java.util.Collection photos) { 52 | LOG.debug("Start SFTP migration to {}@{}:{}", username, host, port); 53 | int processedCount = 0; 54 | try (SSHClient sshClient = sshClientSupplier.get()) { 55 | sshClient.loadKnownHosts(); 56 | sshClient.connect(host, port); 57 | sshClient.authPassword(username, password); 58 | 59 | try (SFTPClient sftpClient = sshClient.newSFTPClient()) { 60 | for (Photo photo : photos) { 61 | String targetPath = getTargetPath(photo); 62 | LOG.trace("Upload {} to {}", photo.name(), targetPath); 63 | try { 64 | // Ensure target directory exists 65 | Path targetPathObj = Paths.get(targetPath); 66 | Path targetDir = targetPathObj.getParent(); 67 | if (targetDir != null) { 68 | sftpClient.mkdirs(targetDir.toString()); 69 | } 70 | sftpClient.put(photo.path().toString(), targetPath); 71 | successCount++; 72 | } catch (IOException e) { 73 | LOG.error("Cannot upload {}: {}", photo.name(), e.getMessage()); 74 | failureCount++; 75 | } 76 | processedCount++; 77 | } 78 | } 79 | } catch (IOException e) { 80 | LOG.error("SFTP connection error: {}", e.getMessage()); 81 | failureCount += (photos.size() - processedCount); 82 | } 83 | } 84 | 85 | @Override 86 | public long getSuccessCount() { 87 | return successCount; 88 | } 89 | 90 | @Override 91 | public long getFailureCount() { 92 | return failureCount; 93 | } 94 | 95 | @Override 96 | public void close() throws Exception { 97 | // No-op: resources are closed within migratePhotos 98 | } 99 | 100 | @NotNull 101 | private String getTargetPath(@NonNull Photo photo) { 102 | try { 103 | return targetRoot + "/" + photoResolver.resolveString(photo) + "/" + photo.name(); 104 | } catch (ResolutionException e) { 105 | return targetRoot + "/Other/" + photo.name(); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Photohaul Codebase Overview for Agents 2 | 3 | ## Project Description 4 | 5 | Photohaul is a Java-based CLI application for migrating photo collections to various destinations (local paths, Dropbox, Google Drive, SFTP, S3) with built-in deduplication to avoid uploading duplicates. It uses SHA-256 hashing for accurate duplicate detection and supports lazy metadata extraction for performance. 6 | 7 | Main Class: `io.huangsam.photohaul.Main` 8 | 9 | Build Tool: Gradle (with application plugin for CLI execution) 10 | 11 | ## Directory Structure (`src/`) 12 | 13 | ### Package `io.huangsam.photohaul` 14 | 15 | - Main entry point for the CLI application. 16 | - Handles argument parsing and configuration loading. 17 | - Orchestrates photo collection, deduplication, and migration processes. 18 | 19 | ### Package `io.huangsam.photohaul.model` 20 | 21 | - Defines core data models for photos. 22 | - Implements lazy-loaded metadata extraction for performance. 23 | - Provides builder patterns for photo instances. 24 | 25 | ### Package `io.huangsam.photohaul.deduplication` 26 | 27 | - Implements multi-level photo deduplication logic. 28 | - Uses size, partial hash, and full SHA-256 hashing for efficient duplicate detection. 29 | - Processes photos in streams for parallel-friendly operations. 30 | 31 | ### Package `io.huangsam.photohaul.migration` 32 | 33 | - Provides migration strategies and implementations. 34 | - Supports transferring photos to local paths, Dropbox, Google Drive, SFTP, and S3 servers. 35 | - Includes factory patterns for configurability. 36 | - Features delta migration to track and skip unchanged files in subsequent runs. 37 | 38 | ### Package `io.huangsam.photohaul.migration.state` 39 | 40 | - Manages state files for delta migration. 41 | - Tracks migrated files' paths, sizes, and timestamps. 42 | - Supports local and cloud-based state storage. 43 | 44 | ### Package `io.huangsam.photohaul.resolution` 45 | 46 | - Handles photo path resolution and organization. 47 | - Supports date-based folder structures for migrated photos. 48 | - Provides interfaces for custom resolution logic. 49 | 50 | ### Package `io.huangsam.photohaul.settings` 51 | 52 | - Manages configuration loading from properties files. 53 | - Handles API keys and migration settings. 54 | - Supports environment-specific configurations. 55 | 56 | ### `src/main/resources/` 57 | 58 | - Default config files: `config.properties`, provider-specific examples (e.g., `dropbox-example.properties`). 59 | - Static assets if needed. 60 | 61 | ### `src/test/java/io/huangsam/photohaul/` 62 | 63 | - Unit tests for all major classes. 64 | - Integration tests for end-to-end flows. 65 | - Uses JUnit 6, Mockito for mocking. 66 | 67 | ## Build Configuration (`build.gradle`) 68 | 69 | ### Plugins 70 | 71 | - `java`: Standard Java compilation. 72 | - `application`: Generates CLI scripts and handles main class execution. 73 | - `checkstyle`: Code style enforcement. 74 | - `jacoco`: Code coverage reporting (70% minimum). 75 | - `task-tree`: For visualizing task dependencies. 76 | 77 | ### Dependencies (via `gradle/libs.versions.toml`) 78 | 79 | - **Core**: JetBrains Annotations, SLF4J/Logback for logging. 80 | - **APIs**: Google Drive API, Dropbox SDK, SSHJ for SFTP, AWS SDK for S3. 81 | - **Metadata**: Drew Noakes' metadata-extractor for EXIF data. 82 | - **Testing**: JUnit 6, Mockito. 83 | 84 | ### Application Block 85 | 86 | - Main class: `io.huangsam.photohaul.Main` 87 | - JVM Args: `-Xmx1g` (1GB heap), `-XX:+UseG1GC` (G1 garbage collector) for performance tuning in photo processing. 88 | 89 | ### Tasks 90 | 91 | - `run`: Executes the app, forwards `-Dphotohaul.config` system property for custom configs. 92 | - `test`: Runs JUnit tests with Jacoco coverage. 93 | - `check`: Includes coverage verification. 94 | - `build`: Compiles, tests, and packages. 95 | 96 | ### Key Notes for Agents 97 | 98 | - **Performance Focus**: Recent optimizations include lazy metadata, multi-level deduplication, and delta migration. 99 | - **Thread Safety**: Photo metadata uses synchronized lazy loading; deduplication is stream-based and parallel-friendly. 100 | - **Config-Driven**: Behavior changes via properties files (e.g., migration type, API credentials). 101 | - **Error Handling**: Robust logging with SLF4J; exceptions in metadata/migration are caught and logged. 102 | - **Testing**: High coverage; mocks external APIs (Dropbox, Drive, S3) for reliable tests. 103 | 104 | For contributions or modifications, ensure tests pass and coverage stays above 70%. 105 | 106 | Use `./gradlew run -Dphotohaul.config=path/to/config.properties` for local testing. 107 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestDropboxMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import com.dropbox.core.v2.DbxClientV2; 4 | import com.dropbox.core.v2.files.DbxUserFilesRequests; 5 | import com.dropbox.core.v2.files.ListFolderErrorException; 6 | import com.dropbox.core.v2.files.ListFolderResult; 7 | import com.dropbox.core.v2.files.UploadBuilder; 8 | import io.huangsam.photohaul.model.Photo; 9 | import io.huangsam.photohaul.resolution.PhotoResolver; 10 | import io.huangsam.photohaul.resolution.ResolutionException; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.extension.ExtendWith; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.junit.jupiter.api.Assertions.assertThrows; 18 | import static org.mockito.ArgumentMatchers.any; 19 | import static org.mockito.ArgumentMatchers.anyString; 20 | import static org.mockito.Mockito.times; 21 | import static org.mockito.Mockito.verify; 22 | import static org.mockito.Mockito.when; 23 | 24 | @ExtendWith(MockitoExtension.class) 25 | public class TestDropboxMigrator extends TestMigrationAbstract { 26 | @Mock 27 | DbxClientV2 clientMock; 28 | 29 | @Mock 30 | PhotoResolver photoResolverMock; 31 | 32 | @Mock 33 | DbxUserFilesRequests requestsMock; 34 | 35 | @Mock 36 | ListFolderResult folderResultMock; 37 | 38 | @Mock 39 | UploadBuilder uploadBuilderMock; 40 | 41 | @Test 42 | void testMigratePhotosNewFoldersSuccess() throws Exception { 43 | when(clientMock.files()).thenReturn(requestsMock); 44 | when(requestsMock.listFolder(anyString())).thenReturn(folderResultMock); 45 | when(requestsMock.uploadBuilder(anyString())).thenReturn(uploadBuilderMock); 46 | 47 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 48 | 49 | Migrator migrator = new DropboxMigrator("/Foobar", photoResolverMock, clientMock); 50 | run(migrator); 51 | 52 | verify(requestsMock, times(0)).createFolderV2(anyString()); 53 | verify(requestsMock, times(2)).uploadBuilder(anyString()); 54 | 55 | assertEquals(2, migrator.getSuccessCount()); 56 | assertEquals(0, migrator.getFailureCount()); 57 | 58 | migrator.close(); // No-op, but ensures no exception 59 | } 60 | 61 | @Test 62 | void testMigratePhotosOldFoldersSuccess() throws Exception { 63 | when(clientMock.files()).thenReturn(requestsMock); 64 | when(requestsMock.listFolder(anyString())).thenThrow(ListFolderErrorException.class); 65 | when(requestsMock.createFolderV2(anyString())).thenReturn(null); 66 | when(requestsMock.uploadBuilder(anyString())).thenReturn(uploadBuilderMock); 67 | 68 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 69 | 70 | Migrator migrator = new DropboxMigrator("/Foobar", photoResolverMock, clientMock); 71 | run(migrator); 72 | 73 | verify(requestsMock, times(2)).createFolderV2(anyString()); 74 | verify(requestsMock, times(2)).uploadBuilder(anyString()); 75 | 76 | assertEquals(2, migrator.getSuccessCount()); 77 | assertEquals(0, migrator.getFailureCount()); 78 | 79 | migrator.close(); // No-op, but ensures no exception 80 | } 81 | 82 | @Test 83 | @SuppressWarnings("resource") 84 | void testDropboxSetupNotWorking() { 85 | assertThrows(IllegalArgumentException.class, () -> 86 | new DropboxMigrator("NoSlashAtStart", photoResolverMock, clientMock)); 87 | } 88 | 89 | @Test 90 | void testMigratePhotosWithResolutionException() throws Exception { 91 | when(clientMock.files()).thenReturn(requestsMock); 92 | when(requestsMock.listFolder(anyString())).thenReturn(folderResultMock); 93 | when(requestsMock.uploadBuilder(anyString())).thenReturn(uploadBuilderMock); 94 | 95 | when(photoResolverMock.resolveString(any(Photo.class))).thenThrow(new ResolutionException("Resolution failed")); 96 | 97 | Migrator migrator = new DropboxMigrator("/Foobar", photoResolverMock, clientMock); 98 | run(migrator); 99 | 100 | verify(requestsMock, times(0)).createFolderV2(anyString()); 101 | verify(requestsMock, times(2)).uploadBuilder(anyString()); 102 | 103 | assertEquals(2, migrator.getSuccessCount()); 104 | assertEquals(0, migrator.getFailureCount()); 105 | 106 | migrator.close(); // No-op, but ensures no exception 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/TestSettings.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul; 2 | 3 | import org.jspecify.annotations.NonNull; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.io.TempDir; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | import java.util.Properties; 11 | 12 | import static org.junit.jupiter.api.Assertions.assertEquals; 13 | import static org.junit.jupiter.api.Assertions.assertFalse; 14 | import static org.junit.jupiter.api.Assertions.assertNotNull; 15 | import static org.junit.jupiter.api.Assertions.assertThrows; 16 | import static org.junit.jupiter.api.Assertions.assertTrue; 17 | 18 | public class TestSettings { 19 | @Test 20 | void testGetSourcePath() { 21 | Settings settings = new Settings("path-example.properties"); 22 | assertTrue(settings.getSourcePath().endsWith("Dummy/Source")); 23 | } 24 | 25 | @Test 26 | void testGetValueIsValid() { 27 | Settings settings = new Settings("dbx-example.properties"); 28 | assertTrue(settings.getValue("dbx.target").startsWith("/")); 29 | } 30 | 31 | @Test 32 | void testGetValueIsMissing() { 33 | Settings settings = new Settings("drive-example.properties"); 34 | assertThrows(NullPointerException.class, () -> settings.getValue("foo.bar")); 35 | } 36 | 37 | @Test 38 | void testGetValueWithFallback() { 39 | Settings settings = new Settings("path-example.properties"); 40 | String expected = "foo"; 41 | assertEquals(expected, settings.getValue("foo.bar", expected)); 42 | } 43 | 44 | @Test 45 | void testSettingsFromProperties() { 46 | Properties properties = new Properties(); 47 | properties.setProperty("hello.message", "world"); 48 | Settings settings = new Settings(properties); 49 | assertEquals("world", settings.getValue("hello.message")); 50 | } 51 | 52 | @Test 53 | void testGetDefaultConfig() { 54 | assertNotNull(Settings.getDefault()); 55 | } 56 | 57 | @Test 58 | void testGetMigratorMode() { 59 | Settings settings = new Settings("path-example.properties"); 60 | assertEquals(io.huangsam.photohaul.migration.MigratorMode.PATH, settings.getMigratorMode()); 61 | } 62 | 63 | @Test 64 | void testLoadFromFilesystem(@TempDir @NonNull Path tmp) throws IOException { 65 | // Arrange: create a temp properties file 66 | Path props = tmp.resolve("custom.properties"); 67 | Files.writeString(props, "hello=filesystem\n"); 68 | 69 | // Act: load using absolute filesystem path 70 | Settings settings = new Settings(props.toString()); 71 | 72 | // Assert 73 | assertEquals("filesystem", settings.getValue("hello")); 74 | } 75 | 76 | @Test 77 | void testMissingSettingsThrows() { 78 | assertThrows(IllegalStateException.class, () -> new Settings("__definitely_not_here__.properties")); 79 | } 80 | 81 | @Test 82 | void testSystemPropertyOverrideFilesystem(@TempDir @NonNull Path tmp) throws IOException { 83 | Path props = tmp.resolve("override.properties"); 84 | Files.writeString(props, "migrator.mode=PATH\nfoo.bar=baz\n"); 85 | String original = System.getProperty("photohaul.config"); 86 | try { 87 | System.setProperty("photohaul.config", props.toString()); 88 | Settings settings = Settings.getDefault(); 89 | assertEquals("baz", settings.getValue("foo.bar")); 90 | assertEquals(io.huangsam.photohaul.migration.MigratorMode.PATH, settings.getMigratorMode()); 91 | } finally { 92 | if (original == null) { 93 | System.clearProperty("photohaul.config"); 94 | } else { 95 | System.setProperty("photohaul.config", original); 96 | } 97 | } 98 | } 99 | 100 | @Test 101 | void testIsDeltaEnabledDefaultsFalse() { 102 | Settings settings = new Settings("path-example.properties"); 103 | assertFalse(settings.isDeltaEnabled()); 104 | } 105 | 106 | @Test 107 | void testIsDeltaEnabledWhenTrue(@TempDir @NonNull Path tmp) throws IOException { 108 | Path props = tmp.resolve("delta.properties"); 109 | Files.writeString(props, "path.source=Dummy/Source\nmigrator.mode=PATH\ndelta.enabled=true\n"); 110 | Settings settings = new Settings(props.toString()); 111 | assertTrue(settings.isDeltaEnabled()); 112 | } 113 | 114 | @Test 115 | void testIsDeltaEnabledWhenExplicitlyFalse(@TempDir @NonNull Path tmp) throws IOException { 116 | Path props = tmp.resolve("nodelta.properties"); 117 | Files.writeString(props, "path.source=Dummy/Source\nmigrator.mode=PATH\ndelta.enabled=false\n"); 118 | Settings settings = new Settings(props.toString()); 119 | assertFalse(settings.isDeltaEnabled()); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /USERGUIDE.md: -------------------------------------------------------------------------------- 1 | # User guide 2 | 3 | Here's an extended version of content from the [general README](README.md). 4 | 5 | ## General setup 6 | 7 | If you have not built the application yet, please follow the general README 8 | to get started first. 9 | 10 | You can specify a different configuration file for various environments or specific scenarios by passing a JVM system property. 11 | Use the `-Dphotohaul.config=` flag when running the application. The value can be either the classpath resource name (relative to `src/main/resources`) or a filesystem path. 12 | 13 | Examples: 14 | 15 | ```shell 16 | # Use the default file (no override) 17 | ./gradlew run 18 | 19 | # Override with a classpath resource under src/main/resources/personal 20 | ./gradlew run -Dphotohaul.config=personal/path.properties 21 | 22 | # Or override with a filesystem path (absolute or relative) 23 | ./gradlew run -Dphotohaul.config=./src/main/resources/personal/path.properties 24 | ``` 25 | 26 | Notes: 27 | 28 | - You can use either a classpath name (`personal/path.properties`) or a filesystem path. 29 | - Default is `config.properties` if not overridden. 30 | 31 | ### Path setup 32 | 33 | Configure the following property fields: 34 | 35 | - `migrator.mode` 36 | - `path.source` 37 | - `path.target` 38 | - `path.action` 39 | 40 | Refer to `PathMigrator` to learn more about the `path.action` values. 41 | 42 | ### Dropbox setup 43 | 44 | Configure the following property fields: 45 | 46 | - `migrator.mode` 47 | - `path.source` 48 | - `dbx.target` 49 | - `dbx.clientId` 50 | - `dbx.accessToken` 51 | 52 | [Click here](https://github.com/dropbox/dropbox-sdk-java?tab=readme-ov-file#dropbox-for-java-tutorial) to learn how to setup the `dbx` fields. 53 | 54 | ### Google Drive setup 55 | 56 | Configure the following property fields: 57 | 58 | - `migrator.mode` 59 | - `path.source` 60 | - `drive.target` 61 | - `drive.credentialFile` 62 | - `drive.appName` 63 | 64 | [Click here](https://developers.google.com/drive/api/quickstart/java#set-up-environment) to learn how to setup the `drive` fields. 65 | 66 | ### SFTP setup 67 | 68 | Configure the following property fields: 69 | 70 | - `migrator.mode` 71 | - `path.source` 72 | - `sftp.host` 73 | - `sftp.port` (optional, defaults to 22) 74 | - `sftp.username` 75 | - `sftp.password` 76 | - `sftp.target` 77 | 78 | SFTP (SSH File Transfer Protocol) is used for secure file transfers over SSH. Host keys are verified against your known hosts file for security. Ensure your server supports SFTP. 79 | 80 | ### S3 setup 81 | 82 | Configure the following property fields: 83 | 84 | - `migrator.mode` 85 | - `path.source` 86 | - `s3.bucket` 87 | - `s3.accessKey` 88 | - `s3.secretKey` 89 | - `s3.region` (optional, defaults to us-east-1) 90 | 91 | Amazon S3 (Simple Storage Service) is used for scalable cloud storage. You'll need AWS credentials with S3 permissions. [Click here](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/get-started.html) to learn how to obtain and configure AWS credentials. 92 | 93 | ## Delta migration (optional) 94 | 95 | Delta migration is an optional feature that tracks which files have been migrated and skips unchanged files in subsequent runs. This significantly improves performance for large photo collections. 96 | 97 | ### How it works 98 | 99 | - Photohaul maintains a `.photohaul_state.json` file that records the path, size, and last modified timestamp of successfully migrated files 100 | - On subsequent runs, only new or modified files are migrated 101 | - For PATH mode, the state file is stored in the target directory 102 | - For cloud destinations (Dropbox, Drive, SFTP, S3), the state file is stored locally at the source path 103 | 104 | ### Enable delta migration 105 | 106 | Add the following property to your configuration file: 107 | 108 | ```properties 109 | delta.enabled=true 110 | ``` 111 | 112 | Example for `PATH` mode: 113 | 114 | ```properties 115 | migrator.mode=PATH 116 | path.source=Dummy/Source 117 | path.target=Dummy/Target 118 | path.action=COPY 119 | delta.enabled=true 120 | ``` 121 | 122 | **Note:** Delta migration is disabled by default to ensure backward compatibility. 123 | 124 | ## Run migration 125 | 126 | - Open your terminal and navigate to the `./photohaul` directory 127 | - Run the command `./gradlew run`. This will start the migration process 128 | 129 | ## Validate migration 130 | 131 | Once the migration is complete, you can verify that your photos are uploaded successfully. 132 | 133 | Below is an example of validating changes on Google Drive. 134 | 135 | ### Google Drive validation 136 | 137 | **Folder creation** was successful: 138 | 139 | Validate step 1 140 | 141 | Photo creation in **2015** was successful: 142 | 143 | Validate step 2 144 | 145 | Photo creation in **2024** was successful: 146 | 147 | Validate step 3 148 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/state/MigrationStateFile.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import com.google.gson.JsonSyntaxException; 6 | import com.google.gson.reflect.TypeToken; 7 | import org.jetbrains.annotations.NotNull; 8 | import org.jspecify.annotations.NonNull; 9 | import org.slf4j.Logger; 10 | 11 | import java.io.IOException; 12 | import java.lang.reflect.Type; 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | import static org.slf4j.LoggerFactory.getLogger; 17 | 18 | /** 19 | * Manages the migration state file for delta migration. 20 | * 21 | *

The state file is a JSON file (e.g., .photohaul_state.json) stored at the 22 | * migration destination. It records the path, size, and last modified timestamp 23 | * of every successfully processed file, enabling efficient delta migrations by 24 | * skipping unchanged files. 25 | * 26 | *

This class provides an abstraction over the state file operations using 27 | * the {@link StateFileStorage} interface, allowing different storage backends 28 | * (local filesystem, S3, Dropbox, etc.) to be used. 29 | */ 30 | public class MigrationStateFile { 31 | private static final Logger LOG = getLogger(MigrationStateFile.class); 32 | private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); 33 | private static final Type STATE_MAP_TYPE = new TypeToken>() {}.getType(); 34 | 35 | public static final String DEFAULT_STATE_FILE_NAME = ".photohaul_state.json"; 36 | 37 | private final @NonNull StateFileStorage storage; 38 | private final @NonNull String stateFileName; 39 | private final @NonNull Map state; 40 | 41 | /** 42 | * Creates a MigrationStateFile with the default state file name. 43 | * 44 | * @param storage the storage backend for reading/writing state 45 | */ 46 | public MigrationStateFile(@NotNull StateFileStorage storage) { 47 | this(storage, DEFAULT_STATE_FILE_NAME); 48 | } 49 | 50 | /** 51 | * Creates a MigrationStateFile with a custom state file name. 52 | * 53 | * @param storage the storage backend for reading/writing state 54 | * @param stateFileName the name of the state file 55 | */ 56 | public MigrationStateFile(@NotNull StateFileStorage storage, @NotNull String stateFileName) { 57 | this.storage = storage; 58 | this.stateFileName = stateFileName; 59 | this.state = new HashMap<>(); 60 | } 61 | 62 | /** 63 | * Load state from the storage backend. 64 | * 65 | *

If the state file doesn't exist, cannot be read, or contains malformed JSON, 66 | * the state will be empty and migration will proceed with all files. 67 | */ 68 | public void load() { 69 | try { 70 | String content = storage.readStateFile(stateFileName); 71 | if (content != null && !content.isBlank()) { 72 | Map loaded = GSON.fromJson(content, STATE_MAP_TYPE); 73 | if (loaded != null) { 74 | state.clear(); 75 | state.putAll(loaded); 76 | LOG.debug("Loaded {} file states from {}", state.size(), stateFileName); 77 | } 78 | } 79 | } catch (IOException e) { 80 | LOG.warn("Could not load state file {}: {}", stateFileName, e.getMessage()); 81 | } catch (JsonSyntaxException e) { 82 | LOG.warn("State file {} contains malformed JSON, proceeding with empty state: {}", 83 | stateFileName, e.getMessage()); 84 | } 85 | } 86 | 87 | /** 88 | * Save state to the storage backend. 89 | * 90 | * @throws IOException if the state cannot be saved 91 | */ 92 | public void save() throws IOException { 93 | String content = GSON.toJson(state, STATE_MAP_TYPE); 94 | storage.writeStateFile(stateFileName, content); 95 | LOG.debug("Saved {} file states to {}", state.size(), stateFileName); 96 | } 97 | 98 | /** 99 | * Check if a file needs migration based on its current state. 100 | * 101 | * @param currentState the current state of the file 102 | * @return true if the file is new or modified since last migration 103 | */ 104 | public boolean needsMigration(@NotNull FileState currentState) { 105 | FileState previousState = state.get(currentState.path()); 106 | if (previousState == null) { 107 | return true; // New file 108 | } 109 | return !previousState.matches(currentState); 110 | } 111 | 112 | /** 113 | * Record a successful file migration. 114 | * 115 | * @param fileState the state of the successfully migrated file 116 | */ 117 | public void recordMigration(@NotNull FileState fileState) { 118 | state.put(fileState.path(), fileState); 119 | } 120 | 121 | /** 122 | * Get the number of files in the state. 123 | * 124 | * @return the number of tracked files 125 | */ 126 | public int size() { 127 | return state.size(); 128 | } 129 | 130 | /** 131 | * Get the state file name. 132 | * 133 | * @return the state file name 134 | */ 135 | public @NonNull String getStateFileName() { 136 | return stateFileName; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/deduplication/TestPhotoDeduplicator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.deduplication; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jspecify.annotations.NonNull; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.io.TempDir; 7 | 8 | import java.io.IOException; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.util.ArrayList; 12 | import java.util.Collection; 13 | import java.util.List; 14 | 15 | import static io.huangsam.photohaul.TestHelper.getStaticResources; 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.junit.jupiter.api.Assertions.assertTrue; 18 | 19 | public class TestPhotoDeduplicator { 20 | @Test 21 | void testDeduplicateWithNoDuplicates() { 22 | // Use existing test files which are unique 23 | List photos = List.of( 24 | new Photo(getStaticResources().resolve("bauerlite.jpg")), 25 | new Photo(getStaticResources().resolve("salad.jpg")) 26 | ); 27 | 28 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 29 | Collection uniquePhotos = deduplicator.deduplicate(photos); 30 | 31 | assertEquals(2, uniquePhotos.size()); 32 | } 33 | 34 | @Test 35 | void testDeduplicateWithDuplicates(@TempDir @NonNull Path tempDir) throws IOException { 36 | // Create duplicate files 37 | Path original = tempDir.resolve("original.jpg"); 38 | Path duplicate1 = tempDir.resolve("duplicate1.jpg"); 39 | Path duplicate2 = tempDir.resolve("duplicate2.jpg"); 40 | Path unique = tempDir.resolve("unique.jpg"); 41 | 42 | // Write same content to original and duplicates 43 | byte[] content1 = "Test photo content for duplicate detection".getBytes(); 44 | Files.write(original, content1); 45 | Files.write(duplicate1, content1); 46 | Files.write(duplicate2, content1); 47 | 48 | // Write different content to unique file 49 | byte[] content2 = "Different photo content".getBytes(); 50 | Files.write(unique, content2); 51 | 52 | // Create Photo objects 53 | List photos = List.of( 54 | new Photo(original), 55 | new Photo(duplicate1), 56 | new Photo(duplicate2), 57 | new Photo(unique) 58 | ); 59 | 60 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 61 | Collection uniquePhotos = deduplicator.deduplicate(photos); 62 | 63 | // Should only have 2 unique photos (original and unique) 64 | assertEquals(2, uniquePhotos.size()); 65 | 66 | // Verify the first occurrence is kept 67 | List uniqueList = new ArrayList<>(uniquePhotos); 68 | assertEquals("original.jpg", uniqueList.getFirst().name()); 69 | assertEquals("unique.jpg", uniqueList.get(1).name()); 70 | } 71 | 72 | @Test 73 | void testDeduplicateWithEmptyCollection() { 74 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 75 | Collection uniquePhotos = deduplicator.deduplicate(List.of()); 76 | 77 | assertTrue(uniquePhotos.isEmpty()); 78 | } 79 | 80 | @Test 81 | void testDeduplicateWithSinglePhoto() { 82 | List photos = List.of( 83 | new Photo(getStaticResources().resolve("bauerlite.jpg")) 84 | ); 85 | 86 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 87 | Collection uniquePhotos = deduplicator.deduplicate(photos); 88 | 89 | assertEquals(1, uniquePhotos.size()); 90 | } 91 | 92 | @Test 93 | void testDeduplicateKeepsFirstOccurrence(@TempDir @NonNull Path tempDir) throws IOException { 94 | // Create three files with same content but different names 95 | Path first = tempDir.resolve("first.jpg"); 96 | Path second = tempDir.resolve("second.jpg"); 97 | Path third = tempDir.resolve("third.jpg"); 98 | 99 | byte[] content = "Same content for all files".getBytes(); 100 | Files.write(first, content); 101 | Files.write(second, content); 102 | Files.write(third, content); 103 | 104 | List photos = List.of( 105 | new Photo(first), 106 | new Photo(second), 107 | new Photo(third) 108 | ); 109 | 110 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 111 | Collection uniquePhotos = deduplicator.deduplicate(photos); 112 | 113 | // Should only keep the first occurrence 114 | assertEquals(1, uniquePhotos.size()); 115 | List uniqueList = new ArrayList<>(uniquePhotos); 116 | assertEquals("first.jpg", uniqueList.getFirst().name()); 117 | } 118 | 119 | @Test 120 | void testDeduplicateWithNonExistentFile(@TempDir @NonNull Path tempDir) { 121 | // Create a photo with a path that doesn't exist 122 | Path nonExistent = tempDir.resolve("nonexistent.jpg"); 123 | 124 | List photos = List.of( 125 | new Photo(nonExistent) 126 | ); 127 | 128 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 129 | Collection uniquePhotos = deduplicator.deduplicate(photos); 130 | 131 | // Should still include the photo (fail-safe behavior) 132 | assertEquals(1, uniquePhotos.size()); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/GoogleDriveMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import com.google.api.client.http.HttpTransport; 4 | import com.google.api.client.http.FileContent; 5 | import com.google.api.services.drive.Drive; 6 | import com.google.api.services.drive.model.FileList; 7 | import io.huangsam.photohaul.model.Photo; 8 | import io.huangsam.photohaul.resolution.PhotoResolver; 9 | import io.huangsam.photohaul.resolution.ResolutionException; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jetbrains.annotations.Nullable; 12 | import org.jspecify.annotations.NonNull; 13 | import org.slf4j.Logger; 14 | import com.google.api.services.drive.model.File; 15 | 16 | import java.io.IOException; 17 | import java.nio.file.Files; 18 | import java.util.Collection; 19 | import java.util.List; 20 | import java.util.Objects; 21 | 22 | import static org.slf4j.LoggerFactory.getLogger; 23 | 24 | public class GoogleDriveMigrator implements Migrator { 25 | private static final Logger LOG = getLogger(GoogleDriveMigrator.class); 26 | private static final String MIME_FOLDER = "application/vnd.google-apps.folder"; 27 | 28 | private final String targetRoot; 29 | private final PhotoResolver photoResolver; 30 | private final Drive driveService; 31 | private final HttpTransport httpTransport; 32 | 33 | private long createdCount = 0L; 34 | private long existedCount = 0L; 35 | private long failureCount = 0L; 36 | 37 | public GoogleDriveMigrator(String target, PhotoResolver resolver, Drive service, HttpTransport transport) { 38 | targetRoot = target; 39 | photoResolver = resolver; 40 | driveService = service; 41 | httpTransport = transport; 42 | } 43 | 44 | @Override 45 | public void migratePhotos(@NotNull Collection photos) { 46 | LOG.debug("Start Drive migration to {}", targetRoot); 47 | photos.forEach(photo -> { 48 | String targetPath = getTargetPath(photo); 49 | LOG.trace("Move {} to {}", photo.name(), targetPath); 50 | try { 51 | String folderId = createDriveFolder(targetPath); 52 | createDrivePhoto(folderId, photo); 53 | } catch (IOException | NullPointerException e) { 54 | LOG.error("Cannot move {}: {}", photo.name(), e.getMessage()); 55 | failureCount++; 56 | } 57 | }); 58 | } 59 | 60 | @Override 61 | public long getSuccessCount() { 62 | return createdCount + existedCount; 63 | } 64 | 65 | @Override 66 | public long getFailureCount() { 67 | return failureCount; 68 | } 69 | 70 | @Override 71 | public void close() throws Exception { 72 | httpTransport.shutdown(); 73 | } 74 | 75 | private @NonNull String getTargetPath(Photo photo) { 76 | try { 77 | return photoResolver.resolveString(photo); 78 | } catch (ResolutionException e) { 79 | return "Other"; 80 | } 81 | } 82 | 83 | private String createDriveFolder(@NonNull String targetPath) throws IOException { 84 | String existingId = getExistingId(targetRoot, targetPath); 85 | if (existingId != null) { 86 | return existingId; 87 | } 88 | if (targetPath.isEmpty()) { 89 | return targetRoot; 90 | } 91 | 92 | File folderMetadata = new File(); 93 | folderMetadata.setName(targetPath); 94 | folderMetadata.setMimeType(MIME_FOLDER); 95 | folderMetadata.setParents(List.of(targetRoot)); 96 | 97 | File folderSuccess = driveService.files().create(folderMetadata) 98 | .setFields("id") 99 | .execute(); 100 | 101 | String folderId = folderSuccess.getId(); 102 | 103 | LOG.trace("Folder created: {}", folderId); 104 | 105 | return folderId; 106 | } 107 | 108 | private void createDrivePhoto(@NonNull String folderId, @NotNull Photo photo) throws IOException { 109 | String existingId = getExistingId(folderId, photo.name()); 110 | if (existingId != null) { 111 | existedCount++; 112 | return; 113 | } 114 | 115 | String contentType = Files.probeContentType(photo.path()); 116 | if (contentType == null) { 117 | throw new IOException("Missing MIME type: " + photo.path()); 118 | } 119 | 120 | File photoMetadata = new File(); 121 | photoMetadata.setName(photo.name()); 122 | photoMetadata.setParents(List.of(folderId)); 123 | 124 | java.io.File photoFile = new java.io.File(photo.path().toString()); 125 | FileContent photoContent = new FileContent(contentType, photoFile); 126 | 127 | File photoSuccess = driveService.files().create(photoMetadata, photoContent) 128 | .setFields("id") 129 | .execute(); 130 | 131 | LOG.trace("Photo created: {}", photoSuccess.getId()); 132 | 133 | createdCount++; 134 | } 135 | 136 | @Nullable 137 | private String getExistingId(String folderId, String fileName) throws IOException { 138 | Objects.requireNonNull(folderId); 139 | String query = String.format("'%s' in parents and name = '%s'", folderId, fileName); 140 | FileList result = driveService.files().list().setQ(query).execute(); 141 | List fileList = result.getFiles(); 142 | if (fileList.isEmpty()) { 143 | return null; 144 | } 145 | return fileList.getFirst().getId(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/integration/TestDeduplicationIntegration.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.integration; 2 | 3 | import io.huangsam.photohaul.deduplication.PhotoDeduplicator; 4 | import io.huangsam.photohaul.migration.PathMigrator; 5 | import io.huangsam.photohaul.model.Photo; 6 | import io.huangsam.photohaul.resolution.PhotoResolver; 7 | import io.huangsam.photohaul.traversal.PathRuleSet; 8 | import io.huangsam.photohaul.traversal.PathWalker; 9 | import io.huangsam.photohaul.traversal.PhotoCollector; 10 | import org.jspecify.annotations.NonNull; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.io.TempDir; 13 | 14 | import java.io.IOException; 15 | import java.nio.file.Files; 16 | import java.nio.file.Path; 17 | import java.util.Collection; 18 | import java.util.List; 19 | 20 | import static org.junit.jupiter.api.Assertions.assertEquals; 21 | 22 | /** 23 | * Integration test to verify the full workflow with deduplication: 24 | * 1. Traverse source directory 25 | * 2. Collect photos 26 | * 3. Deduplicate based on SHA-256 27 | * 4. Migrate unique photos only 28 | */ 29 | public class TestDeduplicationIntegration { 30 | @Test 31 | @SuppressWarnings("resource") 32 | void testFullWorkflowWithDuplicates(@TempDir @NonNull Path tempDir) throws IOException { 33 | // Setup: Create source and target directories 34 | Path sourceDir = tempDir.resolve("source"); 35 | Path targetDir = tempDir.resolve("target"); 36 | Files.createDirectories(sourceDir); 37 | Files.createDirectories(targetDir); 38 | 39 | // Create test files: 2 unique + 2 duplicates = 4 total files 40 | byte[] content1 = "First unique photo content".getBytes(); 41 | byte[] content2 = "Second unique photo content".getBytes(); 42 | 43 | Path photo1 = sourceDir.resolve("photo1.jpg"); 44 | Path photo1Dup = sourceDir.resolve("photo1-duplicate.jpg"); 45 | Path photo2 = sourceDir.resolve("photo2.jpg"); 46 | Path photo2Dup = sourceDir.resolve("photo2-copy.jpg"); 47 | 48 | Files.write(photo1, content1); 49 | Files.write(photo1Dup, content1); // Duplicate of photo1 50 | Files.write(photo2, content2); 51 | Files.write(photo2Dup, content2); // Duplicate of photo2 52 | 53 | // Step 1: Traverse and collect photos 54 | PhotoCollector photoCollector = new PhotoCollector(); 55 | PathRuleSet pathRuleSet = new PathRuleSet(List.of( 56 | Files::isRegularFile 57 | )); 58 | PathWalker pathWalker = new PathWalker(sourceDir, pathRuleSet); 59 | pathWalker.traverse(photoCollector); 60 | 61 | // Verify all 4 files were collected 62 | assertEquals(4, photoCollector.getPhotos().size()); 63 | 64 | // Step 2: Deduplicate photos 65 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 66 | Collection uniquePhotos = deduplicator.deduplicate(photoCollector.getPhotos()); 67 | 68 | // Verify only 2 unique photos remain 69 | assertEquals(2, uniquePhotos.size()); 70 | 71 | // Step 3: Migrate unique photos 72 | PhotoResolver photoResolver = new PhotoResolver(List.of()); 73 | PathMigrator migrator = new PathMigrator(targetDir, photoResolver, PathMigrator.Action.COPY); 74 | migrator.migratePhotos(uniquePhotos); 75 | 76 | // Verify migration succeeded for 2 unique photos 77 | assertEquals(2, migrator.getSuccessCount()); 78 | assertEquals(0, migrator.getFailureCount()); 79 | 80 | // Verify target directory has exactly 2 files (the unique ones) 81 | // Files are placed in "Other" subdirectory by PhotoResolver 82 | Path otherDir = targetDir.resolve("Other"); 83 | if (Files.exists(otherDir)) { 84 | long fileCount = Files.list(otherDir).count(); 85 | assertEquals(2, fileCount); 86 | } 87 | } 88 | 89 | @Test 90 | @SuppressWarnings("resource") 91 | void testFullWorkflowWithNoDuplicates(@TempDir @NonNull Path tempDir) throws IOException { 92 | // Setup: Create source and target directories 93 | Path sourceDir = tempDir.resolve("source"); 94 | Path targetDir = tempDir.resolve("target"); 95 | Files.createDirectories(sourceDir); 96 | Files.createDirectories(targetDir); 97 | 98 | // Create test files: 3 unique files 99 | Files.write(sourceDir.resolve("unique1.jpg"), "Content 1".getBytes()); 100 | Files.write(sourceDir.resolve("unique2.jpg"), "Content 2".getBytes()); 101 | Files.write(sourceDir.resolve("unique3.jpg"), "Content 3".getBytes()); 102 | 103 | // Traverse and collect 104 | PhotoCollector photoCollector = new PhotoCollector(); 105 | PathRuleSet pathRuleSet = new PathRuleSet(List.of( 106 | Files::isRegularFile 107 | )); 108 | PathWalker pathWalker = new PathWalker(sourceDir, pathRuleSet); 109 | pathWalker.traverse(photoCollector); 110 | 111 | assertEquals(3, photoCollector.getPhotos().size()); 112 | 113 | // Deduplicate (should keep all 3) 114 | PhotoDeduplicator deduplicator = new PhotoDeduplicator(); 115 | Collection uniquePhotos = deduplicator.deduplicate(photoCollector.getPhotos()); 116 | 117 | assertEquals(3, uniquePhotos.size()); 118 | 119 | // Migrate 120 | PhotoResolver photoResolver = new PhotoResolver(List.of()); 121 | PathMigrator migrator = new PathMigrator(targetDir, photoResolver, PathMigrator.Action.COPY); 122 | migrator.migratePhotos(uniquePhotos); 123 | 124 | assertEquals(3, migrator.getSuccessCount()); 125 | assertEquals(0, migrator.getFailureCount()); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestSftpMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import io.huangsam.photohaul.resolution.ResolutionException; 6 | import net.schmizz.sshj.SSHClient; 7 | import net.schmizz.sshj.sftp.SFTPClient; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import java.io.IOException; 14 | 15 | import static org.junit.jupiter.api.Assertions.assertEquals; 16 | import static org.mockito.ArgumentMatchers.any; 17 | import static org.mockito.ArgumentMatchers.anyString; 18 | import static org.mockito.Mockito.doThrow; 19 | import static org.mockito.Mockito.times; 20 | import static org.mockito.Mockito.verify; 21 | import static org.mockito.Mockito.when; 22 | 23 | @ExtendWith(MockitoExtension.class) 24 | public class TestSftpMigrator extends TestMigrationAbstract { 25 | @Mock 26 | SSHClient sshClientMock; 27 | 28 | @Mock 29 | SFTPClient sftpClientMock; 30 | 31 | @Mock 32 | PhotoResolver photoResolverMock; 33 | 34 | @Test 35 | void testMigratePhotosSuccess() throws Exception { 36 | when(sshClientMock.newSFTPClient()).thenReturn(sftpClientMock); 37 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 38 | 39 | Migrator migrator = new SftpMigrator("host", 22, "user", "pass", "/target", photoResolverMock, () -> sshClientMock); 40 | run(migrator); 41 | 42 | verify(sshClientMock).connect("host", 22); 43 | verify(sshClientMock).authPassword("user", "pass"); 44 | verify(sshClientMock).newSFTPClient(); 45 | verify(sftpClientMock, times(2)).mkdirs(anyString()); // occurs due to mocking 46 | verify(sftpClientMock, times(2)).put(anyString(), anyString()); 47 | verify(sshClientMock).close(); 48 | 49 | assertEquals(2, migrator.getSuccessCount()); 50 | assertEquals(0, migrator.getFailureCount()); 51 | 52 | migrator.close(); // No-op 53 | } 54 | 55 | @Test 56 | void testMigratePhotosConnectionFailure() throws Exception { 57 | doThrow(new IOException("Connection failed")).when(sshClientMock).connect("host", 22); 58 | 59 | Migrator migrator = new SftpMigrator("host", 22, "user", "pass", "/target", photoResolverMock, () -> sshClientMock); 60 | run(migrator); 61 | 62 | verify(sshClientMock).connect("host", 22); 63 | verify(sshClientMock, times(0)).authPassword(anyString(), anyString()); 64 | verify(sshClientMock).close(); 65 | 66 | assertEquals(0, migrator.getSuccessCount()); 67 | assertEquals(2, migrator.getFailureCount()); 68 | 69 | migrator.close(); // No-op 70 | } 71 | 72 | @Test 73 | void testMigratePhotosUploadFailure() throws Exception { 74 | when(sshClientMock.newSFTPClient()).thenReturn(sftpClientMock); 75 | doThrow(new IOException("Upload failed")).when(sftpClientMock).put(anyString(), anyString()); 76 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 77 | 78 | Migrator migrator = new SftpMigrator("host", 22, "user", "pass", "/target", photoResolverMock, () -> sshClientMock); 79 | run(migrator); 80 | 81 | verify(sftpClientMock, times(2)).mkdirs(anyString()); // occurs due to mocking 82 | verify(sftpClientMock, times(2)).put(anyString(), anyString()); 83 | verify(sshClientMock).close(); 84 | 85 | assertEquals(0, migrator.getSuccessCount()); 86 | assertEquals(2, migrator.getFailureCount()); 87 | 88 | migrator.close(); // No-op 89 | } 90 | 91 | @Test 92 | void testMigratePhotosCloseFailure() throws Exception { 93 | when(sshClientMock.newSFTPClient()).thenReturn(sftpClientMock); 94 | doThrow(new IOException("Close failed")).when(sshClientMock).close(); 95 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 96 | 97 | Migrator migrator = new SftpMigrator("host", 22, "user", "pass", "/target", photoResolverMock, () -> sshClientMock); 98 | run(migrator); 99 | 100 | verify(sshClientMock).connect("host", 22); 101 | verify(sshClientMock).authPassword("user", "pass"); 102 | verify(sshClientMock).newSFTPClient(); 103 | verify(sftpClientMock, times(2)).mkdirs(anyString()); 104 | verify(sftpClientMock, times(2)).put(anyString(), anyString()); 105 | verify(sshClientMock).close(); 106 | 107 | assertEquals(2, migrator.getSuccessCount()); 108 | assertEquals(0, migrator.getFailureCount()); 109 | 110 | migrator.close(); // No-op 111 | } 112 | 113 | @Test 114 | void testMigratePhotosWithResolutionException() throws Exception { 115 | when(sshClientMock.newSFTPClient()).thenReturn(sftpClientMock); 116 | when(photoResolverMock.resolveString(any(Photo.class))).thenThrow(new ResolutionException("Resolution failed")); 117 | 118 | Migrator migrator = new SftpMigrator("host", 22, "user", "pass", "/target", photoResolverMock, () -> sshClientMock); 119 | run(migrator); 120 | 121 | verify(sshClientMock).connect("host", 22); 122 | verify(sshClientMock).authPassword("user", "pass"); 123 | verify(sshClientMock).newSFTPClient(); 124 | verify(sftpClientMock, times(2)).mkdirs(anyString()); 125 | verify(sftpClientMock, times(2)).put(anyString(), anyString()); 126 | verify(sshClientMock).close(); 127 | 128 | assertEquals(2, migrator.getSuccessCount()); 129 | assertEquals(0, migrator.getFailureCount()); 130 | 131 | migrator.close(); // No-op 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/DeltaMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.migration.state.FileState; 4 | import io.huangsam.photohaul.migration.state.MigrationStateFile; 5 | import io.huangsam.photohaul.model.Photo; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.jspecify.annotations.NonNull; 8 | import org.slf4j.Logger; 9 | 10 | import java.io.IOException; 11 | import java.nio.file.Files; 12 | import java.nio.file.attribute.FileTime; 13 | import java.util.ArrayList; 14 | import java.util.Collection; 15 | import java.util.List; 16 | 17 | import static org.slf4j.LoggerFactory.getLogger; 18 | 19 | /** 20 | * A decorator that adds delta migration functionality to any Migrator. 21 | * 22 | *

This migrator wraps an existing migrator and uses a state file to track 23 | * previously migrated files. Before migration, it compares each file's current 24 | * metadata (size and last modified time) against the recorded state, and only 25 | * migrates files that are new or modified since the last run. 26 | * 27 | *

After each successful migration batch, the state file is updated with 28 | * the current state of all successfully migrated files. 29 | * 30 | *

Note: Since the delegate migrators process files in order and we cannot 31 | * determine exactly which specific files failed, we conservatively only record 32 | * state for the number of successful migrations (assuming failures occur at 33 | * the end of the batch). This may result in some files being re-migrated on 34 | * the next run if failures occurred mid-batch, but ensures no file is 35 | * incorrectly marked as migrated. 36 | */ 37 | public class DeltaMigrator implements Migrator { 38 | private static final Logger LOG = getLogger(DeltaMigrator.class); 39 | 40 | private final @NonNull Migrator delegate; 41 | private final @NonNull MigrationStateFile stateFile; 42 | 43 | private long skippedCount = 0L; 44 | 45 | /** 46 | * Creates a DeltaMigrator wrapping the given delegate migrator. 47 | * 48 | * @param delegate the underlying migrator to delegate to 49 | * @param stateFile the state file manager for tracking migrations 50 | */ 51 | public DeltaMigrator(@NotNull Migrator delegate, @NotNull MigrationStateFile stateFile) { 52 | this.delegate = delegate; 53 | this.stateFile = stateFile; 54 | } 55 | 56 | @Override 57 | public void migratePhotos(@NotNull Collection photos) { 58 | // Load existing state 59 | stateFile.load(); 60 | 61 | // Filter to only photos that need migration 62 | List photosToMigrate = new ArrayList<>(); 63 | List fileStates = new ArrayList<>(); 64 | 65 | for (Photo photo : photos) { 66 | try { 67 | FileState currentState = createFileState(photo); 68 | if (stateFile.needsMigration(currentState)) { 69 | photosToMigrate.add(photo); 70 | fileStates.add(currentState); 71 | } else { 72 | LOG.trace("Skipping unchanged file: {}", photo.name()); 73 | skippedCount++; 74 | } 75 | } catch (IOException e) { 76 | LOG.warn("Could not read file metadata for {}: {}", photo.name(), e.getMessage()); 77 | // Include in migration attempt anyway 78 | photosToMigrate.add(photo); 79 | fileStates.add(null); 80 | } 81 | } 82 | 83 | LOG.info("Delta migration: {} files to migrate, {} files skipped (unchanged)", 84 | photosToMigrate.size(), skippedCount); 85 | 86 | if (photosToMigrate.isEmpty()) { 87 | return; 88 | } 89 | 90 | // Get the delegate's success count before migration 91 | long previousSuccessCount = delegate.getSuccessCount(); 92 | 93 | // Delegate actual migration 94 | delegate.migratePhotos(photosToMigrate); 95 | 96 | // Calculate how many files were successfully migrated 97 | long newSuccessCount = delegate.getSuccessCount(); 98 | long successfulMigrations = newSuccessCount - previousSuccessCount; 99 | 100 | if (successfulMigrations > 0) { 101 | // Record states only for the number of successful migrations 102 | // Since we process in order, record states from the beginning 103 | int statesToRecord = (int) Math.min(successfulMigrations, fileStates.size()); 104 | for (int i = 0; i < statesToRecord; i++) { 105 | FileState fileState = fileStates.get(i); 106 | if (fileState != null) { 107 | stateFile.recordMigration(fileState); 108 | } 109 | } 110 | 111 | // Save updated state 112 | try { 113 | stateFile.save(); 114 | } catch (IOException e) { 115 | LOG.error("Failed to save migration state: {}", e.getMessage()); 116 | } 117 | } 118 | } 119 | 120 | @Override 121 | public long getSuccessCount() { 122 | return delegate.getSuccessCount(); 123 | } 124 | 125 | @Override 126 | public long getFailureCount() { 127 | return delegate.getFailureCount(); 128 | } 129 | 130 | /** 131 | * Get the number of files skipped because they were unchanged. 132 | * 133 | * @return the number of skipped files 134 | */ 135 | public long getSkippedCount() { 136 | return skippedCount; 137 | } 138 | 139 | @Override 140 | public void close() throws Exception { 141 | delegate.close(); 142 | } 143 | 144 | @NotNull 145 | private FileState createFileState(@NotNull Photo photo) throws IOException { 146 | String path = photo.path().toString(); 147 | long size = Files.size(photo.path()); 148 | FileTime modifiedTime = Files.getLastModifiedTime(photo.path()); 149 | return new FileState(path, size, modifiedTime.toMillis()); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/state/TestPathStateStorage.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.jspecify.annotations.NonNull; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.io.TempDir; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | 11 | import static org.junit.jupiter.api.Assertions.assertEquals; 12 | import static org.junit.jupiter.api.Assertions.assertNull; 13 | import static org.junit.jupiter.api.Assertions.assertTrue; 14 | 15 | public class TestPathStateStorage { 16 | @Test 17 | void testReadStateFileReturnsNullWhenNotExists(@TempDir @NonNull Path tempDir) throws IOException { 18 | PathStateStorage storage = new PathStateStorage(tempDir); 19 | String result = storage.readStateFile("nonexistent.json"); 20 | assertNull(result); 21 | } 22 | 23 | @Test 24 | void testReadStateFileReturnsContent(@TempDir @NonNull Path tempDir) throws IOException { 25 | String content = "{\"test\": \"data\"}"; 26 | Files.writeString(tempDir.resolve("state.json"), content); 27 | 28 | PathStateStorage storage = new PathStateStorage(tempDir); 29 | String result = storage.readStateFile("state.json"); 30 | assertEquals(content, result); 31 | } 32 | 33 | @Test 34 | void testWriteStateFileCreatesFile(@TempDir @NonNull Path tempDir) throws IOException { 35 | PathStateStorage storage = new PathStateStorage(tempDir); 36 | String content = "{\"test\": \"data\"}"; 37 | 38 | storage.writeStateFile("state.json", content); 39 | 40 | String result = Files.readString(tempDir.resolve("state.json")); 41 | assertEquals(content, result); 42 | } 43 | 44 | @Test 45 | void testWriteStateFileCreatesDirectories(@TempDir @NonNull Path tempDir) throws IOException { 46 | Path nestedDir = tempDir.resolve("nested/dir"); 47 | PathStateStorage storage = new PathStateStorage(nestedDir); 48 | String content = "{\"test\": \"data\"}"; 49 | 50 | storage.writeStateFile("state.json", content); 51 | 52 | String result = Files.readString(nestedDir.resolve("state.json")); 53 | assertEquals(content, result); 54 | } 55 | 56 | @Test 57 | void testWriteAndReadRoundTrip(@TempDir @NonNull Path tempDir) throws IOException { 58 | PathStateStorage storage = new PathStateStorage(tempDir); 59 | String content = "{\"path\":\"/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}"; 60 | 61 | storage.writeStateFile(".photohaul_state.json", content); 62 | String result = storage.readStateFile(".photohaul_state.json"); 63 | 64 | assertEquals(content, result); 65 | } 66 | 67 | @Test 68 | void testReadStateFileWithUTF8Content(@TempDir @NonNull Path tempDir) throws IOException { 69 | String content = "{\"path\":\"/photos/日本語.jpg\",\"size\":1024}"; 70 | Files.writeString(tempDir.resolve("state.json"), content); 71 | 72 | PathStateStorage storage = new PathStateStorage(tempDir); 73 | String result = storage.readStateFile("state.json"); 74 | assertEquals(content, result); 75 | } 76 | 77 | @Test 78 | void testWriteStateFileWithUTF8Content(@TempDir @NonNull Path tempDir) throws IOException { 79 | PathStateStorage storage = new PathStateStorage(tempDir); 80 | String content = "{\"path\":\"/photos/日本語.jpg\",\"size\":1024}"; 81 | 82 | storage.writeStateFile("state.json", content); 83 | 84 | String result = Files.readString(tempDir.resolve("state.json")); 85 | assertEquals(content, result); 86 | } 87 | 88 | @Test 89 | void testWriteStateFileOverwritesExisting(@TempDir @NonNull Path tempDir) throws IOException { 90 | PathStateStorage storage = new PathStateStorage(tempDir); 91 | String originalContent = "{\"original\": \"data\"}"; 92 | String newContent = "{\"new\": \"data\"}"; 93 | 94 | storage.writeStateFile("state.json", originalContent); 95 | storage.writeStateFile("state.json", newContent); 96 | 97 | String result = storage.readStateFile("state.json"); 98 | assertEquals(newContent, result); 99 | } 100 | 101 | @Test 102 | void testReadStateFileWithEmptyFile(@TempDir @NonNull Path tempDir) throws IOException { 103 | Files.writeString(tempDir.resolve("empty.json"), ""); 104 | 105 | PathStateStorage storage = new PathStateStorage(tempDir); 106 | String result = storage.readStateFile("empty.json"); 107 | assertEquals("", result); 108 | } 109 | 110 | @Test 111 | void testWriteStateFileWithEmptyContent(@TempDir @NonNull Path tempDir) throws IOException { 112 | PathStateStorage storage = new PathStateStorage(tempDir); 113 | 114 | storage.writeStateFile("empty.json", ""); 115 | 116 | String result = Files.readString(tempDir.resolve("empty.json")); 117 | assertEquals("", result); 118 | } 119 | 120 | @Test 121 | void testReadStateFileWithMultilineContent(@TempDir @NonNull Path tempDir) throws IOException { 122 | String content = "{\n \"path\": \"/photo.jpg\",\n \"size\": 1024\n}"; 123 | Files.writeString(tempDir.resolve("state.json"), content); 124 | 125 | PathStateStorage storage = new PathStateStorage(tempDir); 126 | String result = storage.readStateFile("state.json"); 127 | assertEquals(content, result); 128 | } 129 | 130 | @Test 131 | void testWriteStateFileWithMultilineContent(@TempDir @NonNull Path tempDir) throws IOException { 132 | PathStateStorage storage = new PathStateStorage(tempDir); 133 | String content = "{\n \"path\": \"/photo.jpg\",\n \"size\": 1024\n}"; 134 | 135 | storage.writeStateFile("state.json", content); 136 | 137 | String result = Files.readString(tempDir.resolve("state.json")); 138 | assertEquals(content, result); 139 | } 140 | 141 | @Test 142 | void testWriteStateFileWithDeeplyNestedDirectories(@TempDir @NonNull Path tempDir) throws IOException { 143 | Path deepPath = tempDir.resolve("a/b/c/d/e/f"); 144 | PathStateStorage storage = new PathStateStorage(deepPath); 145 | String content = "{\"test\": \"deep\"}"; 146 | 147 | storage.writeStateFile("state.json", content); 148 | 149 | assertTrue(Files.exists(deepPath.resolve("state.json"))); 150 | assertEquals(content, Files.readString(deepPath.resolve("state.json"))); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/state/TestFileState.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | import static org.junit.jupiter.api.Assertions.assertFalse; 7 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | 11 | public class TestFileState { 12 | @Test 13 | void testValidConstruction() { 14 | FileState state = new FileState("/path/to/file.jpg", 1024, 1700000000000L); 15 | assertEquals("/path/to/file.jpg", state.path()); 16 | assertEquals(1024, state.size()); 17 | assertEquals(1700000000000L, state.lastModifiedMillis()); 18 | } 19 | 20 | @Test 21 | void testBlankPathThrowsException() { 22 | assertThrows(IllegalArgumentException.class, () -> new FileState(" ", 1024, 1700000000000L)); 23 | } 24 | 25 | @Test 26 | void testNegativeSizeThrowsException() { 27 | assertThrows(IllegalArgumentException.class, () -> new FileState("/path/to/file.jpg", -1, 1700000000000L)); 28 | } 29 | 30 | @Test 31 | void testNegativeTimestampThrowsException() { 32 | assertThrows(IllegalArgumentException.class, () -> new FileState("/path/to/file.jpg", 1024, -1)); 33 | } 34 | 35 | @Test 36 | void testMatchesReturnsTrueForIdenticalSizeAndTime() { 37 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 38 | FileState state2 = new FileState("/path/file2.jpg", 1024, 1700000000000L); 39 | assertTrue(state1.matches(state2)); 40 | } 41 | 42 | @Test 43 | void testMatchesReturnsFalseForDifferentSize() { 44 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 45 | FileState state2 = new FileState("/path/file1.jpg", 2048, 1700000000000L); 46 | assertFalse(state1.matches(state2)); 47 | } 48 | 49 | @Test 50 | void testMatchesReturnsFalseForDifferentTime() { 51 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 52 | FileState state2 = new FileState("/path/file1.jpg", 1024, 1700000001000L); 53 | assertFalse(state1.matches(state2)); 54 | } 55 | 56 | @Test 57 | void testZeroSizeAllowed() { 58 | FileState state = new FileState("/path/to/empty.jpg", 0, 0); 59 | assertEquals(0, state.size()); 60 | assertEquals(0, state.lastModifiedMillis()); 61 | } 62 | 63 | @Test 64 | void testEmptyPathThrowsException() { 65 | assertThrows(IllegalArgumentException.class, () -> new FileState("", 1024, 1700000000000L)); 66 | } 67 | 68 | @Test 69 | void testPathWithOnlyWhitespaceThrowsException() { 70 | assertThrows(IllegalArgumentException.class, () -> new FileState("\t\n", 1024, 1700000000000L)); 71 | } 72 | 73 | @Test 74 | void testMatchesReturnsFalseForBothDifferent() { 75 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 76 | FileState state2 = new FileState("/path/file1.jpg", 2048, 1700000001000L); 77 | assertFalse(state1.matches(state2)); 78 | } 79 | 80 | @Test 81 | void testMatchesIsSymmetric() { 82 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 83 | FileState state2 = new FileState("/path/file2.jpg", 1024, 1700000000000L); 84 | assertEquals(state1.matches(state2), state2.matches(state1)); 85 | } 86 | 87 | @Test 88 | void testLargeSizeValue() { 89 | long largeSize = Long.MAX_VALUE; 90 | FileState state = new FileState("/path/file.jpg", largeSize, 1700000000000L); 91 | assertEquals(largeSize, state.size()); 92 | } 93 | 94 | @Test 95 | void testLargeTimestampValue() { 96 | long largeTimestamp = Long.MAX_VALUE; 97 | FileState state = new FileState("/path/file.jpg", 1024, largeTimestamp); 98 | assertEquals(largeTimestamp, state.lastModifiedMillis()); 99 | } 100 | 101 | @Test 102 | void testPathWithSpecialCharacters() { 103 | String specialPath = "/path/to/file with spaces & special (chars).jpg"; 104 | FileState state = new FileState(specialPath, 1024, 1700000000000L); 105 | assertEquals(specialPath, state.path()); 106 | } 107 | 108 | @Test 109 | void testPathWithUnicodeCharacters() { 110 | String unicodePath = "/photos/日本語/photo.jpg"; 111 | FileState state = new FileState(unicodePath, 1024, 1700000000000L); 112 | assertEquals(unicodePath, state.path()); 113 | } 114 | 115 | @Test 116 | void testRecordEquality() { 117 | FileState state1 = new FileState("/path/file.jpg", 1024, 1700000000000L); 118 | FileState state2 = new FileState("/path/file.jpg", 1024, 1700000000000L); 119 | assertEquals(state1, state2); 120 | } 121 | 122 | @Test 123 | void testRecordInequalityDifferentPath() { 124 | FileState state1 = new FileState("/path/file1.jpg", 1024, 1700000000000L); 125 | FileState state2 = new FileState("/path/file2.jpg", 1024, 1700000000000L); 126 | assertNotEquals(state1, state2); 127 | } 128 | 129 | @Test 130 | void testRecordInequalityDifferentSize() { 131 | FileState state1 = new FileState("/path/file.jpg", 1024, 1700000000000L); 132 | FileState state2 = new FileState("/path/file.jpg", 2048, 1700000000000L); 133 | assertNotEquals(state1, state2); 134 | } 135 | 136 | @Test 137 | void testRecordInequalityDifferentTimestamp() { 138 | FileState state1 = new FileState("/path/file.jpg", 1024, 1700000000000L); 139 | FileState state2 = new FileState("/path/file.jpg", 1024, 1700000001000L); 140 | assertNotEquals(state1, state2); 141 | } 142 | 143 | @Test 144 | void testHashCodeConsistency() { 145 | FileState state1 = new FileState("/path/file.jpg", 1024, 1700000000000L); 146 | FileState state2 = new FileState("/path/file.jpg", 1024, 1700000000000L); 147 | assertEquals(state1.hashCode(), state2.hashCode()); 148 | } 149 | 150 | @Test 151 | void testToStringContainsAllFields() { 152 | FileState state = new FileState("/path/file.jpg", 1024, 1700000000000L); 153 | String str = state.toString(); 154 | assertTrue(str.contains("/path/file.jpg")); 155 | assertTrue(str.contains("1024")); 156 | assertTrue(str.contains("1700000000000")); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/Settings.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul; 2 | 3 | import io.huangsam.photohaul.migration.MigratorMode; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jspecify.annotations.NonNull; 6 | import org.slf4j.Logger; 7 | 8 | import java.io.IOException; 9 | import java.io.InputStream; 10 | import java.nio.file.Files; 11 | import java.nio.file.Path; 12 | import java.nio.file.Paths; 13 | import java.util.Properties; 14 | 15 | import static org.slf4j.LoggerFactory.getLogger; 16 | 17 | public record Settings(Properties properties) { 18 | private static final Logger LOG = getLogger(Settings.class); 19 | private static final String CONFIG_FILE_SYSTEM_PROPERTY = "photohaul.config"; 20 | private static final String CONFIG_FILE_DEFAULT = "config.properties"; 21 | 22 | /** 23 | * Provides the default Settings instance, attempting to load from a system property 24 | * or falling back to a default file name. 25 | * 26 | * @return A Settings instance. 27 | * @throws IllegalArgumentException if the value is not defined. 28 | */ 29 | @NotNull 30 | public static Settings getDefault() { 31 | String configFileName = System.getProperty(CONFIG_FILE_SYSTEM_PROPERTY); 32 | if (configFileName == null || configFileName.isEmpty()) { 33 | configFileName = CONFIG_FILE_DEFAULT; 34 | } 35 | LOG.info("Use config file from {}: {}", CONFIG_FILE_SYSTEM_PROPERTY, configFileName); 36 | return new Settings(configFileName); 37 | } 38 | 39 | /** 40 | * Constructs Settings by loading properties from a classpath resource name, with a filesystem fallback. 41 | * The loading order is as follows: 42 | * 43 | *

    44 | *
  1. Try classpath resource via {@link java.lang.ClassLoader#getResourceAsStream(String)}
  2. 45 | *
  3. If not found, try reading from the filesystem path specified by {@code name}
  4. 46 | *
47 | * 48 | * @param name The classpath resource name or filesystem path. 49 | * @throws IllegalStateException if the settings file is not found in either location. 50 | * @throws RuntimeException if the settings file cannot be parsed. 51 | */ 52 | public Settings(@NonNull String name) { 53 | this(new Properties()); 54 | boolean fromClasspath = true; 55 | Path fsPath = null; 56 | 57 | // Resolve input stream from classpath first, then filesystem fallback 58 | InputStream resolved = getClass().getClassLoader().getResourceAsStream(name); 59 | if (resolved == null) { 60 | fromClasspath = false; 61 | fsPath = Paths.get(name); 62 | try { 63 | if (Files.exists(fsPath)) { 64 | resolved = Files.newInputStream(fsPath); 65 | } 66 | } catch (IOException e) { 67 | LOG.error("Error opening settings file from filesystem '{}': {}", name, e.getMessage()); 68 | throw new RuntimeException("Failed to open settings file: " + name, e); 69 | } 70 | } 71 | 72 | // Load properties from the resolved input stream 73 | try (InputStream input = resolved) { 74 | if (input == null) { 75 | LOG.error("Settings file '{}' not found in classpath or filesystem.", name); 76 | throw new IllegalStateException("Required settings file not found: " + name); 77 | } 78 | 79 | properties.load(input); 80 | 81 | if (fromClasspath) { 82 | LOG.info("Loaded settings from classpath: {}", name); 83 | } else { 84 | LOG.info("Loaded settings from filesystem: {}", fsPath.toAbsolutePath()); 85 | } 86 | } catch (IOException e) { 87 | LOG.error("Error reading settings file '{}': {}", name, e.getMessage()); 88 | throw new RuntimeException("Failed to load settings file: " + name, e); 89 | } 90 | } 91 | 92 | /** 93 | * Retrieves a mandatory string value from settings. Throws NullPointerException if key is not found. 94 | * 95 | * @param key The key to look up. 96 | * @return The string value associated with the key. 97 | * @throws NullPointerException if the key is not found. 98 | */ 99 | public @NonNull String getValue(String key) { 100 | String value = properties.getProperty(key); 101 | if (value == null) { 102 | LOG.error("Mandatory settings key '{}' not found.", key); 103 | throw new NullPointerException("Settings key '" + key + "' is missing."); 104 | } 105 | return value; 106 | } 107 | 108 | /** 109 | * Retrieves a string value from settings, with a fallback default. 110 | * 111 | * @param key The key to look up. 112 | * @param other The default value to return if the key is not found. 113 | * @return The string value, or the default if not found. 114 | */ 115 | public String getValue(String key, String other) { 116 | String value = properties.getProperty(key); 117 | return (value == null) ? other : value; 118 | } 119 | 120 | /** 121 | * Constructs the full source path by resolving a path from the "path.source" property 122 | * against the user's home directory. 123 | * 124 | * @return The resolved source path. 125 | * @throws IllegalArgumentException if "path.source" is missing. 126 | */ 127 | public @NonNull Path getSourcePath() { 128 | String relativeSourcePath = getValue("path.source"); 129 | return Paths.get(System.getProperty("user.home")).resolve(relativeSourcePath); 130 | } 131 | 132 | /** 133 | * Retrieves the MigratorMode from the {@code migrator.mode} property. 134 | * 135 | * @return The MigratorMode enum value. 136 | * @throws IllegalArgumentException if "migrator.mode" is missing or invalid. 137 | */ 138 | public @NonNull MigratorMode getMigratorMode() { 139 | return MigratorMode.valueOf(getValue("migrator.mode")); 140 | } 141 | 142 | /** 143 | * Check if delta migration is enabled. 144 | * 145 | *

When enabled, only new or modified files will be migrated based on 146 | * comparing file metadata (size and last modified time) against a state file 147 | * maintained at the destination. 148 | * 149 | * @return true if delta migration is enabled (delta.enabled=true), false by default 150 | */ 151 | public boolean isDeltaEnabled() { 152 | return Boolean.parseBoolean(getValue("delta.enabled", "false")); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/model/Photo.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.model; 2 | 3 | import com.drew.imaging.ImageMetadataReader; 4 | import com.drew.imaging.ImageProcessingException; 5 | import com.drew.metadata.Directory; 6 | import com.drew.metadata.Metadata; 7 | import com.drew.metadata.Tag; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.jetbrains.annotations.Nullable; 10 | import org.jspecify.annotations.NonNull; 11 | 12 | import java.io.IOException; 13 | import java.io.InputStream; 14 | import java.nio.file.Files; 15 | import java.nio.file.Path; 16 | import java.nio.file.attribute.FileTime; 17 | import java.time.LocalDateTime; 18 | import java.time.format.DateTimeFormatter; 19 | import java.util.Map; 20 | import java.util.concurrent.ConcurrentHashMap; 21 | 22 | /** 23 | * Represents a photo with its metadata. 24 | * 25 | *

Note that this metadata is not guaranteed for all photos. This metadata 26 | * exists on the following assets: 27 | * 28 | *

    29 | *
  • RAW formats from providers such as Canon, Nikon and Sony
  • 30 | *
  • JPG/JPEG files which were generated from Adobe Lightroom
  • 31 | *
32 | * 33 | *

Metadata is extracted lazily on first access to improve performance 34 | * during photo collection. 35 | */ 36 | public class Photo { 37 | // Metadata keys 38 | private static final String TAKEN_KEY = "Date/Time Original"; 39 | private static final String MAKE_KEY = "Make"; 40 | private static final String MODEL_KEY = "Model"; 41 | private static final String FOCAL_LENGTH_KEY = "Focal Length"; 42 | private static final String SHUTTER_SPEED_KEY = "Shutter Speed Value"; 43 | private static final String APERTURE_KEY = "Aperture Value"; 44 | private static final String FLASH_KEY = "Flash"; 45 | 46 | private final Path path; 47 | private final Map metadata = new ConcurrentHashMap<>(); 48 | private volatile boolean metadataLoaded = false; 49 | 50 | public Photo(Path path) { 51 | this.path = path; 52 | } 53 | 54 | /** 55 | * Get photo file name. 56 | * 57 | * @return file name 58 | */ 59 | @NotNull 60 | public String name() { 61 | return path.getFileName().toString(); 62 | } 63 | 64 | /** 65 | * Get the file path. 66 | * 67 | * @return the path 68 | */ 69 | @NotNull 70 | public Path path() { 71 | return path; 72 | } 73 | 74 | /** 75 | * Get photo taken time metadata. 76 | * 77 | * @return taken time string or null 78 | */ 79 | @Nullable 80 | public String taken() { 81 | return getMetadata(TAKEN_KEY); 82 | } 83 | 84 | /** 85 | * Get camera make metadata. 86 | * 87 | * @return make string or null 88 | */ 89 | @Nullable 90 | public String make() { 91 | return getMetadata(MAKE_KEY); 92 | } 93 | 94 | /** 95 | * Get camera model metadata. 96 | * 97 | * @return model string or null 98 | */ 99 | @Nullable 100 | public String model() { 101 | return getMetadata(MODEL_KEY); 102 | } 103 | 104 | /** 105 | * Get focal length metadata. 106 | * 107 | * @return focal length string or null 108 | */ 109 | @Nullable 110 | public String focalLength() { 111 | return getMetadata(FOCAL_LENGTH_KEY); 112 | } 113 | 114 | /** 115 | * Get shutter speed metadata. 116 | * 117 | * @return shutter speed string or null 118 | */ 119 | @Nullable 120 | public String shutterSpeed() { 121 | return getMetadata(SHUTTER_SPEED_KEY); 122 | } 123 | 124 | /** 125 | * Get aperture metadata. 126 | * 127 | * @return aperture string or null 128 | */ 129 | @Nullable 130 | public String aperture() { 131 | return getMetadata(APERTURE_KEY); 132 | } 133 | 134 | /** 135 | * Get flash metadata. 136 | * 137 | * @return flash string or null 138 | */ 139 | @Nullable 140 | public String flash() { 141 | return getMetadata(FLASH_KEY); 142 | } 143 | 144 | /** 145 | * Get photo modified time. 146 | * 147 | * @return modified time as {@code FileTime} 148 | */ 149 | @Nullable 150 | public FileTime modifiedAt() { 151 | try { 152 | return Files.getLastModifiedTime(path); 153 | } catch (IOException e) { 154 | return null; 155 | } 156 | } 157 | 158 | /** 159 | * Get photo taken time, parsed from image tags. 160 | * 161 | * @return taken time as {@code LocalDateTime} 162 | */ 163 | @Nullable 164 | public LocalDateTime takenAt() { 165 | String taken = taken(); 166 | if (taken == null) return null; 167 | DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss"); 168 | try { 169 | return LocalDateTime.parse(taken, formatter); 170 | } catch (Exception e) { 171 | return null; 172 | } 173 | } 174 | 175 | /** 176 | * Get metadata value by key, ensuring metadata is loaded first. 177 | * 178 | * @param key the metadata key 179 | * @return the metadata value or null 180 | */ 181 | @Nullable 182 | private String getMetadata(String key) { 183 | ensureMetadataLoaded(); 184 | return metadata.get(key); 185 | } 186 | 187 | /** 188 | * Ensure metadata is loaded before accessing. 189 | */ 190 | private void ensureMetadataLoaded() { 191 | if (!metadataLoaded) { 192 | synchronized (this) { 193 | if (!metadataLoaded) { 194 | loadMetadata(); 195 | metadataLoaded = true; 196 | } 197 | } 198 | } 199 | } 200 | 201 | /** 202 | * Load metadata from the photo file. 203 | */ 204 | private void loadMetadata() { 205 | extractMetadata(path, metadata); 206 | } 207 | 208 | /** 209 | * Extract metadata from a photo file into the provided map. 210 | * 211 | * @param photoPath the path to the photo file 212 | * @param metadata the map to store metadata in 213 | */ 214 | private static void extractMetadata(@NonNull Path photoPath, @NonNull Map metadata) { 215 | try (InputStream input = Files.newInputStream(photoPath)) { 216 | Metadata imageMetadata = ImageMetadataReader.readMetadata(input); 217 | for (Directory directory : imageMetadata.getDirectories()) { 218 | for (Tag tag : directory.getTags()) { 219 | metadata.put(tag.getTagName(), tag.getDescription()); 220 | } 221 | } 222 | } catch (IOException | ImageProcessingException e) { 223 | // Metadata extraction failed, metadata map remains empty 224 | } 225 | } 226 | 227 | @Override 228 | public boolean equals(Object obj) { 229 | if (this == obj) return true; 230 | if (!(obj instanceof Photo other)) return false; 231 | return path.equals(other.path); 232 | } 233 | 234 | @Override 235 | public int hashCode() { 236 | return path.hashCode(); 237 | } 238 | 239 | @Override 240 | public @NonNull String toString() { 241 | return "Photo{path=" + path + "}"; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestMigratorFactory.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.Settings; 4 | import io.huangsam.photohaul.resolution.PhotoResolver; 5 | import org.jspecify.annotations.NonNull; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.util.List; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | import static org.junit.jupiter.api.Assertions.assertInstanceOf; 15 | import static org.junit.jupiter.api.Assertions.assertSame; 16 | import static org.junit.jupiter.api.Assertions.assertThrows; 17 | 18 | public class TestMigratorFactory { 19 | private static final MigratorFactory FACTORY = new MigratorFactory(); 20 | private static final PhotoResolver RESOLVER = new PhotoResolver(List.of()); 21 | 22 | @Test 23 | void testMakePathMigratorSuccess() throws Exception { 24 | Settings settings = new Settings("path-example.properties"); 25 | try (Migrator migrator = FACTORY.make(MigratorMode.PATH, settings, RESOLVER)) { 26 | assertSame(PathMigrator.class, migrator.getClass()); 27 | } 28 | } 29 | 30 | @Test 31 | void testMakeDropboxMigratorSuccess() throws Exception { 32 | Settings settings = new Settings("dbx-example.properties"); 33 | try (Migrator migrator = FACTORY.make(MigratorMode.DROPBOX, settings, RESOLVER)) { 34 | assertSame(DropboxMigrator.class, migrator.getClass()); 35 | } 36 | } 37 | 38 | @Test 39 | @SuppressWarnings("resource") 40 | void testMakeGoogleDriveMigratorFailure() { 41 | Settings settings = new Settings("drive-example.properties"); 42 | MigrationException exception = assertThrows(MigrationException.class, () -> FACTORY.make(MigratorMode.GOOGLE_DRIVE, settings, RESOLVER)); 43 | assertEquals(MigratorMode.GOOGLE_DRIVE, exception.getMode()); 44 | } 45 | 46 | @Test 47 | void testMakeSftpMigratorSuccess() throws Exception { 48 | Settings settings = new Settings("sftp-example.properties"); 49 | try (Migrator migrator = FACTORY.make(MigratorMode.SFTP, settings, RESOLVER)) { 50 | assertSame(SftpMigrator.class, migrator.getClass()); 51 | } 52 | } 53 | 54 | @Test 55 | void testMakeS3MigratorSuccess() throws Exception { 56 | Settings settings = new Settings("s3-example.properties"); 57 | try (Migrator migrator = FACTORY.make(MigratorMode.S3, settings, RESOLVER)) { 58 | assertSame(S3Migrator.class, migrator.getClass()); 59 | } 60 | } 61 | 62 | @Test 63 | void testMakePathMigratorWithDeltaEnabled(@TempDir @NonNull Path tempDir) throws Exception { 64 | // Create a temporary properties file with delta enabled 65 | Path propsFile = tempDir.resolve("delta-path.properties"); 66 | String propsContent = String.format( 67 | "migrator.mode=PATH%n" + 68 | "path.source=Dummy/Source%n" + 69 | "path.target=Dummy/Target%n" + 70 | "path.action=DRY_RUN%n" + 71 | "delta.enabled=true%n" 72 | ); 73 | Files.writeString(propsFile, propsContent); 74 | 75 | Settings settings = new Settings(propsFile.toString()); 76 | try (Migrator migrator = FACTORY.make(MigratorMode.PATH, settings, RESOLVER)) { 77 | assertInstanceOf(DeltaMigrator.class, migrator); 78 | } 79 | } 80 | 81 | @Test 82 | void testMakeDropboxMigratorWithDeltaEnabled(@TempDir @NonNull Path tempDir) throws Exception { 83 | // Create a temporary properties file with delta enabled 84 | Path propsFile = tempDir.resolve("delta-dbx.properties"); 85 | String propsContent = String.format( 86 | "migrator.mode=DROPBOX%n" + 87 | "path.source=Dummy/Source%n" + 88 | "dbx.target=/Demo/Target%n" + 89 | "dbx.clientId=TestClient%n" + 90 | "dbx.accessToken=TestToken%n" + 91 | "delta.enabled=true%n" 92 | ); 93 | Files.writeString(propsFile, propsContent); 94 | 95 | Settings settings = new Settings(propsFile.toString()); 96 | try (Migrator migrator = FACTORY.make(MigratorMode.DROPBOX, settings, RESOLVER)) { 97 | assertInstanceOf(DeltaMigrator.class, migrator); 98 | } 99 | } 100 | 101 | @Test 102 | void testMakeSftpMigratorWithDeltaEnabled(@TempDir @NonNull Path tempDir) throws Exception { 103 | // Create a temporary properties file with delta enabled 104 | Path propsFile = tempDir.resolve("delta-sftp.properties"); 105 | String propsContent = String.format( 106 | "migrator.mode=SFTP%n" + 107 | "path.source=Dummy/Source%n" + 108 | "sftp.host=localhost%n" + 109 | "sftp.port=22%n" + 110 | "sftp.username=user%n" + 111 | "sftp.password=pass%n" + 112 | "sftp.target=/photos%n" + 113 | "delta.enabled=true%n" 114 | ); 115 | Files.writeString(propsFile, propsContent); 116 | 117 | Settings settings = new Settings(propsFile.toString()); 118 | try (Migrator migrator = FACTORY.make(MigratorMode.SFTP, settings, RESOLVER)) { 119 | assertInstanceOf(DeltaMigrator.class, migrator); 120 | } 121 | } 122 | 123 | @Test 124 | void testMakeS3MigratorWithDeltaEnabled(@TempDir @NonNull Path tempDir) throws Exception { 125 | // Create a temporary properties file with delta enabled 126 | Path propsFile = tempDir.resolve("delta-s3.properties"); 127 | String propsContent = String.format( 128 | "migrator.mode=S3%n" + 129 | "path.source=Dummy/Source%n" + 130 | "s3.bucket=test-bucket%n" + 131 | "s3.accessKey=accessKey%n" + 132 | "s3.secretKey=secretKey%n" + 133 | "s3.region=us-east-1%n" + 134 | "delta.enabled=true%n" 135 | ); 136 | Files.writeString(propsFile, propsContent); 137 | 138 | Settings settings = new Settings(propsFile.toString()); 139 | try (Migrator migrator = FACTORY.make(MigratorMode.S3, settings, RESOLVER)) { 140 | assertInstanceOf(DeltaMigrator.class, migrator); 141 | } 142 | } 143 | 144 | @Test 145 | void testMakePathMigratorWithDeltaDisabled() throws Exception { 146 | Settings settings = new Settings("path-example.properties"); 147 | try (Migrator migrator = FACTORY.make(MigratorMode.PATH, settings, RESOLVER)) { 148 | // Default is delta disabled, so should be PathMigrator 149 | assertSame(PathMigrator.class, migrator.getClass()); 150 | } 151 | } 152 | 153 | @Test 154 | void testMakePathMigratorWithExplicitDeltaDisabled(@TempDir @NonNull Path tempDir) throws Exception { 155 | // Create a temporary properties file with delta explicitly disabled 156 | Path propsFile = tempDir.resolve("nodelta-path.properties"); 157 | String propsContent = String.format( 158 | "migrator.mode=PATH%n" + 159 | "path.source=Dummy/Source%n" + 160 | "path.target=Dummy/Target%n" + 161 | "path.action=DRY_RUN%n" + 162 | "delta.enabled=false%n" 163 | ); 164 | Files.writeString(propsFile, propsContent); 165 | 166 | Settings settings = new Settings(propsFile.toString()); 167 | try (Migrator migrator = FACTORY.make(MigratorMode.PATH, settings, RESOLVER)) { 168 | assertSame(PathMigrator.class, migrator.getClass()); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestGoogleDriveMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import com.google.api.client.http.HttpTransport; 4 | import com.google.api.services.drive.Drive; 5 | import com.google.api.services.drive.model.File; 6 | import com.google.api.services.drive.model.FileList; 7 | import io.huangsam.photohaul.model.Photo; 8 | import io.huangsam.photohaul.resolution.PhotoResolver; 9 | import io.huangsam.photohaul.resolution.ResolutionException; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.Mock; 13 | import org.mockito.junit.jupiter.MockitoExtension; 14 | import org.mockito.junit.jupiter.MockitoSettings; 15 | import org.mockito.quality.Strictness; 16 | 17 | import java.util.List; 18 | 19 | import static org.junit.jupiter.api.Assertions.assertEquals; 20 | import static org.mockito.ArgumentMatchers.any; 21 | import static org.mockito.ArgumentMatchers.anyString; 22 | import static org.mockito.Mockito.times; 23 | import static org.mockito.Mockito.verify; 24 | import static org.mockito.Mockito.when; 25 | 26 | @ExtendWith(MockitoExtension.class) 27 | @MockitoSettings(strictness = Strictness.STRICT_STUBS) 28 | public class TestGoogleDriveMigrator extends TestMigrationAbstract { 29 | private static final String TARGET_ROOT = "rootId123"; 30 | 31 | @Mock 32 | Drive driveMock; 33 | 34 | @Mock 35 | HttpTransport httpTransportMock; 36 | 37 | @Mock 38 | PhotoResolver photoResolverMock; 39 | 40 | @Mock 41 | Drive.Files filesMock; 42 | 43 | @Mock 44 | Drive.Files.List driveListMock; 45 | 46 | @Mock 47 | FileList fileListMock; 48 | 49 | @Mock 50 | File listedFileMock; 51 | 52 | @Mock 53 | Drive.Files.Create driveCreateFolderMock; 54 | 55 | @Mock 56 | File createdFolderMock; 57 | 58 | @Mock 59 | Drive.Files.Create driveCreatePhotoMock; 60 | 61 | @Mock 62 | File createdPhotoMock; 63 | 64 | @Test 65 | void testMigratePhotosAllSuccess() throws Exception { 66 | when(driveMock.files()).thenReturn(filesMock); 67 | 68 | when(filesMock.list()).thenReturn(driveListMock); 69 | when(driveListMock.setQ(anyString())).thenReturn(driveListMock); 70 | when(driveListMock.execute()).thenReturn(fileListMock); 71 | when(fileListMock.getFiles()).thenReturn(List.of(listedFileMock)); 72 | when(listedFileMock.getId()).thenReturn(null); 73 | 74 | when(filesMock.create(any())).thenReturn(driveCreateFolderMock); 75 | when(driveCreateFolderMock.setFields(anyString())).thenReturn(driveCreateFolderMock); 76 | when(driveCreateFolderMock.execute()).thenReturn(createdFolderMock); 77 | when(createdFolderMock.getId()).thenReturn("nestedId123"); 78 | 79 | when(filesMock.create(any(), any())).thenReturn(driveCreatePhotoMock); 80 | when(driveCreatePhotoMock.setFields(anyString())).thenReturn(driveCreatePhotoMock); 81 | when(driveCreatePhotoMock.execute()).thenReturn(createdPhotoMock); 82 | 83 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 84 | 85 | Migrator migrator = new GoogleDriveMigrator(TARGET_ROOT, photoResolverMock, driveMock, httpTransportMock); 86 | run(migrator); 87 | 88 | verify(filesMock, times(4)).list(); 89 | verify(driveCreateFolderMock, times(2)).execute(); 90 | verify(driveCreatePhotoMock, times(2)).execute(); 91 | 92 | assertEquals(2, migrator.getSuccessCount()); 93 | assertEquals(0, migrator.getFailureCount()); 94 | 95 | migrator.close(); 96 | verify(httpTransportMock).shutdown(); 97 | } 98 | 99 | @Test 100 | void testMigratePhotosAllExisting() throws Exception { 101 | when(driveMock.files()).thenReturn(filesMock); 102 | 103 | when(filesMock.list()).thenReturn(driveListMock); 104 | when(driveListMock.setQ(anyString())).thenReturn(driveListMock); 105 | when(driveListMock.execute()).thenReturn(fileListMock); 106 | when(fileListMock.getFiles()).thenReturn(List.of(listedFileMock)); 107 | when(listedFileMock.getId()).thenReturn("existingId123"); 108 | 109 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 110 | 111 | Migrator migrator = new GoogleDriveMigrator(TARGET_ROOT, photoResolverMock, driveMock, httpTransportMock); 112 | run(migrator); 113 | 114 | verify(listedFileMock, times(4)).getId(); 115 | verify(filesMock, times(0)).create(any()); 116 | verify(filesMock, times(0)).create(any(), any()); 117 | 118 | assertEquals(2, migrator.getSuccessCount()); 119 | assertEquals(0, migrator.getFailureCount()); 120 | 121 | migrator.close(); 122 | verify(httpTransportMock).shutdown(); 123 | } 124 | 125 | @Test 126 | void testMigratePhotosWithNullFolder() throws Exception { 127 | when(driveMock.files()).thenReturn(filesMock); 128 | 129 | when(filesMock.list()).thenReturn(driveListMock); 130 | when(driveListMock.setQ(anyString())).thenReturn(driveListMock); 131 | when(driveListMock.execute()).thenReturn(fileListMock); 132 | when(fileListMock.getFiles()).thenReturn(List.of(listedFileMock)); 133 | 134 | when(filesMock.create(any())).thenReturn(driveCreateFolderMock); 135 | when(driveCreateFolderMock.setFields(anyString())).thenReturn(driveCreateFolderMock); 136 | when(driveCreateFolderMock.execute()).thenReturn(createdFolderMock); 137 | when(createdFolderMock.getId()).thenReturn(null); 138 | 139 | when(photoResolverMock.resolveString(any(Photo.class))).thenReturn("some/path"); 140 | 141 | Migrator migrator = new GoogleDriveMigrator(TARGET_ROOT, photoResolverMock, driveMock, httpTransportMock); 142 | run(migrator); 143 | 144 | verify(filesMock, times(2)).list(); 145 | verify(driveCreateFolderMock, times(2)).execute(); 146 | 147 | assertEquals(0, migrator.getSuccessCount()); 148 | assertEquals(2, migrator.getFailureCount()); 149 | 150 | migrator.close(); 151 | verify(httpTransportMock).shutdown(); 152 | } 153 | 154 | @Test 155 | void testMigratePhotosWithResolutionException() throws Exception { 156 | when(driveMock.files()).thenReturn(filesMock); 157 | 158 | when(filesMock.list()).thenReturn(driveListMock); 159 | when(driveListMock.setQ(anyString())).thenReturn(driveListMock); 160 | when(driveListMock.execute()).thenReturn(fileListMock); 161 | when(fileListMock.getFiles()).thenReturn(List.of(listedFileMock)); 162 | when(listedFileMock.getId()).thenReturn(null); 163 | 164 | when(filesMock.create(any())).thenReturn(driveCreateFolderMock); 165 | when(driveCreateFolderMock.setFields(anyString())).thenReturn(driveCreateFolderMock); 166 | when(driveCreateFolderMock.execute()).thenReturn(createdFolderMock); 167 | when(createdFolderMock.getId()).thenReturn("nestedId123"); 168 | 169 | when(filesMock.create(any(), any())).thenReturn(driveCreatePhotoMock); 170 | when(driveCreatePhotoMock.setFields(anyString())).thenReturn(driveCreatePhotoMock); 171 | when(driveCreatePhotoMock.execute()).thenReturn(createdPhotoMock); 172 | 173 | when(photoResolverMock.resolveString(any(Photo.class))).thenThrow(new ResolutionException("Resolution failed")); 174 | 175 | Migrator migrator = new GoogleDriveMigrator(TARGET_ROOT, photoResolverMock, driveMock, httpTransportMock); 176 | run(migrator); 177 | 178 | verify(filesMock, times(4)).list(); 179 | verify(driveCreateFolderMock, times(2)).execute(); 180 | verify(driveCreatePhotoMock, times(2)).execute(); 181 | 182 | assertEquals(2, migrator.getSuccessCount()); 183 | assertEquals(0, migrator.getFailureCount()); 184 | 185 | migrator.close(); 186 | verify(httpTransportMock).shutdown(); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/migration/MigratorFactory.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import com.dropbox.core.DbxRequestConfig; 4 | import com.dropbox.core.v2.DbxClientV2; 5 | import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; 6 | import com.google.api.client.http.HttpRequestInitializer; 7 | import com.google.api.client.json.JsonFactory; 8 | import com.google.api.client.json.gson.GsonFactory; 9 | import com.google.api.services.drive.Drive; 10 | import com.google.api.services.drive.DriveScopes; 11 | import com.google.auth.http.HttpCredentialsAdapter; 12 | import com.google.auth.oauth2.GoogleCredentials; 13 | import io.huangsam.photohaul.Settings; 14 | import io.huangsam.photohaul.migration.state.MigrationStateFile; 15 | import io.huangsam.photohaul.migration.state.PathStateStorage; 16 | import io.huangsam.photohaul.migration.state.StateFileStorage; 17 | import io.huangsam.photohaul.resolution.PhotoResolver; 18 | import org.jetbrains.annotations.NotNull; 19 | import org.jspecify.annotations.NonNull; 20 | import org.slf4j.Logger; 21 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; 22 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; 23 | import software.amazon.awssdk.regions.Region; 24 | import software.amazon.awssdk.services.s3.S3Client; 25 | 26 | import java.io.FileNotFoundException; 27 | import java.io.IOException; 28 | import java.io.InputStream; 29 | import java.nio.file.Path; 30 | import java.nio.file.Paths; 31 | import java.security.GeneralSecurityException; 32 | import java.util.List; 33 | 34 | import static org.slf4j.LoggerFactory.getLogger; 35 | 36 | /** 37 | * A factory class for creating {@link Migrator} instances based on the desired 38 | * migration strategy. 39 | */ 40 | public class MigratorFactory { 41 | private static final Logger LOG = getLogger(MigratorFactory.class); 42 | 43 | /** 44 | * Create instance for migrating photos. 45 | * 46 | * @param mode migrator mode 47 | * @param settings settings for migration process 48 | * @param resolver photo resolver for target path 49 | * @return migrator instance 50 | */ 51 | public @NonNull Migrator make(@NotNull MigratorMode mode, @NonNull Settings settings, PhotoResolver resolver) { 52 | Migrator baseMigrator = switch (mode) { 53 | case PATH -> makePath(settings, resolver); 54 | case DROPBOX -> makeDropbox(settings, resolver); 55 | case GOOGLE_DRIVE -> makeGoogleDrive(settings, resolver); 56 | case SFTP -> makeSftp(settings, resolver); 57 | case S3 -> makeS3(settings, resolver); 58 | }; 59 | 60 | // Wrap with DeltaMigrator if delta migration is enabled 61 | if (settings.isDeltaEnabled()) { 62 | StateFileStorage stateStorage = createStateStorage(mode, settings); 63 | LOG.info("Delta migration enabled for mode {}", mode); 64 | MigrationStateFile stateFile = new MigrationStateFile(stateStorage); 65 | return new DeltaMigrator(baseMigrator, stateFile); 66 | } 67 | 68 | return baseMigrator; 69 | } 70 | 71 | /** 72 | * Create a StateFileStorage for the given migrator mode. 73 | * 74 | * @param mode the migrator mode 75 | * @param settings the settings 76 | * @return a non-null StateFileStorage instance for the given migrator mode 77 | */ 78 | private @NonNull StateFileStorage createStateStorage(@NotNull MigratorMode mode, @NotNull Settings settings) { 79 | return switch (mode) { 80 | case PATH -> new PathStateStorage(getPathTargetDirectory(settings)); 81 | // Delta migration for cloud storage types requires additional implementation 82 | // For now, they use local state storage as a fallback 83 | case DROPBOX, GOOGLE_DRIVE, SFTP, S3 -> { 84 | // Use source path for state storage as fallback for cloud destinations 85 | Path sourcePath = settings.getSourcePath(); 86 | LOG.info("Using local state storage at {} for {} destination", sourcePath, mode); 87 | yield new PathStateStorage(sourcePath); 88 | } 89 | }; 90 | } 91 | 92 | /** 93 | * Get the target directory path for PATH migrator mode. 94 | * 95 | * @param settings the settings 96 | * @return the resolved target path 97 | */ 98 | @NotNull 99 | private Path getPathTargetDirectory(@NotNull Settings settings) { 100 | return Paths.get(System.getProperty("user.home")) 101 | .resolve(settings.getValue("path.target")); 102 | } 103 | 104 | @NotNull 105 | private PathMigrator makePath(@NotNull Settings settings, PhotoResolver resolver) { 106 | Path target = getPathTargetDirectory(settings); 107 | String actionValue = settings.getValue("path.action", "MOVE").toUpperCase(); 108 | return new PathMigrator(target, resolver, PathMigrator.Action.valueOf(actionValue)); 109 | } 110 | 111 | @NotNull 112 | private DropboxMigrator makeDropbox(@NotNull Settings settings, PhotoResolver resolver) { 113 | String target = settings.getValue("dbx.target"); 114 | DbxRequestConfig config = DbxRequestConfig.newBuilder(settings.getValue("dbx.clientId")).build(); 115 | DbxClientV2 client = new DbxClientV2(config, settings.getValue("dbx.accessToken")); 116 | return new DropboxMigrator(target, resolver, client); 117 | } 118 | 119 | private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); 120 | private static final List SCOPES = List.of(DriveScopes.DRIVE); 121 | 122 | @NotNull 123 | private GoogleDriveMigrator makeGoogleDrive(@NotNull Settings settings, PhotoResolver resolver) { 124 | String fileName = settings.getValue("drive.credentialFile"); 125 | String app = settings.getValue("drive.appName"); 126 | try { 127 | com.google.api.client.http.HttpTransport transport = GoogleNetHttpTransport.newTrustedTransport(); 128 | try (InputStream in = getClass().getClassLoader().getResourceAsStream(fileName)) { 129 | if (in == null) { 130 | throw new FileNotFoundException("Cannot find " + fileName); 131 | } 132 | GoogleCredentials credentials = GoogleCredentials.fromStream(in).createScoped(SCOPES); 133 | HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials); 134 | 135 | Drive service = new Drive.Builder(transport, JSON_FACTORY, requestInitializer) 136 | .setApplicationName(app) 137 | .build(); 138 | 139 | return new GoogleDriveMigrator(settings.getValue("drive.target"), resolver, service, transport); 140 | } 141 | } catch (GeneralSecurityException | IOException e) { 142 | throw new MigrationException(e.getMessage(), MigratorMode.GOOGLE_DRIVE); 143 | } 144 | } 145 | 146 | @NotNull 147 | private SftpMigrator makeSftp(@NotNull Settings settings, PhotoResolver resolver) { 148 | String host = settings.getValue("sftp.host"); 149 | int port = Integer.parseInt(settings.getValue("sftp.port", "22")); 150 | String username = settings.getValue("sftp.username"); 151 | String password = settings.getValue("sftp.password"); 152 | String target = settings.getValue("sftp.target"); 153 | return new SftpMigrator(host, port, username, password, target, resolver); 154 | } 155 | 156 | @NotNull 157 | private S3Migrator makeS3(@NotNull Settings settings, PhotoResolver resolver) { 158 | String accessKey = settings.getValue("s3.accessKey"); 159 | String secretKey = settings.getValue("s3.secretKey"); 160 | String region = settings.getValue("s3.region", "us-east-1"); 161 | String bucket = settings.getValue("s3.bucket"); 162 | AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); 163 | S3Client s3Client = S3Client.builder() 164 | .credentialsProvider(StaticCredentialsProvider.create(credentials)) 165 | .region(Region.of(region)) 166 | .build(); 167 | return new S3Migrator(bucket, resolver, s3Client); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/state/TestMigrationStateFile.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration.state; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.ArgumentCaptor; 7 | import org.mockito.Mock; 8 | import org.mockito.junit.jupiter.MockitoExtension; 9 | 10 | import java.io.IOException; 11 | 12 | import static org.junit.jupiter.api.Assertions.assertEquals; 13 | import static org.junit.jupiter.api.Assertions.assertFalse; 14 | import static org.junit.jupiter.api.Assertions.assertTrue; 15 | import static org.mockito.ArgumentMatchers.anyString; 16 | import static org.mockito.ArgumentMatchers.eq; 17 | import static org.mockito.Mockito.verify; 18 | import static org.mockito.Mockito.when; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | public class TestMigrationStateFile { 22 | @Mock 23 | StateFileStorage mockStorage; 24 | 25 | private MigrationStateFile stateFile; 26 | 27 | @BeforeEach 28 | void setUp() { 29 | stateFile = new MigrationStateFile(mockStorage); 30 | } 31 | 32 | @Test 33 | void testLoadWithEmptyFile() throws IOException { 34 | when(mockStorage.readStateFile(anyString())).thenReturn(null); 35 | 36 | stateFile.load(); 37 | 38 | assertEquals(0, stateFile.size()); 39 | } 40 | 41 | @Test 42 | void testLoadWithValidJson() throws IOException { 43 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 44 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 45 | 46 | stateFile.load(); 47 | 48 | assertEquals(1, stateFile.size()); 49 | } 50 | 51 | @Test 52 | void testLoadWithIOException() throws IOException { 53 | when(mockStorage.readStateFile(anyString())).thenThrow(new IOException("Read error")); 54 | 55 | // Should not throw, just log warning 56 | stateFile.load(); 57 | 58 | assertEquals(0, stateFile.size()); 59 | } 60 | 61 | @Test 62 | void testLoadWithMalformedJson() throws IOException { 63 | String malformedJson = "{ this is not valid json }"; 64 | when(mockStorage.readStateFile(anyString())).thenReturn(malformedJson); 65 | 66 | // Should not throw, just log warning and proceed with empty state 67 | stateFile.load(); 68 | 69 | assertEquals(0, stateFile.size()); 70 | } 71 | 72 | @Test 73 | void testNeedsMigrationReturnsTrueForNewFile() { 74 | FileState newFile = new FileState("/path/new.jpg", 1024, 1700000000000L); 75 | 76 | assertTrue(stateFile.needsMigration(newFile)); 77 | } 78 | 79 | @Test 80 | void testNeedsMigrationReturnsFalseForUnchangedFile() throws IOException { 81 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 82 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 83 | stateFile.load(); 84 | 85 | FileState unchanged = new FileState("/path/photo.jpg", 1024, 1700000000000L); 86 | 87 | assertFalse(stateFile.needsMigration(unchanged)); 88 | } 89 | 90 | @Test 91 | void testNeedsMigrationReturnsTrueForModifiedFile() throws IOException { 92 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 93 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 94 | stateFile.load(); 95 | 96 | FileState modified = new FileState("/path/photo.jpg", 2048, 1700000001000L); 97 | 98 | assertTrue(stateFile.needsMigration(modified)); 99 | } 100 | 101 | @Test 102 | void testRecordMigration() { 103 | FileState fileState = new FileState("/path/photo.jpg", 1024, 1700000000000L); 104 | 105 | stateFile.recordMigration(fileState); 106 | 107 | assertEquals(1, stateFile.size()); 108 | assertFalse(stateFile.needsMigration(fileState)); 109 | } 110 | 111 | @Test 112 | void testSave() throws IOException { 113 | FileState fileState = new FileState("/path/photo.jpg", 1024, 1700000000000L); 114 | stateFile.recordMigration(fileState); 115 | 116 | stateFile.save(); 117 | 118 | verify(mockStorage).writeStateFile(eq(MigrationStateFile.DEFAULT_STATE_FILE_NAME), anyString()); 119 | } 120 | 121 | @Test 122 | void testCustomStateFileName() { 123 | MigrationStateFile customFile = new MigrationStateFile(mockStorage, "custom_state.json"); 124 | 125 | assertEquals("custom_state.json", customFile.getStateFileName()); 126 | } 127 | 128 | @Test 129 | void testDefaultStateFileName() { 130 | assertEquals(MigrationStateFile.DEFAULT_STATE_FILE_NAME, stateFile.getStateFileName()); 131 | } 132 | 133 | @Test 134 | void testLoadWithBlankContent() throws IOException { 135 | when(mockStorage.readStateFile(anyString())).thenReturn(" "); 136 | 137 | stateFile.load(); 138 | 139 | assertEquals(0, stateFile.size()); 140 | } 141 | 142 | @Test 143 | void testLoadWithEmptyJsonObject() throws IOException { 144 | when(mockStorage.readStateFile(anyString())).thenReturn("{}"); 145 | 146 | stateFile.load(); 147 | 148 | assertEquals(0, stateFile.size()); 149 | } 150 | 151 | @Test 152 | void testLoadWithMultipleFiles() throws IOException { 153 | String json = "{\"/path/photo1.jpg\":{\"path\":\"/path/photo1.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}," + 154 | "\"/path/photo2.jpg\":{\"path\":\"/path/photo2.jpg\",\"size\":2048,\"lastModifiedMillis\":1700000001000}}"; 155 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 156 | 157 | stateFile.load(); 158 | 159 | assertEquals(2, stateFile.size()); 160 | } 161 | 162 | @Test 163 | void testRecordMigrationOverwritesExisting() { 164 | FileState original = new FileState("/path/photo.jpg", 1024, 1700000000000L); 165 | FileState updated = new FileState("/path/photo.jpg", 2048, 1700000001000L); 166 | 167 | stateFile.recordMigration(original); 168 | stateFile.recordMigration(updated); 169 | 170 | assertEquals(1, stateFile.size()); 171 | assertFalse(stateFile.needsMigration(updated)); 172 | assertTrue(stateFile.needsMigration(original)); 173 | } 174 | 175 | @Test 176 | void testSaveWritesCorrectJson() throws IOException { 177 | FileState fileState = new FileState("/path/photo.jpg", 1024, 1700000000000L); 178 | stateFile.recordMigration(fileState); 179 | 180 | stateFile.save(); 181 | 182 | ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); 183 | verify(mockStorage).writeStateFile(anyString(), contentCaptor.capture()); 184 | String savedContent = contentCaptor.getValue(); 185 | assertTrue(savedContent.contains("/path/photo.jpg")); 186 | assertTrue(savedContent.contains("1024")); 187 | assertTrue(savedContent.contains("1700000000000")); 188 | } 189 | 190 | @Test 191 | void testNeedsMigrationReturnsTrueForDifferentSizeSameTimestamp() throws IOException { 192 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 193 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 194 | stateFile.load(); 195 | 196 | // Same timestamp but different size 197 | FileState modified = new FileState("/path/photo.jpg", 2048, 1700000000000L); 198 | 199 | assertTrue(stateFile.needsMigration(modified)); 200 | } 201 | 202 | @Test 203 | void testNeedsMigrationReturnsTrueForDifferentTimestampSameSize() throws IOException { 204 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 205 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 206 | stateFile.load(); 207 | 208 | // Same size but different timestamp 209 | FileState modified = new FileState("/path/photo.jpg", 1024, 1700000001000L); 210 | 211 | assertTrue(stateFile.needsMigration(modified)); 212 | } 213 | 214 | @Test 215 | void testLoadClearsExistingState() throws IOException { 216 | // First, add a file manually 217 | FileState initial = new FileState("/path/initial.jpg", 512, 1600000000000L); 218 | stateFile.recordMigration(initial); 219 | assertEquals(1, stateFile.size()); 220 | 221 | // Then load new state from storage 222 | String json = "{\"/path/photo.jpg\":{\"path\":\"/path/photo.jpg\",\"size\":1024,\"lastModifiedMillis\":1700000000000}}"; 223 | when(mockStorage.readStateFile(anyString())).thenReturn(json); 224 | stateFile.load(); 225 | 226 | // Should have replaced the initial state 227 | assertEquals(1, stateFile.size()); 228 | assertTrue(stateFile.needsMigration(initial)); 229 | } 230 | 231 | @Test 232 | void testSaveEmptyState() throws IOException { 233 | stateFile.save(); 234 | 235 | ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); 236 | verify(mockStorage).writeStateFile(anyString(), contentCaptor.capture()); 237 | assertEquals("{}", contentCaptor.getValue()); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | 118 | 119 | # Determine the Java command to use to start the JVM. 120 | if [ -n "$JAVA_HOME" ] ; then 121 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 122 | # IBM's JDK on AIX uses strange locations for the executables 123 | JAVACMD=$JAVA_HOME/jre/sh/java 124 | else 125 | JAVACMD=$JAVA_HOME/bin/java 126 | fi 127 | if [ ! -x "$JAVACMD" ] ; then 128 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 129 | 130 | Please set the JAVA_HOME variable in your environment to match the 131 | location of your Java installation." 132 | fi 133 | else 134 | JAVACMD=java 135 | if ! command -v java >/dev/null 2>&1 136 | then 137 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 138 | 139 | Please set the JAVA_HOME variable in your environment to match the 140 | location of your Java installation." 141 | fi 142 | fi 143 | 144 | # Increase the maximum file descriptors if we can. 145 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 146 | case $MAX_FD in #( 147 | max*) 148 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 149 | # shellcheck disable=SC2039,SC3045 150 | MAX_FD=$( ulimit -H -n ) || 151 | warn "Could not query maximum file descriptor limit" 152 | esac 153 | case $MAX_FD in #( 154 | '' | soft) :;; #( 155 | *) 156 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 157 | # shellcheck disable=SC2039,SC3045 158 | ulimit -n "$MAX_FD" || 159 | warn "Could not set maximum file descriptor limit to $MAX_FD" 160 | esac 161 | fi 162 | 163 | # Collect all arguments for the java command, stacking in reverse order: 164 | # * args from the command line 165 | # * the main class name 166 | # * -classpath 167 | # * -D...appname settings 168 | # * --module-path (only if needed) 169 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 170 | 171 | # For Cygwin or MSYS, switch paths to Windows format before running java 172 | if "$cygwin" || "$msys" ; then 173 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | -------------------------------------------------------------------------------- /src/main/java/io/huangsam/photohaul/deduplication/PhotoDeduplicator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.deduplication; 2 | 3 | import io.huangsam.photohaul.model.Photo; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jspecify.annotations.NonNull; 6 | import org.slf4j.Logger; 7 | 8 | import java.io.IOException; 9 | import java.io.InputStream; 10 | import java.nio.file.Files; 11 | import java.security.MessageDigest; 12 | import java.security.NoSuchAlgorithmException; 13 | import java.util.Collection; 14 | import java.util.LinkedHashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.stream.Collectors; 18 | 19 | import static org.slf4j.LoggerFactory.getLogger; 20 | 21 | /** 22 | * Handles deduplication of photos using SHA-256 hashing. 23 | * 24 | *

This class identifies duplicate photos by computing their SHA-256 hash 25 | * and keeps only the first occurrence of each unique file. 26 | */ 27 | public class PhotoDeduplicator { 28 | private static final Logger LOG = getLogger(PhotoDeduplicator.class); 29 | private static final String HASH_ALGORITHM = "SHA-256"; 30 | 31 | /** 32 | * Deduplicate a collection of photos based on their SHA-256 hash. 33 | * 34 | *

For each photo, calculate its SHA-256 hash. If multiple photos have 35 | * the same hash, only the first occurrence is kept. The order of photos 36 | * in the input collection determines which photo is kept. 37 | * 38 | *

Optimization: Uses multi-level deduplication: 39 | * 1. File size filtering (different sizes cannot be duplicates) 40 | * 2. Partial hashing (first 1KB) for same-size files 41 | * 3. Full SHA-256 hashing only when partial hashes match 42 | * 43 | * @param photos collection of photos to deduplicate 44 | * @return collection of unique photos (first occurrence of each hash) 45 | */ 46 | @NotNull 47 | public Collection deduplicate(@NotNull Collection photos) { 48 | Map> photosBySize = groupBy(photos, this::safeGetFileSize); 49 | 50 | Map uniquePhotos = new LinkedHashMap<>(); 51 | int duplicateCount = photosBySize.values().stream() 52 | .mapToInt(sizeGroup -> processSizeGroup(sizeGroup, uniquePhotos)) 53 | .sum(); 54 | 55 | LOG.info("Deduplication complete: {} unique photos, {} duplicates removed", 56 | uniquePhotos.size(), duplicateCount); 57 | return uniquePhotos.values(); 58 | } 59 | 60 | /** 61 | * Group photos by a key function, preserving order. 62 | */ 63 | private @NonNull Map> groupBy(@NonNull Collection photos, java.util.function.@NonNull Function keyFunction) { 64 | return photos.stream() 65 | .collect(Collectors.groupingBy(keyFunction, LinkedHashMap::new, Collectors.toList())); 66 | } 67 | 68 | /** 69 | * Safely get file size, returning -1 on error. 70 | */ 71 | private @NonNull Long safeGetFileSize(@NonNull Photo photo) { 72 | try { 73 | return getFileSize(photo); 74 | } catch (IOException e) { 75 | return -1L; 76 | } 77 | } 78 | 79 | /** 80 | * Process a group of photos with the same size. 81 | */ 82 | private int processSizeGroup(@NonNull List sizeGroup, @NonNull Map uniquePhotos) { 83 | return sizeGroup.size() == 1 84 | ? addUniquePhotoBySize(sizeGroup.getFirst(), uniquePhotos) 85 | : deduplicateByPartialHash(sizeGroup, uniquePhotos); 86 | } 87 | 88 | /** 89 | * Add a photo that is unique by size. 90 | */ 91 | private int addUniquePhotoBySize(@NonNull Photo photo, @NonNull Map uniquePhotos) { 92 | try { 93 | long size = getFileSize(photo); 94 | String key = "size_" + size + "_" + photo.path(); 95 | uniquePhotos.put(key, photo); 96 | LOG.trace("Added unique photo by size: {} (size: {})", photo.name(), size); 97 | } catch (IOException e) { 98 | uniquePhotos.put(java.util.UUID.randomUUID().toString(), photo); 99 | } 100 | return 0; 101 | } 102 | 103 | /** 104 | * Calculate SHA-256 hash for a photo file. 105 | * 106 | * @param photo the photo to hash 107 | * @return hex-encoded SHA-256 hash of the file content 108 | * @throws IOException if file cannot be read 109 | * @throws NoSuchAlgorithmException if SHA-256 algorithm is not available 110 | */ 111 | @NotNull 112 | private String calculateHash(@NotNull Photo photo) throws IOException, NoSuchAlgorithmException { 113 | MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM); 114 | 115 | try (InputStream inputStream = Files.newInputStream(photo.path())) { 116 | byte[] buffer = new byte[65536]; // 64KB 117 | int bytesRead; 118 | 119 | while ((bytesRead = inputStream.read(buffer)) != -1) { 120 | digest.update(buffer, 0, bytesRead); 121 | } 122 | } 123 | 124 | byte[] hashBytes = digest.digest(); 125 | return bytesToHex(hashBytes); 126 | } 127 | 128 | /** 129 | * Get the file size of a photo. 130 | * 131 | * @param photo the photo 132 | * @return file size in bytes 133 | * @throws IOException if file cannot be accessed 134 | */ 135 | private long getFileSize(@NotNull Photo photo) throws IOException { 136 | return Files.size(photo.path()); 137 | } 138 | 139 | /** 140 | * Calculate partial SHA-256 hash for the first 1KB of a photo file. 141 | * 142 | * @param photo the photo to hash 143 | * @return hex-encoded SHA-256 hash of the first 1KB 144 | * @throws IOException if file cannot be read 145 | * @throws NoSuchAlgorithmException if SHA-256 algorithm is not available 146 | */ 147 | @NotNull 148 | private String calculatePartialHash(@NotNull Photo photo) throws IOException, NoSuchAlgorithmException { 149 | MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM); 150 | 151 | try (InputStream inputStream = Files.newInputStream(photo.path())) { 152 | byte[] buffer = new byte[1024]; // 1KB 153 | int bytesRead = inputStream.read(buffer); 154 | if (bytesRead > 0) { 155 | digest.update(buffer, 0, bytesRead); 156 | } 157 | } 158 | 159 | byte[] hashBytes = digest.digest(); 160 | return bytesToHex(hashBytes); 161 | } 162 | 163 | /** 164 | * Deduplicate a group of photos with the same file size using partial hashing. 165 | * 166 | * @param photos photos with the same size 167 | * @param uniquePhotos map to add unique photos to 168 | * @return number of duplicates found 169 | */ 170 | private int deduplicateByPartialHash(@NotNull List photos, @NotNull Map uniquePhotos) { 171 | Map> photosByPartialHash = groupBy(photos, this::safeCalculatePartialHash); 172 | 173 | return photosByPartialHash.values().stream() 174 | .mapToInt(partialGroup -> processPartialHashGroup(partialGroup, uniquePhotos)) 175 | .sum(); 176 | } 177 | 178 | /** 179 | * Safely calculate partial hash, returning error key on failure. 180 | */ 181 | private @NonNull String safeCalculatePartialHash(@NonNull Photo photo) { 182 | try { 183 | return calculatePartialHash(photo); 184 | } catch (IOException | NoSuchAlgorithmException e) { 185 | return "error_" + java.util.UUID.randomUUID(); 186 | } 187 | } 188 | 189 | /** 190 | * Process a group of photos with the same partial hash. 191 | */ 192 | private int processPartialHashGroup(@NonNull List partialGroup, @NonNull Map uniquePhotos) { 193 | return partialGroup.size() == 1 194 | ? addUniquePhotoByPartialHash(partialGroup.getFirst(), uniquePhotos) 195 | : deduplicateByFullHash(partialGroup, uniquePhotos); 196 | } 197 | 198 | /** 199 | * Add a photo that is unique by partial hash. 200 | */ 201 | private int addUniquePhotoByPartialHash(@NonNull Photo photo, @NonNull Map uniquePhotos) { 202 | try { 203 | String partialHash = calculatePartialHash(photo); 204 | String key = "partial_" + partialHash + "_" + photo.path(); 205 | uniquePhotos.put(key, photo); 206 | LOG.trace("Added unique photo by partial hash: {}", photo.name()); 207 | } catch (IOException | NoSuchAlgorithmException e) { 208 | uniquePhotos.put(java.util.UUID.randomUUID().toString(), photo); 209 | } 210 | return 0; 211 | } 212 | 213 | /** 214 | * Deduplicate a group of photos using full SHA-256 hashing. 215 | * 216 | * @param photos photos to deduplicate 217 | * @param uniquePhotos map to add unique photos to 218 | * @return number of duplicates found 219 | */ 220 | private int deduplicateByFullHash(@NotNull List photos, @NotNull Map uniquePhotos) { 221 | return photos.stream() 222 | .mapToInt(photo -> processPhotoForFullHash(photo, uniquePhotos)) 223 | .sum(); 224 | } 225 | 226 | /** 227 | * Process a single photo for full hash deduplication. 228 | */ 229 | private int processPhotoForFullHash(@NonNull Photo photo, @NonNull Map uniquePhotos) { 230 | try { 231 | String hash = calculateHash(photo); 232 | if (!uniquePhotos.containsKey(hash)) { 233 | uniquePhotos.put(hash, photo); 234 | LOG.trace("Added unique photo: {} (hash: {})", photo.name(), hash); 235 | return 0; 236 | } else { 237 | Photo original = uniquePhotos.get(hash); 238 | LOG.debug("Skipping duplicate: {} (original: {}, hash: {})", 239 | photo.name(), original.name(), hash); 240 | return 1; 241 | } 242 | } catch (IOException | NoSuchAlgorithmException e) { 243 | LOG.warn("Cannot calculate hash for {}: {}, including as unique", 244 | photo.name(), e.getMessage()); 245 | uniquePhotos.put(java.util.UUID.randomUUID().toString(), photo); 246 | return 0; 247 | } 248 | } 249 | 250 | /** 251 | * Convert byte array to hexadecimal string. 252 | * 253 | * @param bytes byte array to convert 254 | * @return hex-encoded string 255 | */ 256 | @NotNull 257 | private String bytesToHex(byte @NonNull [] bytes) { 258 | StringBuilder hexString = new StringBuilder(); 259 | for (byte b : bytes) { 260 | String hex = Integer.toHexString(0xff & b); 261 | if (hex.length() == 1) { 262 | hexString.append('0'); 263 | } 264 | hexString.append(hex); 265 | } 266 | return hexString.toString(); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/test/java/io/huangsam/photohaul/migration/TestDeltaMigrator.java: -------------------------------------------------------------------------------- 1 | package io.huangsam.photohaul.migration; 2 | 3 | import io.huangsam.photohaul.migration.state.MigrationStateFile; 4 | import io.huangsam.photohaul.migration.state.StateFileStorage; 5 | import io.huangsam.photohaul.model.Photo; 6 | import org.jspecify.annotations.NonNull; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.junit.jupiter.api.io.TempDir; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | 14 | import java.io.IOException; 15 | import java.nio.file.Files; 16 | import java.nio.file.Path; 17 | import java.util.List; 18 | 19 | import org.mockito.ArgumentMatchers; 20 | 21 | import static org.junit.jupiter.api.Assertions.assertEquals; 22 | import static org.mockito.ArgumentMatchers.any; 23 | import static org.mockito.ArgumentMatchers.anyString; 24 | import static org.mockito.Mockito.never; 25 | import static org.mockito.Mockito.verify; 26 | import static org.mockito.Mockito.when; 27 | import static org.mockito.Mockito.doThrow; 28 | 29 | @ExtendWith(MockitoExtension.class) 30 | public class TestDeltaMigrator { 31 | @Mock 32 | Migrator mockDelegate; 33 | 34 | @Mock 35 | StateFileStorage mockStorage; 36 | 37 | private DeltaMigrator deltaMigrator; 38 | 39 | @BeforeEach 40 | void setUp() { 41 | MigrationStateFile stateFile = new MigrationStateFile(mockStorage); 42 | deltaMigrator = new DeltaMigrator(mockDelegate, stateFile); 43 | } 44 | 45 | @Test 46 | void testMigratePhotosCallsDelegateForNewFiles(@TempDir @NonNull Path tempDir) throws IOException { 47 | // Create a test file 48 | Path testFile = tempDir.resolve("photo.jpg"); 49 | Files.writeString(testFile, "test content"); 50 | Photo photo = new Photo(testFile); 51 | 52 | when(mockStorage.readStateFile(any())).thenReturn(null); 53 | when(mockDelegate.getSuccessCount()).thenReturn(1L); 54 | 55 | deltaMigrator.migratePhotos(List.of(photo)); 56 | 57 | verify(mockDelegate).migratePhotos(any()); 58 | } 59 | 60 | @Test 61 | void testMigratePhotosSkipsUnchangedFiles(@TempDir @NonNull Path tempDir) throws IOException { 62 | // Create a test file 63 | Path testFile = tempDir.resolve("photo.jpg"); 64 | Files.writeString(testFile, "test content"); 65 | long size = Files.size(testFile); 66 | long lastModified = Files.getLastModifiedTime(testFile).toMillis(); 67 | 68 | // Create state file content that matches the file 69 | String stateJson = String.format( 70 | "{\"%s\":{\"path\":\"%s\",\"size\":%d,\"lastModifiedMillis\":%d}}", 71 | testFile.toString().replace("\\", "\\\\"), 72 | testFile.toString().replace("\\", "\\\\"), 73 | size, 74 | lastModified 75 | ); 76 | when(mockStorage.readStateFile(any())).thenReturn(stateJson); 77 | 78 | Photo photo = new Photo(testFile); 79 | deltaMigrator.migratePhotos(List.of(photo)); 80 | 81 | // Should not call delegate since file is unchanged 82 | verify(mockDelegate, never()).migratePhotos(any()); 83 | assertEquals(1, deltaMigrator.getSkippedCount()); 84 | } 85 | 86 | @Test 87 | void testMigratePhotosMigratesModifiedFiles(@TempDir @NonNull Path tempDir) throws IOException { 88 | // Create a test file 89 | Path testFile = tempDir.resolve("photo.jpg"); 90 | Files.writeString(testFile, "test content"); 91 | long lastModified = Files.getLastModifiedTime(testFile).toMillis(); 92 | 93 | // Create state file content with different size (simulating modification) 94 | String stateJson = String.format( 95 | "{\"%s\":{\"path\":\"%s\",\"size\":%d,\"lastModifiedMillis\":%d}}", 96 | testFile.toString().replace("\\", "\\\\"), 97 | testFile.toString().replace("\\", "\\\\"), 98 | 100L, // Different size 99 | lastModified 100 | ); 101 | when(mockStorage.readStateFile(any())).thenReturn(stateJson); 102 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 103 | 104 | Photo photo = new Photo(testFile); 105 | deltaMigrator.migratePhotos(List.of(photo)); 106 | 107 | // Should call delegate since file is modified 108 | verify(mockDelegate).migratePhotos(ArgumentMatchers.any()); 109 | } 110 | 111 | @Test 112 | void testGetSuccessCountDelegatesToWrapped() { 113 | when(mockDelegate.getSuccessCount()).thenReturn(5L); 114 | 115 | assertEquals(5L, deltaMigrator.getSuccessCount()); 116 | } 117 | 118 | @Test 119 | void testGetFailureCountDelegatesToWrapped() { 120 | when(mockDelegate.getFailureCount()).thenReturn(3L); 121 | 122 | assertEquals(3L, deltaMigrator.getFailureCount()); 123 | } 124 | 125 | @Test 126 | void testCloseCallsDelegateClose() throws Exception { 127 | deltaMigrator.close(); 128 | 129 | verify(mockDelegate).close(); 130 | } 131 | 132 | @Test 133 | void testGetSkippedCountInitiallyZero() { 134 | assertEquals(0, deltaMigrator.getSkippedCount()); 135 | } 136 | 137 | @Test 138 | void testMigratePhotosWithEmptyCollection() throws IOException { 139 | when(mockStorage.readStateFile(any())).thenReturn(null); 140 | 141 | deltaMigrator.migratePhotos(List.of()); 142 | 143 | // Should not call delegate for empty collection 144 | verify(mockDelegate, never()).migratePhotos(any()); 145 | } 146 | 147 | @Test 148 | void testMigratePhotosSavesStateAfterSuccessfulMigration(@TempDir @NonNull Path tempDir) throws IOException { 149 | // Create a test file 150 | Path testFile = tempDir.resolve("photo.jpg"); 151 | Files.writeString(testFile, "test content"); 152 | Photo photo = new Photo(testFile); 153 | 154 | when(mockStorage.readStateFile(any())).thenReturn(null); 155 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 156 | 157 | deltaMigrator.migratePhotos(List.of(photo)); 158 | 159 | // Should save state after successful migration 160 | verify(mockStorage).writeStateFile(anyString(), anyString()); 161 | } 162 | 163 | @Test 164 | void testMigratePhotosHandlesStateSaveIOException(@TempDir @NonNull Path tempDir) throws IOException { 165 | // Create a test file 166 | Path testFile = tempDir.resolve("photo.jpg"); 167 | Files.writeString(testFile, "test content"); 168 | Photo photo = new Photo(testFile); 169 | 170 | when(mockStorage.readStateFile(any())).thenReturn(null); 171 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 172 | doThrow(new IOException("Save failed")).when(mockStorage).writeStateFile(anyString(), anyString()); 173 | 174 | // Should not throw, just log error 175 | deltaMigrator.migratePhotos(List.of(photo)); 176 | 177 | verify(mockDelegate).migratePhotos(ArgumentMatchers.any()); 178 | } 179 | 180 | @Test 181 | void testMigratePhotosRecordsOnlySuccessfulMigrations(@TempDir @NonNull Path tempDir) throws IOException { 182 | // Create two test files 183 | Path testFile1 = tempDir.resolve("photo1.jpg"); 184 | Path testFile2 = tempDir.resolve("photo2.jpg"); 185 | Files.writeString(testFile1, "test content 1"); 186 | Files.writeString(testFile2, "test content 2"); 187 | Photo photo1 = new Photo(testFile1); 188 | Photo photo2 = new Photo(testFile2); 189 | 190 | when(mockStorage.readStateFile(any())).thenReturn(null); 191 | // Simulate only 1 successful migration out of 2 192 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 193 | 194 | deltaMigrator.migratePhotos(List.of(photo1, photo2)); 195 | 196 | // Should call delegate with both photos 197 | verify(mockDelegate).migratePhotos(ArgumentMatchers.any()); 198 | // State should still be saved 199 | verify(mockStorage).writeStateFile(anyString(), anyString()); 200 | } 201 | 202 | @Test 203 | void testMigratePhotosMixedNewAndUnchangedFiles(@TempDir @NonNull Path tempDir) throws IOException { 204 | // Create two test files 205 | Path unchangedFile = tempDir.resolve("unchanged.jpg"); 206 | Path newFile = tempDir.resolve("new.jpg"); 207 | Files.writeString(unchangedFile, "unchanged content"); 208 | Files.writeString(newFile, "new content"); 209 | long unchangedSize = Files.size(unchangedFile); 210 | long unchangedModified = Files.getLastModifiedTime(unchangedFile).toMillis(); 211 | 212 | // Create state file content that only matches the unchanged file 213 | String stateJson = String.format( 214 | "{\"%s\":{\"path\":\"%s\",\"size\":%d,\"lastModifiedMillis\":%d}}", 215 | unchangedFile.toString().replace("\\", "\\\\"), 216 | unchangedFile.toString().replace("\\", "\\\\"), 217 | unchangedSize, 218 | unchangedModified 219 | ); 220 | when(mockStorage.readStateFile(any())).thenReturn(stateJson); 221 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 222 | 223 | Photo photo1 = new Photo(unchangedFile); 224 | Photo photo2 = new Photo(newFile); 225 | deltaMigrator.migratePhotos(List.of(photo1, photo2)); 226 | 227 | // Should call delegate for new file only 228 | verify(mockDelegate).migratePhotos(ArgumentMatchers.any()); 229 | assertEquals(1, deltaMigrator.getSkippedCount()); 230 | } 231 | 232 | @Test 233 | void testMigratePhotosNoSuccessfulMigrations(@TempDir @NonNull Path tempDir) throws IOException { 234 | // Create a test file 235 | Path testFile = tempDir.resolve("photo.jpg"); 236 | Files.writeString(testFile, "test content"); 237 | Photo photo = new Photo(testFile); 238 | 239 | when(mockStorage.readStateFile(any())).thenReturn(null); 240 | // Simulate no successful migrations 241 | when(mockDelegate.getSuccessCount()).thenReturn(0L); 242 | 243 | deltaMigrator.migratePhotos(List.of(photo)); 244 | 245 | // Should not save state if no successful migrations 246 | verify(mockStorage, never()).writeStateFile(anyString(), anyString()); 247 | } 248 | 249 | @Test 250 | void testMigratePhotosHandlesNonExistentFile(@TempDir @NonNull Path tempDir) throws IOException { 251 | // Create a path to non-existent file 252 | Path nonExistentFile = tempDir.resolve("nonexistent.jpg"); 253 | Photo photo = new Photo(nonExistentFile); 254 | 255 | when(mockStorage.readStateFile(any())).thenReturn(null); 256 | when(mockDelegate.getSuccessCount()).thenReturn(1L); 257 | 258 | // Should not throw, should include file in migration anyway 259 | deltaMigrator.migratePhotos(List.of(photo)); 260 | 261 | verify(mockDelegate).migratePhotos(any()); 262 | } 263 | 264 | @Test 265 | void testMigratePhotosWithDifferentLastModifiedTime(@TempDir @NonNull Path tempDir) throws IOException { 266 | // Create a test file 267 | Path testFile = tempDir.resolve("photo.jpg"); 268 | Files.writeString(testFile, "test content"); 269 | long size = Files.size(testFile); 270 | 271 | // Create state file content with same size but different timestamp 272 | String stateJson = String.format( 273 | "{\"%s\":{\"path\":\"%s\",\"size\":%d,\"lastModifiedMillis\":%d}}", 274 | testFile.toString().replace("\\", "\\\\"), 275 | testFile.toString().replace("\\", "\\\\"), 276 | size, 277 | 1000L // Different timestamp 278 | ); 279 | when(mockStorage.readStateFile(any())).thenReturn(stateJson); 280 | when(mockDelegate.getSuccessCount()).thenReturn(0L, 1L); 281 | 282 | Photo photo = new Photo(testFile); 283 | deltaMigrator.migratePhotos(List.of(photo)); 284 | 285 | // Should call delegate since file has different timestamp 286 | verify(mockDelegate).migratePhotos(ArgumentMatchers.any()); 287 | } 288 | } 289 | --------------------------------------------------------------------------------