diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java new file mode 100644 index 0000000..11f545c --- /dev/null +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2026 Markus Fleischhacker + * + * This file is part of Bounding Box Editor + * + * Bounding Box Editor is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Bounding Box Editor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Bounding Box Editor. If not, see . + */ +package com.github.mfl28.boundingboxeditor.controller; + +import com.github.mfl28.boundingboxeditor.model.Model; +import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation; +import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData; +import com.github.mfl28.boundingboxeditor.ui.DialogService; +import javafx.animation.PauseTransition; +import javafx.scene.control.ButtonBar; +import javafx.stage.Stage; +import javafx.util.Duration; + +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.function.Function; + +/** + * Saves the not yet exported annotations of the open folder a few seconds after the last change (into an + * {@link AutosaveStore}), deletes the autosave once they are exported or discarded, and offers to restore it when the + * folder is opened again, or at startup. + */ +class AutosaveController { + static final String RESTORE_DIALOG_TITLE = "Restore Unsaved Annotations"; + private static final Duration DELAY = Duration.seconds(5); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()); + + private final Model model; + private final DialogService dialogService; + private final Stage stage; + private final RecentImageFolders recentImageFolders; + private final AutosaveStore store; + private final AutosaveWriter writer; + private final Timer delay; + private final Operations operations; + // The folder whose images are shown, which the autosave belongs to. + private File currentFolder; + // While it is open whether to restore the shown folder's autosave, and while it is restored, nothing is saved. + private boolean paused = false; + // After the window was closed. + private boolean stopped = false; + private boolean restoring = false; + // Set when the user agreed at startup to restore the last folder's autosave, so that it isn't asked again. + private File folderToRestoreWithoutAsking; + + /** + * What the autosave needs from the rest of the app. + */ + interface Operations { + /** + * Stores the shown image's shapes in the model. + */ + void updateModelFromView(); + + boolean isDrawingInProgress(); + + /** + * Starts importing the annotations of an autosave file (in the JSON format). The import calls + * {@link #onRestoreFinished(boolean)} or {@link #onRestoreFailed()} when it is done. + */ + void restore(File autosaveFile); + + /** + * Opens an image folder (which then calls {@link #onFolderOpened(File)}). + */ + void openImageFolder(File folder); + + /** + * Runs an action on the JavaFX thread (the autosave thread reports through it). + */ + void runOnUiThread(Runnable action); + + /** + * Shows when the annotations were last saved automatically. + * + * @param time the time, or null if there is no autosave + */ + void showAutosaveTime(Instant time); + + void showAutosaveFailed(String reason); + } + + /** + * The delay after a change before the annotations are saved. + */ + interface Timer { + void restart(); + + void stop(); + } + + /** + * Creates the autosave for the user's application data folder, with its own writer thread. + */ + static AutosaveController create(Model model, DialogService dialogService, Stage stage, + RecentImageFolders recentImageFolders, Operations operations) { + return new AutosaveController(model, dialogService, stage, recentImageFolders, AutosaveStore.createDefault(), + AutosaveWriter.createExecutor(), AutosaveController::createDelay, operations); + } + + /** + * Creates the autosave. + * + * @param executor the executor (with one thread) that writes the autosaves + * @param timerFactory creates the delay after a change, for the action that saves + */ + AutosaveController(Model model, DialogService dialogService, Stage stage, RecentImageFolders recentImageFolders, + AutosaveStore store, ExecutorService executor, Function timerFactory, + Operations operations) { + this.model = model; + this.dialogService = dialogService; + this.stage = stage; + this.recentImageFolders = recentImageFolders; + this.store = store; + this.operations = operations; + this.writer = new AutosaveWriter(store, executor, this::onSaved, + exception -> onSaveFailed(exception.getMessage())); + this.delay = timerFactory.apply(this::saveNow); + + // Exported (or discarded, when another folder is opened) annotations need no autosave anymore. + model.savedProperty().addListener((observable, oldValue, newValue) -> { + if(paused) { + return; + } + + if(Boolean.TRUE.equals(newValue)) { + delay.stop(); + delete(currentFolder); + } else { + schedule(); + } + }); + } + + AutosaveStore getStore() { + return store; + } + + /** + * Restarts the delay after a change. + */ + void onEdited() { + schedule(); + } + + /** + * Saves the annotations of the shown folder now (as after the delay), unless they are exported. + */ + void saveNow() { + final File folder = currentFolder; + + if(folder == null || paused || stopped) { + return; + } + + if(operations.isDrawingInProgress()) { + schedule(); + return; + } + + operations.updateModelFromView(); + + if(model.isSaved()) { + delete(folder); + return; + } + + writer.save(folder, createSnapshot()); + } + + /** + * Waits until the autosaves that were started are written (or deleted). + */ + void awaitCompletion() { + writer.awaitCompletion(); + } + + /** + * Called when a folder was opened: asks whether to restore its autosave (unless the user already agreed at + * startup), and restores or deletes it. + */ + void onFolderOpened(File folder) { + currentFolder = folder; + final Optional lastSaved = store.lastSaved(folder); + final boolean restoreWithoutAsking = folder.equals(folderToRestoreWithoutAsking); + folderToRestoreWithoutAsking = null; + + if(lastSaved.isPresent()) { + paused = true; + delay.stop(); + + final boolean restore = restoreWithoutAsking + || dialogService.displayYesNoDialogAndGetResult(RESTORE_DIALOG_TITLE, + String.format("The annotations of this folder from %s were not exported. They were saved " + + "automatically. Restore them?", + TIME_FORMATTER.format(lastSaved.get())), stage) == ButtonBar.ButtonData.YES; + + if(restore) { + restoring = true; + operations.restore(store.fileFor(folder).toFile()); + } else { + paused = false; + delete(folder); + } + } + + showAutosaveTime(); + } + + /** + * Called when no folder is shown anymore. + */ + void onFolderClosed() { + currentFolder = null; + showAutosaveTime(); + } + + boolean isRestoring() { + return restoring; + } + + /** + * Called when the autosave was imported. + * + * @param anyRestored whether any annotations were restored + */ + void onRestoreFinished(boolean anyRestored) { + restoring = false; + paused = false; + + // Restored annotations are still not exported. + if(anyRestored) { + model.setSaved(false); + } + } + + void onRestoreFailed() { + restoring = false; + paused = false; + } + + /** + * Called when the user quits without saving: the annotations are discarded, so is their autosave. + */ + void discard() { + delete(currentFolder); + } + + /** + * Stops saving, after the save that is running (once the window is closed or the app quits). + */ + void stop() { + stopped = true; + delay.stop(); + writer.awaitCompletion(); + } + + /** + * At startup, offers to reopen the most recently opened folder if its annotations were not exported. + */ + void offerToRestoreLastSession() { + final List folders = recentImageFolders.getFolders(); + + if(folders.isEmpty() || currentFolder != null) { + return; + } + + final File folder = folders.getFirst(); + final Optional lastSaved = store.lastSaved(folder); + + if(!folder.isDirectory() || lastSaved.isEmpty()) { + return; + } + + final ButtonBar.ButtonData answer = dialogService.displayYesNoDialogAndGetResult( + RESTORE_DIALOG_TITLE, + String.format("The annotations of the folder \"%s\" from %s were not exported. They were saved " + + "automatically. Open the folder and restore them?", + folder.getPath(), TIME_FORMATTER.format(lastSaved.get())), stage); + + if(answer == ButtonBar.ButtonData.YES) { + openFolderAndRestore(folder); + } + } + + /** + * Opens an image folder and restores its autosave without asking. + * + * @param folder the image folder + */ + void openFolderAndRestore(File folder) { + folderToRestoreWithoutAsking = folder; + operations.openImageFolder(folder); + } + + // Called on the autosave thread. + private void onSaved() { + operations.runOnUiThread(this::showAutosaveTime); + } + + // Called on the autosave thread. + private void onSaveFailed(String reason) { + operations.runOnUiThread(() -> operations.showAutosaveFailed(reason)); + } + + private void schedule() { + if(currentFolder != null && !paused && !stopped) { + delay.restart(); + } + } + + /** + * Deletes the autosave of a folder, after the autosaves that were already started, and waits for it (so that it + * is gone even if the app quits right afterwards). + */ + private void delete(File folder) { + if(folder == null) { + return; + } + + writer.delete(folder); + showAutosaveTime(); + } + + private void showAutosaveTime() { + operations.showAutosaveTime(currentFolder == null ? null : store.lastSaved(currentFolder).orElse(null)); + } + + /** + * Copies the annotations (with their tags and parts), so that they can be saved off the JavaFX thread while the + * user goes on editing. + */ + private ImageAnnotationData createSnapshot() { + final ImageAnnotationData data = model.createImageAnnotationData(); + final List annotations = data.imageAnnotations().stream() + .map(annotation -> new ImageAnnotation(annotation.getImageMetaData(), + annotation.getBoundingShapeData().stream().map(ShapeClipboardController::copyOf).toList())) + .toList(); + return new ImageAnnotationData(annotations, Map.copyOf(data.categoryNameToBoundingShapeCountMap()), + Map.copyOf(data.categoryNameToCategoryMap())); + } + + private static Timer createDelay(Runnable action) { + final PauseTransition transition = new PauseTransition(DELAY); + transition.setOnFinished(event -> action.run()); + + return new Timer() { + @Override + public void restart() { + transition.playFromStart(); + } + + @Override + public void stop() { + transition.stop(); + } + }; + } +} diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java index 4d3fffa..0187e79 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java @@ -60,14 +60,14 @@ final class AutosaveWriter { } /** - * Creates a writer with its own daemon thread. + * Creates the executor for a writer: one daemon thread. */ - static AutosaveWriter create(AutosaveStore store, Runnable onSaved, Consumer onFailed) { - return new AutosaveWriter(store, Executors.newSingleThreadExecutor(runnable -> { + static ExecutorService createExecutor() { + return Executors.newSingleThreadExecutor(runnable -> { final Thread thread = new Thread(runnable, "autosave"); thread.setDaemon(true); return thread; - }), onSaved, onFailed); + }); } /** diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java index 063ce80..e236e1e 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java @@ -23,7 +23,6 @@ import com.github.mfl28.boundingboxeditor.model.Model; import com.github.mfl28.boundingboxeditor.model.data.BoundingShapeData; import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation; -import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData; import com.github.mfl28.boundingboxeditor.model.data.ImageMetaData; import com.github.mfl28.boundingboxeditor.model.data.IoMetaData; import com.github.mfl28.boundingboxeditor.model.data.ObjectCategory; @@ -43,7 +42,6 @@ import com.github.mfl28.boundingboxeditor.ui.statusevents.ImageFilesLoadingSuccessfulEvent; import com.github.mfl28.boundingboxeditor.utils.ColorUtils; import com.github.mfl28.boundingboxeditor.utils.ImageUtils; -import javafx.animation.PauseTransition; import javafx.application.HostServices; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -61,14 +59,9 @@ import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.WindowEvent; -import javafx.util.Duration; import java.io.File; -import java.io.IOException; import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; import java.util.*; import java.util.function.Supplier; @@ -147,25 +140,6 @@ public class Controller { private final ShapeClipboardController shapeClipboardController = new ShapeClipboardController(new ShapeClipboardOperations()); private boolean editCheckpointScheduled = false; - // Autosave: the annotations are saved a few seconds after the last change, until they are exported. - private static final Duration AUTOSAVE_DELAY = Duration.seconds(5); - static final String RESTORE_AUTOSAVE_DIALOG_TITLE = "Restore Unsaved Annotations"; - private static final DateTimeFormatter AUTOSAVE_TIME_FORMATTER = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()); - private final AutosaveStore autosaveStore = AutosaveStore.createDefault(); - private final PauseTransition autosaveDelay = new PauseTransition(AUTOSAVE_DELAY); - // Saves and deletes in order, off the JavaFX thread. - private final AutosaveWriter autosaveWriter = - AutosaveWriter.create(autosaveStore, this::onAutosaved, this::onAutosaveFailed); - // The folder whose images are shown, which the autosave belongs to. - private File currentImageFolder; - // While it is open whether to restore the shown folder's autosave, and while it is restored, nothing is saved. - private boolean autosavePaused = false; - // After the window was closed. - private boolean autosaveStopped = false; - private boolean restoringAutosave = false; - // Set when the user agreed at startup to restore the last folder's autosave, so that it isn't asked again. - private File folderToRestoreWithoutAsking; private final ListChangeListener boundingShapeCountPerCategoryListener = createBoundingShapeCountPerCategoryListener(); private final ChangeListener imageLoadProgressListener = createImageLoadingProgressListener(); @@ -173,6 +147,7 @@ public class Controller { private final IoMetaData ioMetaData = new IoMetaData(); private final PreferencesStore preferencesStore = PreferencesStore.forApplication(); private final RecentImageFolders recentImageFolders = new RecentImageFolders(preferencesStore); + private final AutosaveController autosaveController; final List keyCombinationHandlers; String lastLoadedImageUrl; private final ChangeListener selectedFileIndexListener = createSelectedFileIndexListener(); @@ -218,9 +193,10 @@ public Controller(final Stage mainStage, final MainView view, final HostServices view.connectToController(this); setUpModelListeners(); setUpServices(); - setUpAutosave(); + autosaveController = AutosaveController.create(model, dialogService, stage, recentImageFolders, + new AutosaveOperations()); // After the window was shown. - Platform.runLater(this::offerToRestoreLastSession); + Platform.runLater(autosaveController::offerToRestoreLastSession); } public void onRegisterSettingsAction() { @@ -470,10 +446,10 @@ public void onRegisterExitAction() { } // The user decided not to keep the annotations. - deleteAutosave(currentImageFolder); + autosaveController.discard(); } - autosaveDelay.stop(); + autosaveController.stop(); savePreferences(); imageFolderController.stopWatching(); makeClientUnavailable(); @@ -826,8 +802,7 @@ private void onImageMetaDataLoadingSucceeded(WorkerStateEvent workerStateEvent) private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { ImageAnnotationImportResult importResult = annotationImportService.getValue(); - final boolean restoredAutosave = restoringAutosave; - restoringAutosave = false; + final boolean restoredAutosave = autosaveController.isRestoring(); if(importResult.getNrSuccessfullyProcessedItems() != 0) { model.updateFromImageAnnotationData(importResult.getImageAnnotationData(), importResult.getOperationType()); @@ -835,12 +810,7 @@ private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { } if(restoredAutosave) { - autosavePaused = false; - - // Restored annotations are still not exported. - if(importResult.getNrSuccessfullyProcessedItems() != 0) { - model.setSaved(false); - } + autosaveController.onRestoreFinished(importResult.getNrSuccessfullyProcessedItems() != 0); } updateViewFileExplorerFileInfoElements(); @@ -865,9 +835,8 @@ private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { private void onIoServiceFailed(WorkerStateEvent event) { - if(annotationImportService.equals(event.getSource()) && restoringAutosave) { - restoringAutosave = false; - autosavePaused = false; + if(annotationImportService.equals(event.getSource()) && autosaveController.isRestoring()) { + autosaveController.onRestoreFailed(); } final Throwable exception = event.getSource().getException(); @@ -1285,7 +1254,7 @@ private void setupStage() { event.consume(); }); // Once the window is closed, nothing is saved anymore (also not into the next test's autosave folder). - stage.addEventHandler(WindowEvent.WINDOW_HIDDEN, event -> stopAutosaving()); + stage.addEventHandler(WindowEvent.WINDOW_HIDDEN, event -> autosaveController.stop()); stage.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { if (!Objects.equals(event.getTarget(), view.getEditorImageView())) { @@ -1308,165 +1277,19 @@ private void scheduleEditCheckpoint() { Platform.runLater(() -> { editCheckpointScheduled = false; editHistoryController.checkpoint(); - scheduleAutosave(); + autosaveController.onEdited(); }); } } - /** - * Saves the annotations of the shown folder now (as after the autosave delay), unless they are exported. - */ - void autosaveNow() { - final File folder = currentImageFolder; - - if(folder == null || autosavePaused || autosaveStopped) { - return; - } - - if(view.getEditorImagePane().isDrawingInProgress()) { - scheduleAutosave(); - return; - } - - updateModelFromView(); - - if(model.isSaved()) { - deleteAutosave(folder); - return; - } - - autosaveWriter.save(folder, createAutosaveSnapshot()); - } - - /** - * Waits until the autosaves that were started are written (or deleted). - */ - void waitForAutosave() { - autosaveWriter.awaitCompletion(); - } - - AutosaveStore getAutosaveStore() { - return autosaveStore; + AutosaveController getAutosaveController() { + return autosaveController; } RecentImageFolders getRecentImageFolders() { return recentImageFolders; } - private void setUpAutosave() { - autosaveDelay.setOnFinished(event -> autosaveNow()); - - // Exported (or discarded, when another folder is opened) annotations need no autosave anymore. - model.savedProperty().addListener((observable, oldValue, newValue) -> { - if(autosavePaused) { - return; - } - - if(Boolean.TRUE.equals(newValue)) { - autosaveDelay.stop(); - deleteAutosave(currentImageFolder); - } else { - scheduleAutosave(); - } - }); - } - - private void scheduleAutosave() { - if(currentImageFolder != null && !autosavePaused && !autosaveStopped) { - autosaveDelay.playFromStart(); - } - } - - private void stopAutosaving() { - autosaveStopped = true; - autosaveDelay.stop(); - autosaveWriter.awaitCompletion(); - } - - /** - * Deletes the autosave of a folder, after the autosaves that were already started, and waits for it (so that it - * is gone even if the app quits right afterwards). - */ - private void deleteAutosave(File folder) { - if(folder == null) { - return; - } - - autosaveWriter.delete(folder); - showAutosaveTime(); - } - - // Called on the autosave thread. - private void onAutosaved() { - Platform.runLater(this::showAutosaveTime); - } - - // Called on the autosave thread. - private void onAutosaveFailed(IOException exception) { - Platform.runLater(() -> view.getStatusBar().setStatusEvent(new AutosaveFailedEvent(exception.getMessage()))); - } - - /** - * Shows when the annotations of the shown folder were last saved automatically (in the status bar's saved-status - * tooltip). - */ - private void showAutosaveTime() { - view.getStatusBar().setAutosaveTime(currentImageFolder == null ? null - : autosaveStore.lastSaved(currentImageFolder).orElse(null)); - } - - /** - * Copies the annotations (with their tags and parts), so that they can be saved off the JavaFX thread while the - * user goes on editing. - */ - private ImageAnnotationData createAutosaveSnapshot() { - final ImageAnnotationData data = model.createImageAnnotationData(); - final List annotations = data.imageAnnotations().stream() - .map(annotation -> new ImageAnnotation(annotation.getImageMetaData(), - annotation.getBoundingShapeData().stream().map(ShapeClipboardController::copyOf).toList())) - .toList(); - return new ImageAnnotationData(annotations, Map.copyOf(data.categoryNameToBoundingShapeCountMap()), - Map.copyOf(data.categoryNameToCategoryMap())); - } - - /** - * At startup, offers to reopen the most recently opened folder if its annotations were not exported. - */ - void offerToRestoreLastSession() { - final List folders = recentImageFolders.getFolders(); - - if(folders.isEmpty() || currentImageFolder != null) { - return; - } - - final File folder = folders.getFirst(); - final Optional lastSaved = autosaveStore.lastSaved(folder); - - if(!folder.isDirectory() || lastSaved.isEmpty()) { - return; - } - - final ButtonBar.ButtonData answer = dialogService.displayYesNoDialogAndGetResult( - RESTORE_AUTOSAVE_DIALOG_TITLE, - String.format("The annotations of the folder \"%s\" from %s were not exported. They were saved " - + "automatically. Open the folder and restore them?", - folder.getPath(), AUTOSAVE_TIME_FORMATTER.format(lastSaved.get())), stage); - - if(answer == ButtonBar.ButtonData.YES) { - loadImageFilesAndRestoreAutosave(folder); - } - } - - /** - * Opens an image folder and restores its autosaved annotations without asking. - * - * @param folder the image folder - */ - void loadImageFilesAndRestoreAutosave(File folder) { - folderToRestoreWithoutAsking = folder; - loadImageFiles(folder); - } - /** * Discards all edit histories and starts a new one for the shown image, e.g. after annotations were imported. */ @@ -1507,38 +1330,6 @@ public void startExport(File destination, ImageAnnotationSaveStrategy.Type forma * Loads and shows the image folders opened by the {@link ImageFolderController}. */ private class ImageFolderOperations implements ImageFolderController.Operations { - /** - * Asks whether to restore the autosaved annotations of a folder that was opened (unless the user already agreed - * at startup), and restores or deletes them. - */ - private void offerToRestoreAutosave(File folder) { - final Optional lastSaved = autosaveStore.lastSaved(folder); - final boolean restoreWithoutAsking = folder.equals(folderToRestoreWithoutAsking); - folderToRestoreWithoutAsking = null; - - if(lastSaved.isEmpty()) { - return; - } - - autosavePaused = true; - autosaveDelay.stop(); - - final boolean restore = restoreWithoutAsking - || dialogService.displayYesNoDialogAndGetResult(RESTORE_AUTOSAVE_DIALOG_TITLE, - String.format("The annotations of this folder from %s were not exported. They were saved " - + "automatically. Restore them?", - AUTOSAVE_TIME_FORMATTER.format(lastSaved.get())), stage) == ButtonBar.ButtonData.YES; - - if(restore) { - restoringAutosave = true; - startAnnotationImportService(autosaveStore.fileFor(folder).toFile(), - ImageAnnotationLoadStrategy.Type.JSON); - } else { - autosavePaused = false; - deleteAutosave(folder); - } - } - @Override public void updateModelFromView() { Controller.this.updateModelFromView(); @@ -1570,16 +1361,13 @@ public void showLoadedImageFiles(ImageMetaDataLoadingResult result, File folder, recentImageFolders.add(folder); view.getStatusBar().setStatusEvent(new ImageFilesLoadingSuccessfulEvent(result, folder)); - currentImageFolder = folder; - offerToRestoreAutosave(folder); - showAutosaveTime(); + autosaveController.onFolderOpened(folder); } @Override public void clearWorkspace() { clearViewAndModel(); - currentImageFolder = null; - showAutosaveTime(); + autosaveController.onFolderClosed(); } } @@ -1730,4 +1518,41 @@ public boolean hasPreviousImage() { return imageFileFilterController.hasPreviousProperty().get(); } } + + private class AutosaveOperations implements AutosaveController.Operations { + @Override + public void updateModelFromView() { + Controller.this.updateModelFromView(); + } + + @Override + public boolean isDrawingInProgress() { + return view.getEditorImagePane().isDrawingInProgress(); + } + + @Override + public void restore(File autosaveFile) { + startAnnotationImportService(autosaveFile, ImageAnnotationLoadStrategy.Type.JSON); + } + + @Override + public void openImageFolder(File folder) { + loadImageFiles(folder); + } + + @Override + public void runOnUiThread(Runnable action) { + Platform.runLater(action); + } + + @Override + public void showAutosaveTime(Instant time) { + view.getStatusBar().setAutosaveTime(time); + } + + @Override + public void showAutosaveFailed(String reason) { + view.getStatusBar().setStatusEvent(new AutosaveFailedEvent(reason)); + } + } } diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java new file mode 100644 index 0000000..7c90107 --- /dev/null +++ b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2026 Markus Fleischhacker + * + * This file is part of Bounding Box Editor + * + * Bounding Box Editor is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Bounding Box Editor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Bounding Box Editor. If not, see . + */ +package com.github.mfl28.boundingboxeditor.controller; + +import com.github.mfl28.boundingboxeditor.model.Model; +import com.github.mfl28.boundingboxeditor.model.data.*; +import com.github.mfl28.boundingboxeditor.ui.DialogService; +import javafx.collections.FXCollections; +import javafx.scene.control.ButtonBar; +import javafx.scene.paint.Color; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@Tag("unit") +class AutosaveControllerTest { + private static final String IMAGE = "a.jpg"; + + private final Model model = new Model(); + private final DialogService dialogService = mock(DialogService.class); + private final RecentImageFolders recentImageFolders = mock(RecentImageFolders.class); + private final AutosaveController.Operations operations = mock(AutosaveController.Operations.class); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final ObjectCategory category = new ObjectCategory("boat", Color.RED); + private FakeTimer timer; + private AutosaveStore store; + private AutosaveController autosave; + + @TempDir + Path tempDir; + private File folder; + + @BeforeEach + void setUp() throws Exception { + folder = Files.createDirectory(tempDir.resolve("images")).toFile(); + model.getImageFileNameToMetaDataMap().put(IMAGE, + new ImageMetaData(IMAGE, "images", new File(folder, IMAGE).toURI().toString(), 100, 50, 3)); + model.setImageFiles(List.of(new File(folder, IMAGE))); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList()); + // The autosave thread reports through the UI thread, which these tests don't have. + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(operations).runOnUiThread(any()); + + store = new AutosaveStore(tempDir.resolve("autosave")); + autosave = new AutosaveController(model, dialogService, null, recentImageFolders, store, executor, + action -> timer = new FakeTimer(action), operations); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void onEditing_ShouldSaveTheAnnotationsWhenTheDelayRunsOut() { + autosave.onFolderOpened(folder); + assertFalse(timer.running); + + annotate(); + assertTrue(timer.running); + + timer.fire(); + autosave.awaitCompletion(); + + assertTrue(Files.isRegularFile(store.fileFor(folder))); + verify(operations).updateModelFromView(); + verify(operations, atLeastOnce()).showAutosaveTime(notNull()); + verifyNoInteractions(dialogService); + } + + @Test + void onEditingAgain_ShouldRestartTheDelay() { + autosave.onFolderOpened(folder); + annotate(); + timer.running = false; + + autosave.onEdited(); + + assertTrue(timer.running); + } + + @Test + void onSavingWhileAShapeIsDrawn_ShouldWaitForTheDrawingToFinish() { + autosave.onFolderOpened(folder); + annotate(); + when(operations.isDrawingInProgress()).thenReturn(true); + + timer.fire(); + autosave.awaitCompletion(); + + assertFalse(Files.exists(store.fileFor(folder))); + assertTrue(timer.running); + } + + @Test + void onExportingTheAnnotations_ShouldDeleteTheAutosave() { + autosave.onFolderOpened(folder); + annotate(); + timer.fire(); + autosave.awaitCompletion(); + clearInvocations(operations); + + model.setSaved(true); + + assertFalse(Files.exists(store.fileFor(folder))); + assertFalse(timer.running); + verify(operations).showAutosaveTime(null); + } + + @Test + void onSavingExportedAnnotations_ShouldDeleteTheAutosaveInstead() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.YES); + autosave.onFolderOpened(folder); + autosave.onRestoreFinished(false); + + autosave.saveNow(); + + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onQuittingWithoutSaving_ShouldDeleteTheAutosave() { + autosave.onFolderOpened(folder); + annotate(); + timer.fire(); + autosave.awaitCompletion(); + + autosave.discard(); + + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onOpeningAFolderWithAutosave_WhenRestoring_ShouldImportItAndKeepItUnsaved() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(eq(AutosaveController.RESTORE_DIALOG_TITLE), + startsWith("The annotations of this folder from "), + any())).thenReturn(ButtonBar.ButtonData.YES); + + autosave.onFolderOpened(folder); + + verify(operations).restore(store.fileFor(folder).toFile()); + assertTrue(autosave.isRestoring()); + // Nothing is saved while it is restored. + model.setSaved(false); + assertFalse(timer.running); + + model.setSaved(true); + autosave.onRestoreFinished(true); + + assertFalse(autosave.isRestoring()); + assertFalse(model.isSaved()); + assertTrue(timer.running); + assertTrue(Files.isRegularFile(store.fileFor(folder))); + } + + @Test + void onOpeningAFolderWithAutosave_WhenNotRestoring_ShouldDeleteIt() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.NO); + + autosave.onFolderOpened(folder); + + verify(operations, never()).restore(any()); + assertFalse(Files.exists(store.fileFor(folder))); + assertFalse(autosave.isRestoring()); + } + + @Test + void onFailingToRestore_ShouldSaveAgainAfterEdits() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.YES); + autosave.onFolderOpened(folder); + + autosave.onRestoreFailed(); + annotate(); + + assertFalse(autosave.isRestoring()); + assertTrue(timer.running); + } + + @Test + void onStartingWithAnAutosaveOfTheLastFolder_WhenAccepting_ShouldOpenAndRestoreItWithoutAskingAgain() + throws Exception { + store.save(folder, annotationData()); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + when(dialogService.displayYesNoDialogAndGetResult(eq(AutosaveController.RESTORE_DIALOG_TITLE), + contains("Open the folder and restore them?"), any())) + .thenReturn(ButtonBar.ButtonData.YES); + + autosave.offerToRestoreLastSession(); + verify(operations).openImageFolder(folder); + + // Opening the folder restores it. + autosave.onFolderOpened(folder); + verify(operations).restore(store.fileFor(folder).toFile()); + verify(dialogService, times(1)).displayYesNoDialogAndGetResult(any(), any(), any()); + } + + @Test + void onStartingWithAnAutosaveOfTheLastFolder_WhenDeclining_ShouldNotOpenIt() throws Exception { + store.save(folder, annotationData()); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.NO); + + autosave.offerToRestoreLastSession(); + + verify(operations, never()).openImageFolder(any()); + assertTrue(Files.isRegularFile(store.fileFor(folder))); + } + + @Test + void onStarting_WithoutAnAutosaveOfTheLastFolder_ShouldNotAsk() { + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + + autosave.offerToRestoreLastSession(); + + verifyNoInteractions(dialogService); + } + + @Test + void onStopping_ShouldNotSaveAnymore() { + autosave.onFolderOpened(folder); + annotate(); + + autosave.stop(); + autosave.onEdited(); + autosave.saveNow(); + autosave.awaitCompletion(); + + assertFalse(timer.running); + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onClosingTheFolder_ShouldNotSaveAnymore() { + autosave.onFolderOpened(folder); + annotate(); + + autosave.onFolderClosed(); + autosave.saveNow(); + autosave.awaitCompletion(); + + assertFalse(Files.exists(store.fileFor(folder))); + verify(operations, atLeastOnce()).showAutosaveTime(null); + } + + @Test + void onFailingToSave_ShouldShowTheError() throws Exception { + // A file in place of the autosave folder makes saving fail. + Files.createFile(store.directory()); + autosave.onFolderOpened(folder); + annotate(); + + timer.fire(); + autosave.awaitCompletion(); + + verify(operations).showAutosaveFailed(notNull()); + } + + private void annotate() { + model.updateCurrentBoundingShapeData(List.of(new BoundingBoxData(category, 0.1, 0.1, 0.5, 0.5, List.of()))); + } + + private ImageAnnotationData annotationData() { + final ImageAnnotation annotation = new ImageAnnotation(model.getImageFileNameToMetaDataMap().get(IMAGE), + List.of(new BoundingBoxData(category, 0.1, 0.1, 0.5, 0.5, List.of()))); + return new ImageAnnotationData(List.of(annotation), Map.of(category.getName(), 1), + Map.of(category.getName(), category)); + } + + private static final class FakeTimer implements AutosaveController.Timer { + private final Runnable action; + private boolean running; + + FakeTimer(Runnable action) { + this.action = action; + } + + @Override + public void restart() { + running = true; + } + + @Override + public void stop() { + running = false; + } + + void fire() { + running = false; + action.run(); + } + } +} diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java index 58d5b72..4f09d5c 100644 --- a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java +++ b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java @@ -63,7 +63,7 @@ void start(Stage stage) { void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobot robot, TestInfo testinfo, @TempDir Path tempDir) { final File folder = testImageFolder(); - final AutosaveStore store = controller.getAutosaveStore(); + final AutosaveStore store = controller.getAutosaveController().getStore(); robot.interact(() -> controller.loadImageFiles(folder)); waitUntilCurrentImageIsLoaded(testinfo); enterNewCategory(robot, "Boat", testinfo); @@ -71,8 +71,8 @@ void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobo waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the drawn box", testinfo); // (Instead of waiting for the autosave delay.) - robot.interact(controller::autosaveNow); - controller.waitForAutosave(); + robot.interact(controller.getAutosaveController()::saveNow); + controller.getAutosaveController().awaitCompletion(); verifyThat(Files.isRegularFile(store.fileFor(folder)), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(model.isSaved(), Matchers.is(false), saveScreenshot(testinfo)); @@ -91,7 +91,7 @@ void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobo ImageAnnotationSaveStrategy.Type.JSON)); WaitForAsyncUtils.waitForFxEvents(); timeOutAssertServiceSucceeded(controller.getAnnotationExportService(), testinfo); - controller.waitForAutosave(); + controller.getAutosaveController().awaitCompletion(); verifyThat(model.isSaved(), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(Files.exists(store.fileFor(folder)), Matchers.is(false), saveScreenshot(testinfo)); @@ -107,7 +107,7 @@ void onOpeningFolderWithAutosave_WhenRestoring_ShouldLoadTheUnsavedAnnotations(F writeAutosave(folder); robot.interact(() -> controller.loadImageFiles(folder)); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "Yes", testinfo); waitUntil(() -> model.getImageFileNameToAnnotationMap().containsKey(IMAGE_FILE_NAME), "the restored annotation", @@ -118,15 +118,15 @@ void onOpeningFolderWithAutosave_WhenRestoring_ShouldLoadTheUnsavedAnnotations(F verifyThat(shapes.getFirst().getTags(), Matchers.contains("difficult"), saveScreenshot(testinfo)); // Still not exported, so still unsaved, and the autosave is kept. verifyThat(model.isSaved(), Matchers.is(false), saveScreenshot(testinfo)); - controller.waitForAutosave(); - verifyThat(Files.isRegularFile(controller.getAutosaveStore().fileFor(folder)), Matchers.is(true), + controller.getAutosaveController().awaitCompletion(); + verifyThat(Files.isRegularFile(controller.getAutosaveController().getStore().fileFor(folder)), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(mainView.getStatusBar().getSavedStatusDescription(), Matchers.startsWith("The annotations are not exported yet. They were saved automatically "), saveScreenshot(testinfo)); // The autosave folder doesn't become the folder for importing annotations. verifyThat(controller.getIoMetaData().getDefaultAnnotationLoadingDirectory(), - Matchers.not(Matchers.equalTo(controller.getAutosaveStore().directory().toFile())), + Matchers.not(Matchers.equalTo(controller.getAutosaveController().getStore().directory().toFile())), saveScreenshot(testinfo)); } @@ -137,13 +137,13 @@ void onOpeningFolderWithAutosave_WhenNotRestoring_ShouldDeleteIt(FxRobot robot, writeAutosave(folder); robot.interact(() -> controller.loadImageFiles(folder)); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "No", testinfo); waitUntilCurrentImageIsLoaded(testinfo); - controller.waitForAutosave(); + controller.getAutosaveController().awaitCompletion(); verifyThat(model.getImageFileNameToAnnotationMap().isEmpty(), Matchers.is(true), saveScreenshot(testinfo)); - verifyThat(Files.exists(controller.getAutosaveStore().fileFor(folder)), Matchers.is(false), + verifyThat(Files.exists(controller.getAutosaveController().getStore().fileFor(folder)), Matchers.is(false), saveScreenshot(testinfo)); } @@ -154,7 +154,7 @@ void onRestoringAutosaveWithoutAsking_ShouldPlaceTheShapesOnTheLoadedImage(FxRob writeAutosave(folder); // The annotations may be restored before the image is loaded (no dialog in between, as at startup). - robot.interact(() -> controller.loadImageFilesAndRestoreAutosave(folder)); + robot.interact(() -> controller.getAutosaveController().openFolderAndRestore(folder)); waitUntilCurrentImageIsLoaded(testinfo); waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the restored box", testinfo); WaitForAsyncUtils.waitForFxEvents(); @@ -179,8 +179,8 @@ void onStartingWithAnAutosaveOfTheLastFolder_WhenAccepting_ShouldOpenTheFolderAn robot.interact(() -> controller.getRecentImageFolders().add(folder)); // The dialog waits for an answer, so it is opened later on the JavaFX thread. - Platform.runLater(controller::offerToRestoreLastSession); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + Platform.runLater(controller.getAutosaveController()::offerToRestoreLastSession); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "Yes", testinfo); // The folder is opened and restored without asking again. @@ -203,13 +203,13 @@ void onFailingToSaveAutomatically_ShouldShowTheErrorInTheStatusBar(FxRobot robot waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the drawn box", testinfo); // A file in place of the autosave folder makes saving fail. - final Path directory = controller.getAutosaveStore().directory(); + final Path directory = controller.getAutosaveController().getStore().directory(); deleteRecursively(directory); Files.createFile(directory); try { - robot.interact(controller::autosaveNow); - controller.waitForAutosave(); + robot.interact(controller.getAutosaveController()::saveNow); + controller.getAutosaveController().awaitCompletion(); WaitForAsyncUtils.waitForFxEvents(); verifyThat(mainView.getStatusBar().getCurrentEventMessage(), @@ -244,7 +244,7 @@ private void writeAutosave(File folder) throws Exception { new File(folder, IMAGE_FILE_NAME).toURI().toString(), 3854, 2033, 3); final BoundingBoxData box = new BoundingBoxData(category, 0.2, 0.2, 0.6, 0.6, List.of("difficult")); - controller.getAutosaveStore().save(folder, new ImageAnnotationData( + controller.getAutosaveController().getStore().save(folder, new ImageAnnotationData( List.of(new ImageAnnotation(image, List.of(box))), Map.of("Boat", 1), Map.of("Boat", category))); }