From 67b6eef6415546bd9ccc4b19eaa78931a416f6ec Mon Sep 17 00:00:00 2001 From: ylexus Date: Thu, 10 Jun 2021 23:28:49 +0100 Subject: [PATCH] #105 Set up target percentage of free space in Google Drive and monitor it; dependency upgrades; minor fixes --- build.gradle | 50 +- .../googlephotosupload/cli/CliStarter.java | 3 +- .../cli/LoggingProgressStatusFactory.java | 9 +- .../core/BasePreferences.java | 15 + .../core/DependenciesModule.java | 9 +- .../core/DriveSpaceTracker.java | 14 + .../core/DriveSpaceTrackerImpl.java | 165 +++++++ .../core/GooglePhotosUploaderImpl.java | 76 +++- .../core/ProgressStatus.java | 2 + .../core/ResourceBundleModule.java | 4 +- .../core/UploadPhotosModule.java | 1 + .../core/UploadStateManagerImpl.java | 6 +- .../googlephotosupload/core/UploaderImpl.java | 76 ++-- .../ui/PreferencesDialogController.java | 428 +++++++++++++----- .../ui/ProgressBoxFxController.java | 10 + .../ui/ProgressStatusBarImpl.java | 5 + .../ui/ProgressValueUpdater.java | 2 + .../ui/ThrottlingProgressStatus.java | 8 + ...gradeNotificationDialogControllerImpl.java | 6 +- .../ui/UploadPaneControllerImpl.java | 5 + src/main/resources/FolderSelector.fxml | 4 +- src/main/resources/MainScreen.fxml | 2 +- src/main/resources/PreferencesDialog.fxml | 32 ++ src/main/resources/i18n/Resources.properties | 10 + .../resources/i18n/Resources_es.properties | 23 +- .../resources/i18n/Resources_nl.properties | 20 + .../resources/i18n/Resources_ru.properties | 14 +- .../i18n/Resources_zh_Hans.properties | 22 +- .../i18n/Resources_zh_Hant.properties | 22 +- .../core/IntegrationTest.java | 7 +- .../core/IntegrationTestUploadStarter.java | 14 +- .../core/MockGoogleDriveModule.java | 12 + .../core/RecordingProgressStatusFactory.java | 118 +++-- src/test/resources/log4j2-test.yaml | 2 +- 34 files changed, 956 insertions(+), 240 deletions(-) create mode 100644 src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTracker.java create mode 100644 src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTrackerImpl.java create mode 100644 src/test/java/net/yudichev/googlephotosupload/core/MockGoogleDriveModule.java diff --git a/build.gradle b/build.gradle index 5b376e7..dcbd533 100644 --- a/build.gradle +++ b/build.gradle @@ -7,13 +7,13 @@ import java.nio.file.Paths plugins { id 'application' id 'org.openjfx.javafxplugin' version '0.0.9' - id 'org.beryx.runtime' version '1.8.5' - id "name.remal.check-updates" version "1.0.211" + id 'org.beryx.runtime' version '1.12.5' + id "name.remal.check-updates" version "1.3.1" // TODO no good alternative to maven duplicate finder // id "net.idlestate.gradle-duplicate-classes-check" version "1.0.2" - id "ca.cutterslade.analyze" version "1.4.2" + id "ca.cutterslade.analyze" version "1.6.0" } version = readEnvOrDefault 'VERSION', '0.DEV' @@ -48,29 +48,25 @@ repositories { } dependencies { - ext.orgJunitJupiterVersion = '5.6.0' - ext.orgMockitoVersion = '3.2.4' - ext.orgImmutablesVersion = '2.8.3' - ext.netYudichevJiottyVersion = '1.7.1' + ext.orgJunitJupiterVersion = '5.7.2' + ext.orgMockitoVersion = '3.10.0' + ext.orgImmutablesVersion = '2.8.8' + ext.netYudichevJiottyVersion = '2.0-SNAPSHOT' + ext.orgApacheLoggingLog4jVersion = '2.14.1' annotationProcessor "org.immutables:value:$orgImmutablesVersion" testAnnotationProcessor "org.immutables:value:$orgImmutablesVersion" implementation platform("net.yudichev.jiotty:jiotty-bom:$netYudichevJiottyVersion") - implementation platform("com.google.inject:guice-bom:4.2.2") - // This is needed as we depend on no_aop guice instead (regular Guice does not work on Java 14) - def withoutGuice = { - exclude group: "com.google.inject", module: "guice" - } - - implementation "net.yudichev.jiotty:jiotty-common", withoutGuice - implementation "net.yudichev.jiotty:jiotty-connector-google-common", withoutGuice - implementation "net.yudichev.jiotty:jiotty-connector-google-photos", withoutGuice - implementation "com.google.inject.extensions:guice-assistedinject", withoutGuice + implementation "net.yudichev.jiotty:jiotty-common" + implementation "net.yudichev.jiotty:jiotty-connector-google-common" + implementation "net.yudichev.jiotty:jiotty-connector-google-photos" + implementation "net.yudichev.jiotty:jiotty-connector-google-drive" + implementation "com.google.inject.extensions:guice-assistedinject" implementation "org.slf4j:slf4j-api" implementation "org.apache.logging.log4j:log4j-api" - implementation "com.google.inject:guice::no_aop" + implementation "com.google.inject:guice" implementation "javax.inject:javax.inject" implementation "com.google.guava:guava:29.0-jre" implementation "com.google.code.findbugs:jsr305" @@ -83,23 +79,24 @@ dependencies { implementation "de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7" implementation "io.grpc:grpc-api" implementation "com.squareup.okhttp3:okhttp" - implementation "com.sandec:mdfx:0.1.8" + implementation "com.sandec:mdfx:0.2.1" implementation 'com.h2database:h2:1.4.200' // This is to test dependency analyser //implementation "org.apache.commons:commons-skin:4.2" compileOnly "org.immutables:value:$orgImmutablesVersion" - runtimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:2.12.1" - runtimeOnly "org.apache.logging.log4j:log4j-jcl:2.12.1" - runtimeOnly "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.9" - runtimeOnly "com.lmax:disruptor:3.4.2" + runtimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:$orgApacheLoggingLog4jVersion" + runtimeOnly "org.apache.logging.log4j:log4j-jcl:$orgApacheLoggingLog4jVersion" + runtimeOnly "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.12.3" + runtimeOnly "com.lmax:disruptor:3.4.4" testImplementation "net.yudichev.jiotty:jiotty-common" testImplementation "org.junit.jupiter:junit-jupiter-api:$orgJunitJupiterVersion" testImplementation "org.junit.jupiter:junit-jupiter-params:$orgJunitJupiterVersion" + testImplementation "net.yudichev.jiotty:jiotty-connector-google-drive::tests" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$orgJunitJupiterVersion" testImplementation "org.mockito:mockito-junit-jupiter:$orgMockitoVersion" - testImplementation "org.hamcrest:hamcrest:2.1" + testImplementation "org.hamcrest:hamcrest:2.2" testCompileOnly "org.immutables:value:$orgImmutablesVersion" // these are added by the javafx plugin @@ -169,14 +166,14 @@ javafx { } runtime { - //noinspection GroovyAssignabilityCheck, GroovyAccessibility + //noinspection GroovyAssignabilityCheck, GroovyAccessibility, GrFinalVariableAccess options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages'] // required by httpclient, missed by default when building and running on Windows - //noinspection GroovyAssignabilityCheck,GroovyAccessibility + //noinspection GroovyAssignabilityCheck,GroovyAccessibility,GrFinalVariableAccess modules = [ // required by httpclient, missed by default when building and running on Windows 'java.naming', @@ -185,6 +182,7 @@ runtime { // required for a heap dump via JMX 'jdk.management' ] + //noinspection GroovyAssignabilityCheck,GroovyAccessibility,GrFinalVariableAccess additive = true jpackage { diff --git a/src/main/java/net/yudichev/googlephotosupload/cli/CliStarter.java b/src/main/java/net/yudichev/googlephotosupload/cli/CliStarter.java index fb69aa7..d12a326 100644 --- a/src/main/java/net/yudichev/googlephotosupload/cli/CliStarter.java +++ b/src/main/java/net/yudichev/googlephotosupload/cli/CliStarter.java @@ -16,8 +16,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import static net.yudichev.jiotty.common.lang.CompletableFutures.logErrorOnFailure; -final class -CliStarter extends BaseLifecycleComponent { +final class CliStarter extends BaseLifecycleComponent { private static final Logger logger = LoggerFactory.getLogger(CliStarter.class); private final Path rootDir; private final Uploader uploader; diff --git a/src/main/java/net/yudichev/googlephotosupload/cli/LoggingProgressStatusFactory.java b/src/main/java/net/yudichev/googlephotosupload/cli/LoggingProgressStatusFactory.java index c9414a4..61465a9 100644 --- a/src/main/java/net/yudichev/googlephotosupload/cli/LoggingProgressStatusFactory.java +++ b/src/main/java/net/yudichev/googlephotosupload/cli/LoggingProgressStatusFactory.java @@ -30,10 +30,11 @@ final class LoggingProgressStatusFactory implements ProgressStatusFactory { } @Override - public ProgressStatus create(String name, Optional totalCount) { + public ProgressStatus create(String name, @SuppressWarnings("ParameterNameDiffersFromOverriddenParameter") Optional totalCountOpt) { return new ProgressStatus() { private final Lock lock = new ReentrantLock(); private int successCount; + private Optional totalCount = totalCountOpt; private int failureCount; @Override @@ -42,6 +43,12 @@ public void updateSuccess(int newValue) { throttledLog(); } + @Override + public void updateTotal(int newValue) { + inLock(lock, () -> { totalCount = Optional.of(newValue);}); + throttledLog(); + } + @Override public void updateDescription(String newValue) { // do nothing in CLI - log is enough diff --git a/src/main/java/net/yudichev/googlephotosupload/core/BasePreferences.java b/src/main/java/net/yudichev/googlephotosupload/core/BasePreferences.java index c1eead5..e080a7d 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/BasePreferences.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/BasePreferences.java @@ -85,6 +85,8 @@ public boolean useCustomCredentials() { return false; } + public abstract Optional failOnDriveSpace(); + @Value.Check void validateRelevantDirDepthLimit() { relevantDirDepthLimit().ifPresent(value -> checkArgument(value > 0, "validateRelevantDirDepthLimit cannot be <=0: %s", value)); @@ -147,4 +149,17 @@ private static Set compile(Set strings) { .map(fileSystem::getPathMatcher) .collect(toImmutableSet()); } + + @Immutable + @PublicImmutablesStyle + interface BaseFailOnDriveSpaceOption { + Optional minFreeMegabytes(); + + Optional maxUsedPercentage(); + + @Value.Check + default void validate() { + checkArgument(minFreeMegabytes().isPresent() ^ maxUsedPercentage().isPresent()); + } + } } diff --git a/src/main/java/net/yudichev/googlephotosupload/core/DependenciesModule.java b/src/main/java/net/yudichev/googlephotosupload/core/DependenciesModule.java index 2745005..6e0eb3f 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/DependenciesModule.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/DependenciesModule.java @@ -6,12 +6,14 @@ import net.yudichev.jiotty.common.lang.TypedBuilder; import net.yudichev.jiotty.common.time.TimeModule; import net.yudichev.jiotty.connector.google.common.GoogleApiAuthSettings; +import net.yudichev.jiotty.connector.google.drive.GoogleDriveModule; import net.yudichev.jiotty.connector.google.photos.GooglePhotosModule; import javax.inject.Singleton; import java.nio.file.Path; import java.util.function.Consumer; +import static com.google.api.services.drive.DriveScopes.DRIVE_APPDATA; import static com.google.common.base.Preconditions.checkNotNull; import static net.yudichev.googlephotosupload.core.AppGlobals.APP_TITLE; import static net.yudichev.jiotty.common.inject.BindingSpec.providedBy; @@ -46,8 +48,13 @@ protected void configure() { .setApplicationName(APP_TITLE) .setCredentialsUrl(providedBy(CustomCredentialsManagerImpl.class)); googleApiSettingsCustomiser.accept(googleApiSettingsBuilder); + var settings = googleApiSettingsBuilder.build(); install(GooglePhotosModule.builder() - .setSettings(googleApiSettingsBuilder.build()) + .setSettings(settings) + .build()); + install(GoogleDriveModule.builder() + .setSettings(settings) + .addScopes(DRIVE_APPDATA) .build()); } diff --git a/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTracker.java b/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTracker.java new file mode 100644 index 0000000..5ddbd6f --- /dev/null +++ b/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTracker.java @@ -0,0 +1,14 @@ +package net.yudichev.googlephotosupload.core; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +interface DriveSpaceTracker { + CompletableFuture reset(); + + boolean validationEnabled(); + + void beforeUpload(); + + void afterUpload(List pathStates); +} diff --git a/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTrackerImpl.java b/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTrackerImpl.java new file mode 100644 index 0000000..00dcd0d --- /dev/null +++ b/src/main/java/net/yudichev/googlephotosupload/core/DriveSpaceTrackerImpl.java @@ -0,0 +1,165 @@ +package net.yudichev.googlephotosupload.core; + +import com.google.common.collect.ImmutableSet; +import net.yudichev.jiotty.connector.google.drive.GoogleDriveClient; +import net.yudichev.jiotty.connector.google.drive.GoogleDrivePath; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static net.yudichev.jiotty.common.lang.Locks.inLock; +import static net.yudichev.jiotty.common.lang.MoreThrowables.getAsUnchecked; + +final class DriveSpaceTrackerImpl implements DriveSpaceTracker { + private static final Logger logger = LoggerFactory.getLogger(DriveSpaceTrackerImpl.class); + + private static final long CHECK_SPACE_EVERY_BYTES = 50 * 1024 * 1024; // 50 MB + private static final ImmutableSet FIELDS = ImmutableSet.of("storageQuota/limit", "storageQuota/usage"); + private static final byte[] NO_DATA = new byte[0]; + private final ProgressStatusFactory progressStatusFactory; + private final GoogleDriveClient googleDriveClient; + private final PreferencesManager preferencesManager; + private final ResourceBundle resourceBundle; + private final CloudOperationHelper cloudOperationHelper; + private final Lock lock = new ReentrantLock(); + + private ProgressStatus driveSpaceStatus; + private Optional limit; + private long usage; + private long bytesUploaded; + private long bytesUploadedSinceSpaceCheck; + + @Inject + DriveSpaceTrackerImpl(ProgressStatusFactory progressStatusFactory, + GoogleDriveClient googleDriveClient, + PreferencesManager preferencesManager, + ResourceBundle resourceBundle, + CloudOperationHelper cloudOperationHelper) { + this.progressStatusFactory = progressStatusFactory; + this.googleDriveClient = googleDriveClient; + this.preferencesManager = checkNotNull(preferencesManager); + this.resourceBundle = checkNotNull(resourceBundle); + this.cloudOperationHelper = checkNotNull(cloudOperationHelper); + } + + @Override + public CompletableFuture reset() { + return supplyAsync(() -> { + inLock(lock, () -> { + //noinspection AssignmentToNull assigned in the method called next + driveSpaceStatus = null; + refreshDriveQuota(); + }); + return null; + }); + } + + @Override + public boolean validationEnabled() { + return inLock(lock, () -> preferencesManager.get().failOnDriveSpace().isPresent() && limit.isPresent()); + } + + @Override + public void beforeUpload() { + inLock(lock, () -> { + if (driveSpaceStatus != null) { + validateUsage(); + } + }); + } + + @Override + public void afterUpload(List pathStates) { + inLock(lock, () -> { + var totalFileSize = pathStates.stream().map(PathState::path).mapToLong(path -> getAsUnchecked(() -> Files.size(path))).sum(); + bytesUploaded += totalFileSize; + refreshStatusDescription(); + bytesUploadedSinceSpaceCheck += totalFileSize; + if (bytesUploadedSinceSpaceCheck > CHECK_SPACE_EVERY_BYTES) { + logger.debug("bytesUploadedSinceSpaceCheck ({}) > CHECK_SPACE_EVERY_BYTES ({})", bytesUploadedSinceSpaceCheck, CHECK_SPACE_EVERY_BYTES); + bytesUploadedSinceSpaceCheck = 0; + refreshDriveQuota(); + validateUsage(); + } + }); + } + + private void refreshStatusDescription() { + if (driveSpaceStatus != null) { + driveSpaceStatus.updateDescription( + String.format(resourceBundle.getString("driveSpaceUploadedTotal"), + limit.map(DriveSpaceTrackerImpl::formatSize).orElse("∞"), + formatSize(bytesUploaded))); + } + } + + private void refreshDriveQuota() { + logger.debug("Refreshing drive quota"); + if (driveSpaceStatus == null) { + driveSpaceStatus = progressStatusFactory.create(resourceBundle.getString("driveSpaceStatusTitle"), Optional.empty()); + } + cloudOperationHelper.withBackOffAndRetry("Get drive quota", + // Creating a file in Drive refreshes usage stats + () -> googleDriveClient.getAppDataFolder(directExecutor()).createFile("file.txt", "text/plain", NO_DATA) + .thenCompose(GoogleDrivePath::delete) + .thenCompose(ignored -> googleDriveClient.aboutDrive(FIELDS, directExecutor())), + value -> {}) + .thenAccept(about -> { + limit = Optional.ofNullable(about.getStorageQuota().getLimit()); + var usage = about.getStorageQuota().getUsage(); + if (usage != null) { + limit.map(DriveSpaceTrackerImpl::toMegabytes).ifPresent(newValue -> driveSpaceStatus.updateTotal(newValue.intValue())); + this.usage = usage; + //noinspection NumericCastThatLosesPrecision + driveSpaceStatus.updateSuccess((int) toMegabytes(usage)); + refreshStatusDescription(); + } + }); + } + + private void validateUsage() { + preferencesManager.get().failOnDriveSpace().ifPresent(option -> limit.ifPresent(limitBytes -> option.minFreeMegabytes().ifPresentOrElse( + minFreeMegabytes -> { + if (toMegabytes(limitBytes - usage) <= minFreeMegabytes) { + throw new IllegalStateException(String.format(resourceBundle.getString("driveSpaceMinFreeSpaceViolated"), minFreeMegabytes)); + } + }, + () -> { + //noinspection OptionalGetWithoutIsPresent mutually exclusive + if (toMegabytes(usage / limitBytes * 100) >= option.maxUsedPercentage().get()) { + throw new IllegalStateException(String + .format(resourceBundle.getString("driveSpaceMaxUsedPercentageViolated"), option.maxUsedPercentage().get())); + } + } + ))); + } + + private static String formatSize(long bytes) { + if (bytes >= 1024 * 1024) { + return String.format("%,.2f MB", toMegabytes(bytes)); + } else if (bytes >= 1024) { + return String.format("%,.2f KB", toKilobytes(bytes)); + } else { + return String.format("%s B", bytes); + } + } + + private static double toMegabytes(long bytes) { + return toKilobytes(bytes) / 1024; + } + + private static double toKilobytes(long bytes) { + return (double) bytes / 1024; + } +} diff --git a/src/main/java/net/yudichev/googlephotosupload/core/GooglePhotosUploaderImpl.java b/src/main/java/net/yudichev/googlephotosupload/core/GooglePhotosUploaderImpl.java index c66b1fc..4837a1e 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/GooglePhotosUploaderImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/GooglePhotosUploaderImpl.java @@ -1,6 +1,7 @@ package net.yudichev.googlephotosupload.core; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import com.google.rpc.Code; import net.yudichev.jiotty.common.inject.BaseLifecycleComponent; import net.yudichev.jiotty.common.lang.ResultOrFailure; @@ -18,9 +19,11 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ResourceBundle; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; +import java.util.function.Function; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; @@ -30,18 +33,26 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.CompletableFuture.supplyAsync; import static java.util.stream.Collectors.toConcurrentMap; +import static java.util.stream.Collectors.toList; import static net.yudichev.googlephotosupload.core.Bindings.Backpressured; import static net.yudichev.jiotty.common.lang.CompletableFutures.toFutureOfList; +import static net.yudichev.jiotty.common.lang.CompletableFutures.toFutureOfListChaining; import static net.yudichev.jiotty.common.lang.ResultOrFailure.failure; import static net.yudichev.jiotty.common.lang.ResultOrFailure.success; final class GooglePhotosUploaderImpl extends BaseLifecycleComponent implements GooglePhotosUploader { public static final int GOOGLE_PHOTOS_API_BATCH_SIZE = 50; + /** + * Max number of media items to upload in one directory before drive space is checked; only applicable if drive space check is enabled. + */ + public static final int DRIVE_SPACE_MONITORING_BATCH_SIZE = 20; @SuppressWarnings("NonConstantLogger") // as designed private final Logger logger = LoggerFactory.getLogger(getClass()); private final CloudOperationHelper cloudOperationHelper; private final AddToAlbumStrategy addToAlbumStrategy; + private final DriveSpaceTracker driveSpaceTracker; + private final ResourceBundle resourceBundle; private final FatalUserCorrectableRemoteApiExceptionHandler fatalUserCorrectableHandler; private final GooglePhotosClient googlePhotosClient; private final UploadStateManager uploadStateManager; @@ -64,7 +75,9 @@ final class GooglePhotosUploaderImpl extends BaseLifecycleComponent implements G UploadStateManager uploadStateManager, CurrentDateTimeProvider currentDateTimeProvider, CloudOperationHelper cloudOperationHelper, - AddToAlbumStrategy addToAlbumStrategy) { + AddToAlbumStrategy addToAlbumStrategy, + DriveSpaceTracker driveSpaceTracker, + ResourceBundle resourceBundle) { this.executorServiceProvider = checkNotNull(executorServiceProvider); this.backOffHandler = checkNotNull(backOffHandler); this.fatalUserCorrectableHandler = checkNotNull(fatalUserCorrectableHandler); @@ -73,6 +86,8 @@ final class GooglePhotosUploaderImpl extends BaseLifecycleComponent implements G this.currentDateTimeProvider = checkNotNull(currentDateTimeProvider); this.cloudOperationHelper = checkNotNull(cloudOperationHelper); this.addToAlbumStrategy = checkNotNull(addToAlbumStrategy); + this.driveSpaceTracker = checkNotNull(driveSpaceTracker); + this.resourceBundle = checkNotNull(resourceBundle); } @Override @@ -85,22 +100,32 @@ public CompletableFuture uploadDirectory(Optional googl return supplyAsync(() -> files, executorService) .thenCompose(paths -> { directoryProgressStatus.updateDescription(googlePhotosAlbum.map(GooglePhotosAlbum::getTitle).orElse("")); - var createMediaDataResultsFuture = paths.stream() + var sortedPaths = paths.stream() .sorted(comparing(path -> path.getFileName().toString())) - .map(path -> createMediaData(path, fileProgressStatus) - .thenApply(itemState -> { - itemState.toFailure().ifPresentOrElse( - error -> fileProgressStatus.addFailure(KeyedError.of(path, error)), - fileProgressStatus::incrementSuccess); - return PathState.of(path, itemState); - })) - .collect(toFutureOfList()); - return addToAlbumStrategy.addToAlbum( - createMediaDataResultsFuture, - googlePhotosAlbum, - fileProgressStatus, - directoryProgressStatus, - (albumId, pathStates) -> createMediaItems(albumId, fileProgressStatus, pathStates), this::getItemState); + .collect(toList()); + Function, CompletableFuture> uploader = partition -> { + var createMediaDataResultsFuture = partition.stream() + .map(path -> createMediaData(path, fileProgressStatus) + .thenApply(itemState -> { + itemState.toFailure().ifPresentOrElse( + error -> fileProgressStatus.addFailure(KeyedError.of(path, error)), + fileProgressStatus::incrementSuccess); + return PathState.of(path, itemState); + })) + .collect(toFutureOfList()); + return addToAlbumStrategy.addToAlbum( + createMediaDataResultsFuture, + googlePhotosAlbum, + fileProgressStatus, + directoryProgressStatus, + (albumId, pathStates) -> createMediaItems(albumId, fileProgressStatus, pathStates), + this::getItemState); + }; + return driveSpaceTracker.validationEnabled() ? + Lists.partition(sortedPaths, DRIVE_SPACE_MONITORING_BATCH_SIZE).stream() + .collect(toFutureOfListChaining(partition -> uploader.apply(files))) + .thenApply(list -> null) : + uploader.apply(sortedPaths); }); } @@ -169,7 +194,7 @@ private CompletableFuture> createMediaItems(Optional< .collect(toImmutableList()); return cloudOperationHelper.withBackOffAndRetry( "create media items", - () -> googlePhotosClient.createMediaItems(albumId, pendingNewMediaItems, executorService), + () -> googlePhotosClient.createMediaItems(albumId, pendingNewMediaItems, createMediaItemsExecutor(pendingPathStates, fileProgressStatus)), fileProgressStatus::onBackoffDelay) .>thenApply(mediaItemOrErrors -> { ImmutableList.Builder resultListBuilder = ImmutableList.builder(); @@ -229,11 +254,11 @@ private CompletableFuture> createMediaData(Path file, return uploadTokenNotExpired(file, uploadMediaItemState); }) .map(uploadMediaItemState -> { - logger.info("Already uploaded, skipping: {}", file); + logger.info("Media data already uploaded, skipping: {}", file); return completedFuture(itemState); }) .orElseGet(() -> { - logger.info("Uploaded, but upload token expired, re-uploading: {}", file); + logger.info("Media data uploaded, but upload token expired, re-uploading: {}", file); return doCreateMediaData(theFile, fileProgressStatus); }); } else { @@ -273,7 +298,7 @@ private boolean uploadTokenNotExpired(Path file, UploadMediaItemState uploadMedi } private CompletableFuture doCreateMediaData(Path file, ProgressStatus fileProgressStatus) { - return googlePhotosClient.uploadMediaData(file, statusUpdatingExecutor(file, fileProgressStatus)) + return googlePhotosClient.uploadMediaData(file, createMediaDataExecutor(file, fileProgressStatus)) .thenApply(uploadToken -> { logger.info("Uploaded file {}", file); logger.debug("Upload token {}", uploadToken); @@ -283,10 +308,19 @@ private CompletableFuture doCreateMediaData(Path file, ProgressStatus }); } - private Executor statusUpdatingExecutor(Path file, ProgressStatus fileProgressStatus) { + private Executor createMediaDataExecutor(Path file, ProgressStatus fileProgressStatus) { return command -> executorService.execute(() -> { fileProgressStatus.updateDescription(file.toAbsolutePath().toString()); + driveSpaceTracker.beforeUpload(); + command.run(); + }); + } + + private Executor createMediaItemsExecutor(List pathStates, ProgressStatus fileProgressStatus) { + return command -> executorService.execute(() -> { + fileProgressStatus.updateDescription(String.format(resourceBundle.getString("uploaderFinalizing"), pathStates.size())); command.run(); + driveSpaceTracker.afterUpload(pathStates); }); } } diff --git a/src/main/java/net/yudichev/googlephotosupload/core/ProgressStatus.java b/src/main/java/net/yudichev/googlephotosupload/core/ProgressStatus.java index aab30b6..63326b7 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/ProgressStatus.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/ProgressStatus.java @@ -3,6 +3,8 @@ public interface ProgressStatus { void updateSuccess(int newValue); + void updateTotal(int newValue); + void updateDescription(String newValue); void incrementSuccessBy(int increment); diff --git a/src/main/java/net/yudichev/googlephotosupload/core/ResourceBundleModule.java b/src/main/java/net/yudichev/googlephotosupload/core/ResourceBundleModule.java index 5787002..ddefd45 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/ResourceBundleModule.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/ResourceBundleModule.java @@ -27,7 +27,9 @@ public List getCandidateLocales(String baseName, Locale locale) { public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException { var resourceBundle = super.newBundle(baseName, locale, format, loader, reload); - logger.debug("newBundle {}, {} = {}", locale, format, resourceBundle.getLocale()); + if (resourceBundle != null) { + logger.debug("newBundle {}, {} = {}", locale, format, resourceBundle.getLocale()); + } return resourceBundle; } diff --git a/src/main/java/net/yudichev/googlephotosupload/core/UploadPhotosModule.java b/src/main/java/net/yudichev/googlephotosupload/core/UploadPhotosModule.java index a0269b0..78dc317 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/UploadPhotosModule.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/UploadPhotosModule.java @@ -52,6 +52,7 @@ protected void configure() { .to(AddToAlbumAfterCreatingStrategy.class) .in(Singleton.class); bind(AddToAlbumStrategy.class).to(SelectingAddToAlbumStrategy.class); + bind(DriveSpaceTracker.class).to(DriveSpaceTrackerImpl.class).in(Singleton.class); bind(GooglePhotosUploader.class).to(boundLifecycleComponent(GooglePhotosUploaderImpl.class)); bind(getExposedKey()).to(UploaderImpl.class); diff --git a/src/main/java/net/yudichev/googlephotosupload/core/UploadStateManagerImpl.java b/src/main/java/net/yudichev/googlephotosupload/core/UploadStateManagerImpl.java index de34ab4..3049355 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/UploadStateManagerImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/UploadStateManagerImpl.java @@ -50,8 +50,10 @@ final class UploadStateManagerImpl extends BaseLifecycleComponent implements Upl @Override protected void doStart() { inLock(lock, () -> asUnchecked(() -> { + var url = "jdbc:h2:" + h2DbPath.toAbsolutePath(); + logger.debug("Creating connection to {}", url); //noinspection CallToDriverManagerGetConnection no need - connection = DriverManager.getConnection("jdbc:h2:" + h2DbPath.toAbsolutePath(), "sa", ""); + connection = DriverManager.getConnection(url, "sa", ""); connection.setAutoCommit(false); try (var statement = connection.createStatement()) { statement.execute("CREATE TABLE IF NOT EXISTS MEDIA_ITEMS(" + @@ -117,6 +119,7 @@ public Map loadUploadedMediaItemIdByAbsolutePath() { @Override public void forgetState() { inLock(lock, () -> asUnchecked(() -> removeAllStmt.execute())); + logger.trace("Forgot state"); } @Override @@ -126,6 +129,7 @@ public void saveItemState(Path path, ItemState itemState) { updateOneStateStmt.execute(); connection.commit(); })); + logger.trace("Saved state: {}->{}", path, itemState); } @Override diff --git a/src/main/java/net/yudichev/googlephotosupload/core/UploaderImpl.java b/src/main/java/net/yudichev/googlephotosupload/core/UploaderImpl.java index 8cde318..fc198df 100644 --- a/src/main/java/net/yudichev/googlephotosupload/core/UploaderImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/core/UploaderImpl.java @@ -22,6 +22,7 @@ final class UploaderImpl implements Uploader { private final ProgressStatusFactory progressStatusFactory; private final UploadStateManager uploadStateManager; private final ResourceBundle resourceBundle; + private final DriveSpaceTracker driveSpaceTracker; @Inject UploaderImpl(GooglePhotosUploader googlePhotosUploader, @@ -30,7 +31,8 @@ final class UploaderImpl implements Uploader { CloudAlbumsProvider cloudAlbumsProvider, ProgressStatusFactory progressStatusFactory, UploadStateManager uploadStateManager, - ResourceBundle resourceBundle) { + ResourceBundle resourceBundle, + DriveSpaceTracker driveSpaceTracker) { this.googlePhotosUploader = checkNotNull(googlePhotosUploader); this.directoryStructureSupplier = checkNotNull(directoryStructureSupplier); this.albumManager = checkNotNull(albumManager); @@ -38,6 +40,7 @@ final class UploaderImpl implements Uploader { this.progressStatusFactory = checkNotNull(progressStatusFactory); this.uploadStateManager = checkNotNull(uploadStateManager); this.resourceBundle = checkNotNull(resourceBundle); + this.driveSpaceTracker = checkNotNull(driveSpaceTracker); } @Override @@ -45,40 +48,43 @@ public CompletableFuture upload(List rootDirs, boolean resume) { if (!resume) { googlePhotosUploader.doNotResume(); } - var albumDirectoriesFuture = directoryStructureSupplier.listAlbumDirectories(rootDirs); - var cloudAlbumsByTitleFuture = cloudAlbumsProvider.listCloudAlbums(); - return albumDirectoriesFuture - .thenCompose(albumDirectories -> cloudAlbumsByTitleFuture - .thenCompose(cloudAlbumsByTitle -> albumManager.listAlbumsByTitle(albumDirectories, cloudAlbumsByTitle) - .thenCompose(albumsByTitle -> { - var fileProgressStatus = progressStatusFactory.create( - resourceBundle.getString("uploaderFileProgressTitle"), - Optional.of(albumDirectories.stream().mapToInt(albumDirectory -> albumDirectory.files().size()).sum())); - var directoryProgressStatus = - progressStatusFactory.create( - resourceBundle.getString("uploaderAlbumProgressTitle"), - Optional.of(albumDirectories.size())); - try { - return albumDirectories.stream() - .map(albumDirectory -> googlePhotosUploader - .uploadDirectory( - albumDirectory.albumTitle().map(albumsByTitle::get), - albumDirectory.files(), - directoryProgressStatus, - fileProgressStatus) - .thenRun(directoryProgressStatus::incrementSuccess)) - .collect(toFutureOfList()) - .whenComplete((ignored, e) -> { - directoryProgressStatus.close(e == null); - fileProgressStatus.close(e == null); - }) - .thenRun(() -> logger.info("All done without fatal errors")); - } catch (RuntimeException e) { - directoryProgressStatus.closeUnsuccessfully(); - fileProgressStatus.closeUnsuccessfully(); - throw e; - } - }))); + return driveSpaceTracker.reset() + .thenCompose(ignored -> { + var albumDirectoriesFuture = directoryStructureSupplier.listAlbumDirectories(rootDirs); + var cloudAlbumsByTitleFuture = cloudAlbumsProvider.listCloudAlbums(); + return albumDirectoriesFuture + .thenCompose(albumDirectories -> cloudAlbumsByTitleFuture + .thenCompose(cloudAlbumsByTitle -> albumManager.listAlbumsByTitle(albumDirectories, cloudAlbumsByTitle) + .thenCompose(albumsByTitle -> { + var fileProgressStatus = progressStatusFactory.create( + resourceBundle.getString("uploaderFileProgressTitle"), + Optional.of(albumDirectories.stream().mapToInt(albumDirectory -> albumDirectory.files().size()).sum())); + var directoryProgressStatus = + progressStatusFactory.create( + resourceBundle.getString("uploaderAlbumProgressTitle"), + Optional.of(albumDirectories.size())); + try { + return albumDirectories.stream() + .map(albumDirectory -> googlePhotosUploader + .uploadDirectory( + albumDirectory.albumTitle().map(albumsByTitle::get), + albumDirectory.files(), + directoryProgressStatus, + fileProgressStatus) + .thenRun(directoryProgressStatus::incrementSuccess)) + .collect(toFutureOfList()) + .whenComplete((ignored2, e) -> { + directoryProgressStatus.close(e == null); + fileProgressStatus.close(e == null); + }) + .thenRun(() -> logger.info("All done without fatal errors")); + } catch (RuntimeException e) { + directoryProgressStatus.closeUnsuccessfully(); + fileProgressStatus.closeUnsuccessfully(); + throw e; + } + }))); + }); } @Override diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/PreferencesDialogController.java b/src/main/java/net/yudichev/googlephotosupload/ui/PreferencesDialogController.java index 50fe036..40c16fa 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/PreferencesDialogController.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/PreferencesDialogController.java @@ -5,10 +5,14 @@ import javafx.scene.control.*; import javafx.scene.input.MouseEvent; import javafx.stage.FileChooser; +import javafx.util.StringConverter; +import javafx.util.converter.DoubleStringConverter; import javafx.util.converter.IntegerStringConverter; import net.yudichev.googlephotosupload.core.*; import net.yudichev.jiotty.common.varstore.VarStore; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Provider; import java.util.List; @@ -17,6 +21,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; +import java.util.function.Predicate; import java.util.regex.Pattern; import static com.google.common.base.Preconditions.checkNotNull; @@ -28,32 +33,41 @@ public final class PreferencesDialogController implements PreferencesManager { static final String CUSTOM_CREDENTIALS_HELP_URL = "https://github.com/ylexus/jiotty-photos-uploader/wiki#using-your-own-google-api-client-secret"; private static final String VAR_STORE_KEY = "preferences"; - private static final Pattern RELEVANT_DIR_DEPTH_PATTERN = Pattern.compile("(|[1-9]\\d{0,2})"); - private static final int DEFAULT_RELEVANT_DIR_DEPTH_LIMIT = 2; private final VarStore varStore; private final Provider uploaderStrategyChoicePanelControllerProvider; - private final CustomCredentialsManager customCredentialsManager; private final Restarter restarter; private final ResourceBundle resourceBundle; private final Lock lock = new ReentrantLock(); private final Provider javafxApplicationResourcesProvider; + private final AlbumDelimiter albumDelimiter; + private final CustomCredentials customCredentials; + private final RelevantDir relevantDir; + private final DriveSpace driveSpace; + public TitledPane uploaderStrategyChoiceContainer; public PreferencePatternEditorController excludePanelController; public PreferencePatternEditorController includePanelController; + public RadioButton relevantDirDepthTitleFullRadioButton; public RadioButton relevantDirDepthTitleLimitedRadioButton; public TextField relevantDirDepthTitleLimitTextField; + public TextField albumDelimiterTextField; public Label albumDelimiterExampleLabel; + public TitledPane customCredentialsPane; public RadioButton customCredentialsUseStandardRadioButton; public RadioButton customCredentialsUseCustomRadioButton; public Button customCredentialsBrowseButton; public Hyperlink logoutHyperlink; - private Runnable selfCloseAction; + + public RadioButton driveSpacePercentageRadioButton; + public TextField driveSpacePercentageTextField; + public RadioButton driveSpaceFreeSpaceRadioButton; + public TextField driveSpaceFreeSpaceRadioTextField; + public RadioButton driveSpaceDisabledRadioButton; + private Preferences preferences; - private TextFormatter relevantDirDepthTitleLimitTextFieldFormatter; - private SepiaToneEffectAnimatedNode flashingCustomCredentialsPane; @Inject PreferencesDialogController(VarStore varStore, @@ -64,7 +78,10 @@ public final class PreferencesDialogController implements PreferencesManager { ResourceBundle resourceBundle) { this.varStore = checkNotNull(varStore); this.uploaderStrategyChoicePanelControllerProvider = checkNotNull(uploaderStrategyChoicePanelControllerProvider); - this.customCredentialsManager = checkNotNull(customCredentialsManager); + customCredentials = new CustomCredentials(customCredentialsManager); + albumDelimiter = new AlbumDelimiter(); + relevantDir = new RelevantDir(); + driveSpace = new DriveSpace(); this.restarter = checkNotNull(restarter); this.resourceBundle = checkNotNull(resourceBundle); try { @@ -86,31 +103,15 @@ public void initialize() { includePanelController.initialise(preferences.scanInclusionGlobs(), this::onIncludeGlobsChanged); preferences.addToAlbumStrategy().ifPresent(uploaderStrategyChoicePanelController::setSelection); - albumDelimiterTextField.setTextFormatter(new TextFormatter<>(change -> change.getControlNewText().length() > 10 ? null : change)); - albumDelimiterTextField.setText(preferences.albumDelimiter()); - updateAlbumDelimiterExampleLabel(preferences.albumDelimiter()); - albumDelimiterTextField.textProperty().addListener(this::onAlbumDelimiterChanged); - relevantDirDepthTitleLimitTextFieldFormatter = new TextFormatter<>( - new IntegerStringConverter(), - preferences.relevantDirDepthLimit().orElse(null), - change -> RELEVANT_DIR_DEPTH_PATTERN.matcher(change.getText()).matches() ? change : null); - relevantDirDepthTitleLimitTextFieldFormatter.valueProperty().addListener( - (observable, oldValue, newValue) -> onRelevantDirDepthTitleLimitChange(newValue)); - relevantDirDepthTitleLimitTextField.setTextFormatter(relevantDirDepthTitleLimitTextFieldFormatter); - relevantDirDepthTitleFullRadioButton.setSelected(preferences.relevantDirDepthLimit().isEmpty()); - relevantDirDepthTitleLimitedRadioButton.setSelected(preferences.relevantDirDepthLimit().isPresent()); - - customCredentialsUseStandardRadioButton.setSelected(!preferences.useCustomCredentials()); - customCredentialsUseCustomRadioButton.setSelected(preferences.useCustomCredentials()); - customCredentialsUpdateBrowseButtonDisabled(); - flashingCustomCredentialsPane = new SepiaToneEffectAnimatedNode(customCredentialsPane, 4); - - refreshLogoutHyperlink(); + albumDelimiter.initialise(); + relevantDir.initialise(); + driveSpace.initialise(); + customCredentials.initialise(); }); } public void setSelfCloseAction(Runnable selfCloseAction) { - this.selfCloseAction = checkNotNull(selfCloseAction); + customCredentials.setSelfCloseAction(selfCloseAction); } private void onExcludeGlobsChanged(List patterns) { @@ -147,10 +148,6 @@ public void update(Function updater) { }); } - private void savePreferences() { - varStore.saveValue(VAR_STORE_KEY, preferences); - } - public void onPatternsDocumentationLinkAction(ActionEvent actionEvent) { javafxApplicationResourcesProvider.get().hostServices().showDocument( "https://docs.oracle.com/en/java/javase/14/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher(java.lang.String)"); @@ -158,114 +155,333 @@ public void onPatternsDocumentationLinkAction(ActionEvent actionEvent) { } public void onRelevantDirDepthTypeSelectionChange(ActionEvent actionEvent) { - if (relevantDirDepthTitleFullRadioButton.isSelected()) { - relevantDirDepthTitleLimitTextFieldFormatter.setValue(null); - } else { - relevantDirDepthTitleLimitTextField.requestFocus(); - if (relevantDirDepthTitleLimitTextFieldFormatter.getValue() == null) { - relevantDirDepthTitleLimitTextFieldFormatter.setValue(DEFAULT_RELEVANT_DIR_DEPTH_LIMIT); - } - } + relevantDir.onDepthTypeSelectionChange(); actionEvent.consume(); } - public void onRelevantDirDepthTitleLimitChange(Integer newValue) { - inLock(lock, () -> { - if (newValue == null && relevantDirDepthTitleLimitedRadioButton.isSelected()) { - relevantDirDepthTitleLimitTextFieldFormatter.setValue(DEFAULT_RELEVANT_DIR_DEPTH_LIMIT); - } else { - preferences = preferences.withRelevantDirDepthLimit(Optional.ofNullable(newValue)); - savePreferences(); - } - }); + + public void onDriveSpaceSelectionChange(ActionEvent actionEvent) { + driveSpace.onSelectionChange(); + actionEvent.consume(); } public void onRelevantDirDepthHelp(MouseEvent mouseEvent) { - javafxApplicationResourcesProvider.get().hostServices().showDocument( - "https://github.com/ylexus/jiotty-photos-uploader/wiki#configurable-directory-depth-level"); + relevantDir.onDepthHelp(); mouseEvent.consume(); } public void onCustomCredentialsHelp(MouseEvent mouseEvent) { - javafxApplicationResourcesProvider.get().hostServices().showDocument(CUSTOM_CREDENTIALS_HELP_URL); + customCredentials.onHelp(); mouseEvent.consume(); } public void focusOnCustomCredentials() { - customCredentialsPane.requestFocus(); - flashingCustomCredentialsPane.show(); + customCredentials.focus(); } public void onCustomCredentialsSelectionChange(ActionEvent actionEvent) { - customCredentialsUpdateBrowseButtonDisabled(); - inLock(lock, () -> { - if (customCredentialsUseCustomRadioButton.isSelected()) { - if (!customCredentialsManager.configuredToUseCustomCredentials()) { - var fileSelected = browseForCustomCredentialsFile(); - if (!fileSelected) { - customCredentialsUseCustomRadioButton.setSelected(false); - customCredentialsUseStandardRadioButton.setSelected(true); - customCredentialsUpdateBrowseButtonDisabled(); - } - } - } else { - customCredentialsManager.deleteCustomCredentials(); - } - inLock(lock, () -> { - preferences = preferences.withUseCustomCredentials(customCredentialsUseCustomRadioButton.isSelected()); - savePreferences(); - }); - refreshLogoutHyperlink(); - }); + customCredentials.onSelectionChange(); actionEvent.consume(); } - private void customCredentialsUpdateBrowseButtonDisabled() { - customCredentialsBrowseButton.setDisable(customCredentialsUseStandardRadioButton.isSelected()); + public void onCustomCredentialsBrowseButtonAction(ActionEvent actionEvent) { + customCredentials.browseForFile(); + actionEvent.consume(); } - public void onCustomCredentialsBrowseButtonAction(ActionEvent actionEvent) { - browseForCustomCredentialsFile(); + public void onLogoutHyperlinkClicked(ActionEvent actionEvent) { + customCredentials.onLogoutHyperlinkClicked(); actionEvent.consume(); } - private boolean browseForCustomCredentialsFile() { - var fileChooser = new FileChooser(); - fileChooser.setTitle(resourceBundle.getString("preferencesCustomCredentialsFileChooserTitle")); - var file = fileChooser.showOpenDialog(customCredentialsUseCustomRadioButton.getScene().getWindow()); - if (file != null) { + private void savePreferences() { + varStore.saveValue(VAR_STORE_KEY, preferences); + } + + private final class CustomCredentials { + private final CustomCredentialsManager customCredentialsManager; + private Runnable selfCloseAction; + private SepiaToneEffectAnimatedNode flashingCustomCredentialsPane; + + CustomCredentials(CustomCredentialsManager customCredentialsManager) { + this.customCredentialsManager = checkNotNull(customCredentialsManager); + } + + public void initialise() { + customCredentialsUseStandardRadioButton.setSelected(!preferences.useCustomCredentials()); + customCredentialsUseCustomRadioButton.setSelected(preferences.useCustomCredentials()); + customCredentialsUpdateBrowseButtonDisabled(); + flashingCustomCredentialsPane = new SepiaToneEffectAnimatedNode(customCredentialsPane, 4); + + customCredentials.refreshLogoutHyperlink(); + } + + public void onSelectionChange() { + customCredentialsUpdateBrowseButtonDisabled(); inLock(lock, () -> { - preferences = preferences.withUseCustomCredentials(true); - savePreferences(); + if (customCredentialsUseCustomRadioButton.isSelected()) { + if (!customCredentialsManager.configuredToUseCustomCredentials()) { + var fileSelected = browseForFile(); + if (!fileSelected) { + customCredentialsUseCustomRadioButton.setSelected(false); + customCredentialsUseStandardRadioButton.setSelected(true); + customCredentialsUpdateBrowseButtonDisabled(); + } + } + } else { + customCredentialsManager.deleteCustomCredentials(); + } + inLock(lock, () -> { + preferences = preferences.withUseCustomCredentials(customCredentialsUseCustomRadioButton.isSelected()); + savePreferences(); + }); + refreshLogoutHyperlink(); }); - customCredentialsManager.saveCustomCredentials(file.toPath()); - refreshLogoutHyperlink(); - return true; } - return false; - } - public void onLogoutHyperlinkClicked(ActionEvent actionEvent) { - selfCloseAction.run(); - restarter.initiateLogoutAndRestart(); - actionEvent.consume(); + public boolean browseForFile() { + var fileChooser = new FileChooser(); + fileChooser.setTitle(resourceBundle.getString("preferencesCustomCredentialsFileChooserTitle")); + var file = fileChooser.showOpenDialog(customCredentialsUseCustomRadioButton.getScene().getWindow()); + if (file != null) { + inLock(lock, () -> { + preferences = preferences.withUseCustomCredentials(true); + savePreferences(); + }); + customCredentialsManager.saveCustomCredentials(file.toPath()); + refreshLogoutHyperlink(); + return true; + } + return false; + } + + public void focus() { + customCredentialsPane.requestFocus(); + flashingCustomCredentialsPane.show(); + } + + public void onHelp() { + javafxApplicationResourcesProvider.get().hostServices().showDocument(CUSTOM_CREDENTIALS_HELP_URL); + } + + public void onLogoutHyperlinkClicked() { + selfCloseAction.run(); + restarter.initiateLogoutAndRestart(); + } + + private void customCredentialsUpdateBrowseButtonDisabled() { + customCredentialsBrowseButton.setDisable(customCredentialsUseStandardRadioButton.isSelected()); + } + + private void refreshLogoutHyperlink() { + logoutHyperlink.setVisible(!customCredentialsManager.usedCredentialsMatchConfigured()); + } + + public void setSelfCloseAction(Runnable selfCloseAction) { + this.selfCloseAction = checkNotNull(selfCloseAction); + } } - @SuppressWarnings("TypeParameterExtendsFinalClass") - private void onAlbumDelimiterChanged(ObservableValue observable, String oldValue, String newValue) { - updateAlbumDelimiterExampleLabel(newValue); - inLock(lock, () -> { - preferences = preferences.withAlbumDelimiter(newValue); - savePreferences(); - }); + private final class AlbumDelimiter { + + public void initialise() { + albumDelimiterTextField.setTextFormatter(new TextFormatter<>(change -> change.getControlNewText().length() > 10 ? null : change)); + albumDelimiterTextField.setText(preferences.albumDelimiter()); + updateAlbumDelimiterExampleLabel(preferences.albumDelimiter()); + albumDelimiterTextField.textProperty().addListener(this::onAlbumDelimiterChanged); + } + + @SuppressWarnings("TypeParameterExtendsFinalClass") + private void onAlbumDelimiterChanged(ObservableValue observable, String oldValue, String newValue) { + updateAlbumDelimiterExampleLabel(newValue); + inLock(lock, () -> { + preferences = preferences.withAlbumDelimiter(newValue); + savePreferences(); + }); + } + + private void updateAlbumDelimiterExampleLabel(String newValue) { + albumDelimiterExampleLabel.setText( + String.format(resourceBundle.getString("preferencesDialogAlbumDelimiterExampleLabel"), newValue, newValue, newValue)); + } } - private void updateAlbumDelimiterExampleLabel(String newValue) { - albumDelimiterExampleLabel.setText( - String.format(resourceBundle.getString("preferencesDialogAlbumDelimiterExampleLabel"), newValue, newValue, newValue)); + private final class RelevantDir { + private static final int DEFAULT_RELEVANT_DIR_DEPTH_LIMIT = 2; + private final Pattern RELEVANT_DIR_DEPTH_PATTERN = Pattern.compile("[1-9]\\d{0,2}"); + private TextFormatter relevantDirDepthTitleLimitTextFieldFormatter; + + public void initialise() { + relevantDirDepthTitleLimitTextFieldFormatter = new TextFormatter<>( + new IntegerStringConverter(), + preferences.relevantDirDepthLimit().orElse(null), + change -> change.getControlNewText().isEmpty() || change.getControlNewText().equals(change.getControlText()) + || RELEVANT_DIR_DEPTH_PATTERN.matcher(change.getControlNewText()).matches() ? change : null); + relevantDirDepthTitleLimitTextFieldFormatter.valueProperty().addListener( + (observable, oldValue, newValue) -> onRelevantDirDepthTitleLimitChange(newValue)); + relevantDirDepthTitleLimitTextField.setTextFormatter(relevantDirDepthTitleLimitTextFieldFormatter); + relevantDirDepthTitleFullRadioButton.setSelected(preferences.relevantDirDepthLimit().isEmpty()); + relevantDirDepthTitleLimitedRadioButton.setSelected(preferences.relevantDirDepthLimit().isPresent()); + } + + public void onDepthTypeSelectionChange() { + if (relevantDirDepthTitleFullRadioButton.isSelected()) { + // this changes the value and triggers saving prefs + relevantDirDepthTitleLimitTextFieldFormatter.setValue(null); + } else { + relevantDirDepthTitleLimitTextField.requestFocus(); + if (relevantDirDepthTitleLimitTextFieldFormatter.getValue() == null) { + relevantDirDepthTitleLimitTextFieldFormatter.setValue(DEFAULT_RELEVANT_DIR_DEPTH_LIMIT); + } + } + } + + private void onRelevantDirDepthTitleLimitChange(@Nullable Integer newValue) { + if (newValue == null && relevantDirDepthTitleLimitedRadioButton.isSelected()) { + relevantDirDepthTitleLimitTextFieldFormatter.setValue(DEFAULT_RELEVANT_DIR_DEPTH_LIMIT); + } else { + inLock(lock, () -> { + preferences = preferences.withRelevantDirDepthLimit(Optional.ofNullable(newValue)); + savePreferences(); + }); + } + } + + public void onDepthHelp() { + javafxApplicationResourcesProvider.get().hostServices().showDocument( + "https://github.com/ylexus/jiotty-photos-uploader/wiki#configurable-directory-depth-level"); + } } - private void refreshLogoutHyperlink() { - logoutHyperlink.setVisible(!customCredentialsManager.usedCredentialsMatchConfigured()); + private final class DriveSpace { + private Option percentageOption; + private Option freeSpaceOption; + + public void initialise() { + percentageOption = new PercentageOption(); + freeSpaceOption = new FreeSpaceOption(); + preferences.failOnDriveSpace().ifPresentOrElse( + failOnDriveSpaceOption -> { + driveSpacePercentageRadioButton.setSelected(failOnDriveSpaceOption.maxUsedPercentage().isPresent()); + driveSpaceFreeSpaceRadioButton.setSelected(failOnDriveSpaceOption.minFreeMegabytes().isPresent()); + }, + () -> driveSpaceDisabledRadioButton.setSelected(true)); + } + + public void onSelectionChange() { + if (driveSpaceDisabledRadioButton.isSelected()) { + percentageOption.deactivate(); + freeSpaceOption.deactivate(); + inLock(lock, () -> { + preferences = preferences.withFailOnDriveSpace(Optional.empty()); + savePreferences(); + }); + } else { + if (driveSpacePercentageRadioButton.isSelected()) { + percentageOption.activate(); + freeSpaceOption.deactivate(); + } else { + freeSpaceOption.activate(); + percentageOption.deactivate(); + } + } + } + + private class Option { + private final TextFormatter textFormatter; + private final RadioButton radioButton; + @Nonnull + private final TextField textField; + private final T defaultValue; + private final Function configValueFactory; + + private Option(RadioButton radioButton, + TextField textField, + T defaultValue, + StringConverter converter, + Function> preferencesValueExtractor, + Predicate valueValidator, + Function configValueFactory) { + this.radioButton = radioButton; + this.textField = textField; + this.defaultValue = defaultValue; + this.configValueFactory = configValueFactory; + textFormatter = new TextFormatter<>( + converter, + preferences.failOnDriveSpace().flatMap(preferencesValueExtractor).orElse(null), + change -> { + var controlNewText = change.getControlNewText(); + if (controlNewText.isEmpty() || controlNewText.equals(change.getControlText())) { + return change; + } + try { + return valueValidator.test(controlNewText) ? change : null; + } catch (NumberFormatException e) { + return null; + } + }); + textFormatter.valueProperty().addListener((observable, oldValue, newValue) -> onTextChange(newValue)); + textField.setTextFormatter(textFormatter); + } + + private void onTextChange(@Nullable T newValue) { + if (newValue == null) { + if (radioButton.isSelected()) { + textFormatter.setValue(defaultValue); + } + } else { + inLock(lock, () -> { + preferences = preferences.withFailOnDriveSpace(configValueFactory.apply(newValue)); + savePreferences(); + }); + } + } + + public void deactivate() { + textFormatter.setValue(null); + } + + public void activate() { + textField.requestFocus(); + if (textFormatter.getValue() == null) { + textFormatter.setValue(defaultValue); + } + } + } + + private final class PercentageOption extends Option { + private static final double DEFAULT_DRIVE_SPACE_PERCENTAGE = 98; + + private PercentageOption() { + super(driveSpacePercentageRadioButton, + driveSpacePercentageTextField, + DEFAULT_DRIVE_SPACE_PERCENTAGE, + new DoubleStringConverter(), + FailOnDriveSpaceOption::maxUsedPercentage, + controlNewText -> { + var percentage = Double.parseDouble(controlNewText); + return percentage > 0 && percentage < 100; + }, + newValue -> FailOnDriveSpaceOption.builder() + .setMaxUsedPercentage(newValue) + .build()); + } + } + + private final class FreeSpaceOption extends Option { + private static final int DEFAULT_DRIVE_SPACE_FREE_SPACE_MB = 500; + + private FreeSpaceOption() { + super(driveSpaceFreeSpaceRadioButton, + driveSpaceFreeSpaceRadioTextField, + DEFAULT_DRIVE_SPACE_FREE_SPACE_MB, + new IntegerStringConverter(), + FailOnDriveSpaceOption::minFreeMegabytes, + controlNewText -> Integer.parseInt(controlNewText) > 0, + newValue -> FailOnDriveSpaceOption.builder() + .setMinFreeMegabytes(newValue) + .build()); + } + } } } diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressBoxFxController.java b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressBoxFxController.java index e01af16..894e27b 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressBoxFxController.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressBoxFxController.java @@ -37,6 +37,7 @@ public final class ProgressBoxFxController { private SepiaToneEffectAnimatedNode animatedBackoffInfoIcon; private Tooltip backoffTooltip; private Dialog failuresDialog; + private int successCount; @Inject public ProgressBoxFxController(ResourceBundle resourceBundle, @@ -67,12 +68,21 @@ public void init(String name, Optional totalCount) { public void updateSuccessCount(int value) { runLater(() -> { + successCount = value; totalCount.ifPresent(count -> progressIndicator.setProgress((double) value / count)); valueLabel.setText(Integer.toString(value)); animatedBackoffInfoIcon.hide(); }); } + public void updateTotalCount(int newValue) { + runLater(() -> { + totalCount = Optional.of(newValue); + progressIndicator.setProgress((double) successCount / newValue); + animatedBackoffInfoIcon.hide(); + }); + } + public void updateDescription(String description) { runLater(() -> descriptionLabel.setText(description)); } diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressStatusBarImpl.java b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressStatusBarImpl.java index 53429ff..f87144f 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressStatusBarImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressStatusBarImpl.java @@ -30,6 +30,11 @@ public void updateSuccess(int newValue) { controller.updateSuccessCount(newValue); } + @Override + public void updateTotal(int newValue) { + controller.updateTotalCount(newValue); + } + @Override public void updateDescription(String description) { controller.updateDescription(description); diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressValueUpdater.java b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressValueUpdater.java index 66a8575..4214e71 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/ProgressValueUpdater.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/ProgressValueUpdater.java @@ -7,6 +7,8 @@ interface ProgressValueUpdater { void updateSuccess(int newValue); + void updateTotal(int newValue); + void updateDescription(String description); void addFailures(Collection failures); diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/ThrottlingProgressStatus.java b/src/main/java/net/yudichev/googlephotosupload/ui/ThrottlingProgressStatus.java index 342016a..fd380b4 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/ThrottlingProgressStatus.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/ThrottlingProgressStatus.java @@ -28,6 +28,7 @@ final class ThrottlingProgressStatus implements ProgressStatus { private final ProgressValueUpdater delegate; private final ThrottlingConsumer eventSink; private final AtomicInteger successCount = new AtomicInteger(); + private final AtomicInteger totalCount = new AtomicInteger(); private final AtomicReference description = new AtomicReference<>(); private final BlockingQueue pendingErrors = new ArrayBlockingQueue<>(65536); private final SchedulingExecutor executor; @@ -51,6 +52,13 @@ public void updateSuccess(int newValue) { eventSink.accept(() -> delegate.updateSuccess(successCount.get())); } + @Override + public void updateTotal(int newValue) { + ensureNotClosed(); + totalCount.set(newValue); + eventSink.accept(() -> delegate.updateTotal(totalCount.get())); + } + @Override public void updateDescription(String newValue) { ensureNotClosed(); diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/UpgradeNotificationDialogControllerImpl.java b/src/main/java/net/yudichev/googlephotosupload/ui/UpgradeNotificationDialogControllerImpl.java index 2aeda68..aec8249 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/UpgradeNotificationDialogControllerImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/UpgradeNotificationDialogControllerImpl.java @@ -1,6 +1,6 @@ package net.yudichev.googlephotosupload.ui; -import com.sandec.mdfx.MDFXNode; +import com.sandec.mdfx.MarkdownView; import javafx.event.ActionEvent; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; @@ -47,7 +47,9 @@ public void initialise(List orderedNewerRevisions, Runnable dism .append("### ").append(revision.tag_name()).append(lineSeparator()) .append(revision.body().get()).append(lineSeparator())); - releaseNotesScrollPane.setContent(new MDFXNode(builder.toString())); + var markdownView = new MarkdownView(builder.toString()); + markdownView.getStylesheets().add("/com/sandec/mdfx/mdfx-default.css"); + releaseNotesScrollPane.setContent(markdownView); releaseNotesPane.heightProperty().addListener((obs, oldHeight, newHeight) -> dialogResizeAction.run()); } diff --git a/src/main/java/net/yudichev/googlephotosupload/ui/UploadPaneControllerImpl.java b/src/main/java/net/yudichev/googlephotosupload/ui/UploadPaneControllerImpl.java index abb4100..8e2c722 100644 --- a/src/main/java/net/yudichev/googlephotosupload/ui/UploadPaneControllerImpl.java +++ b/src/main/java/net/yudichev/googlephotosupload/ui/UploadPaneControllerImpl.java @@ -42,6 +42,7 @@ public final class UploadPaneControllerImpl extends BaseLifecycleComponent imple public Button stopButton; public VBox topVBox; public Button uploadMoreButton; + private boolean everInitialised; @Inject UploadPaneControllerImpl(Uploader uploader, @@ -59,6 +60,7 @@ public final class UploadPaneControllerImpl extends BaseLifecycleComponent imple public void initialize() { Pane supportPane = fxmlContainerFactory.create("SupportPane.fxml").root(); topVBox.getChildren().add(supportPane); + everInitialised = true; } @Override @@ -72,6 +74,9 @@ public void addProgressBox(ProgressBox progressBox) { @Override public void reset() { runLater(() -> { + if (!everInitialised) { + return; + } logArea.getStyleClass().remove("success-background"); logArea.getStyleClass().remove("failed-background"); logArea.getStyleClass().remove("partial-success-background"); diff --git a/src/main/resources/FolderSelector.fxml b/src/main/resources/FolderSelector.fxml index 980ea7f..c531008 100644 --- a/src/main/resources/FolderSelector.fxml +++ b/src/main/resources/FolderSelector.fxml @@ -50,8 +50,8 @@ -