diff --git a/core/src/main/java/edu/wpi/grip/core/GripFileManager.java b/core/src/main/java/edu/wpi/grip/core/GripFileManager.java index 2a5083b49a..88eef09370 100644 --- a/core/src/main/java/edu/wpi/grip/core/GripFileManager.java +++ b/core/src/main/java/edu/wpi/grip/core/GripFileManager.java @@ -22,6 +22,8 @@ public class GripFileManager implements FileManager { public static final File GRIP_DIRECTORY = new File(System.getProperty("user.home") + File.separator + "GRIP"); public static final File IMAGE_DIRECTORY = new File(GRIP_DIRECTORY, "images"); + public static final File BACKUP_FILE = new File(GRIP_DIRECTORY, ".backup.grip"); + public static final File LAST_SAVE_FILE = new File(GRIP_DIRECTORY, ".last_save"); @Override public void saveImage(byte[] image, String fileName) { diff --git a/core/src/main/java/edu/wpi/grip/core/Pipeline.java b/core/src/main/java/edu/wpi/grip/core/Pipeline.java index fc09cdd8d6..3868d0e167 100644 --- a/core/src/main/java/edu/wpi/grip/core/Pipeline.java +++ b/core/src/main/java/edu/wpi/grip/core/Pipeline.java @@ -28,7 +28,7 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.concurrent.locks.Lock; @@ -61,7 +61,7 @@ public class Pipeline implements ConnectionValidator, SettingsProvider, StepInde */ private final List sources = new ArrayList<>(); private final List steps = new ArrayList<>(); - private final Set connections = new HashSet<>(); + private final Set connections = new LinkedHashSet<>(); @Inject @XStreamOmitField private transient EventBus eventBus; diff --git a/core/src/main/java/edu/wpi/grip/core/events/DirtiesSaveEvent.java b/core/src/main/java/edu/wpi/grip/core/events/DirtiesSaveEvent.java index b66a591124..29aa833692 100644 --- a/core/src/main/java/edu/wpi/grip/core/events/DirtiesSaveEvent.java +++ b/core/src/main/java/edu/wpi/grip/core/events/DirtiesSaveEvent.java @@ -8,6 +8,13 @@ */ public interface DirtiesSaveEvent { + DirtiesSaveEvent DIRTIES_SAVE_EVENT = new DirtiesSaveEvent() { + @Override + public boolean doesDirtySave() { + return true; + } + }; + /** * Some events may have more logic regarding whether they make the save dirty or not. * diff --git a/core/src/main/java/edu/wpi/grip/core/serialization/Project.java b/core/src/main/java/edu/wpi/grip/core/serialization/Project.java index 0324fcd77c..a2e0147e32 100644 --- a/core/src/main/java/edu/wpi/grip/core/serialization/Project.java +++ b/core/src/main/java/edu/wpi/grip/core/serialization/Project.java @@ -1,5 +1,6 @@ package edu.wpi.grip.core.serialization; +import edu.wpi.grip.core.GripFileManager; import edu.wpi.grip.core.Pipeline; import edu.wpi.grip.core.PipelineRunner; import edu.wpi.grip.core.events.DirtiesSaveEvent; @@ -8,6 +9,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; +import com.google.common.io.Files; import com.google.common.reflect.ClassPath; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.annotations.XStreamAlias; @@ -22,11 +24,15 @@ import java.io.Reader; import java.io.StringReader; import java.io.Writer; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + import javax.inject.Inject; import javax.inject.Singleton; @@ -36,6 +42,8 @@ @Singleton public class Project { + private static final Logger logger = Logger.getLogger(Project.class.getName()); + protected final XStream xstream = new XStream(new PureJavaReflectionProvider()); @Inject private EventBus eventBus; @@ -74,7 +82,6 @@ public void initialize(StepConverter stepConverter, } catch (InternalError ex) { throw new AssertionError("Failed to load class: " + clazz.getName(), ex); } - }); } catch (IOException ex) { throw new AssertionError("Could not load classes for XStream annotation processing", ex); @@ -88,8 +95,27 @@ public Optional getFile() { return file; } + /** + * Sets the current project file to the given optional one. This also updates a file on disk + * so the app can "remember" the most recent save file when it's opened later. + * + * @param file the optional file that this project is associated with. + */ public void setFile(Optional file) { + file.ifPresent(f -> logger.info("Setting project file to: " + f.getAbsolutePath())); this.file = file; + try { + if (file.isPresent()) { + Files.write(file.get().getAbsolutePath(), + GripFileManager.LAST_SAVE_FILE, + Charset.defaultCharset()); + } else { + // No project file, delete the last_save file + GripFileManager.LAST_SAVE_FILE.delete(); + } + } catch (IOException e) { + logger.log(Level.WARNING, "Could not set last file", e); + } } /** @@ -100,7 +126,7 @@ public void open(File file) throws IOException { StandardCharsets.UTF_8)) { this.open(reader); } - this.file = Optional.of(file); + setFile(Optional.of(file)); } /** @@ -158,11 +184,29 @@ public void save(File file) throws IOException { this.file = Optional.of(file); } + /** + * Save the project using a writer to write the data. This will clear the dirty flag. + * + * @param writer the writer to use to save the project + * + * @see #saveRaw(Writer) + */ public void save(Writer writer) { this.xstream.toXML(this.pipeline, writer); saveIsDirty.set(false); } + /** + * Save the project using a writer to write the data. This has no other side effects. + * + * @param writer the writer to use to save the project + * + * @see #save(Writer) + */ + public void saveRaw(Writer writer) { + xstream.toXML(pipeline, writer); + } + public boolean isSaveDirty() { return saveIsDirty.get(); } @@ -173,9 +217,7 @@ public void addIsSaveDirtyConsumer(Consumer consumer) { @Subscribe public void onDirtiesSaveEvent(DirtiesSaveEvent dirtySaveEvent) { - // Only update the flag the save isn't already dirty - // We don't need to be redundantly checking if the event dirties the save - if (!saveIsDirty.get() && dirtySaveEvent.doesDirtySave()) { + if (dirtySaveEvent.doesDirtySave()) { saveIsDirty.set(true); } } diff --git a/ui/src/main/java/edu/wpi/grip/ui/Main.java b/ui/src/main/java/edu/wpi/grip/ui/Main.java index b797176548..7bab8e5140 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/Main.java +++ b/ui/src/main/java/edu/wpi/grip/ui/Main.java @@ -1,6 +1,8 @@ package edu.wpi.grip.ui; +import edu.wpi.grip.core.CoreCommandLineHelper; import edu.wpi.grip.core.GripCoreModule; +import edu.wpi.grip.core.GripFileManager; import edu.wpi.grip.core.GripFileModule; import edu.wpi.grip.core.PipelineRunner; import edu.wpi.grip.core.events.UnexpectedThrowableEvent; @@ -27,6 +29,9 @@ import org.apache.commons.cli.CommandLine; import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.logging.Level; import java.util.logging.Logger; @@ -65,6 +70,7 @@ public class Main extends Application { @Inject private CVOperations cvOperations; @Inject private GripServer server; @Inject private HttpPipelineSwitcher pipelineSwitcher; + @Inject private ProjectBackupLoader backupLoader; private Parent root; private boolean headless; private final UICommandLineHelper commandLineHelper = new UICommandLineHelper(); @@ -128,6 +134,16 @@ public void start(Stage stage) throws IOException { Platform.runLater(() -> stage.setTitle(MAIN_TITLE)); } }); + project.addIsSaveDirtyConsumer(dirty -> { + if (dirty) { + try (Writer fw = Files.newBufferedWriter( + GripFileManager.BACKUP_FILE.toPath(), StandardCharsets.UTF_8)) { + project.saveRaw(fw); + } catch (IOException e) { + logger.log(Level.WARNING, "Could not save backup file", e); + } + } + }); stage.setTitle(MAIN_TITLE); stage.getIcons().add(new Image("/edu/wpi/grip/ui/icons/grip.png")); @@ -141,7 +157,11 @@ public void start(Stage stage) throws IOException { operations.addOperations(); cvOperations.addOperations(); - commandLineHelper.loadFile(parsedArgs, project); + if (parsedArgs.hasOption(CoreCommandLineHelper.FILE_OPTION)) { + commandLineHelper.loadFile(parsedArgs, project); + } else { + backupLoader.loadBackupOrPreviousSave(); + } commandLineHelper.setServerPort(parsedArgs, settingsProvider, eventBus); // This will throw an exception if the port specified by the save file or command line diff --git a/ui/src/main/java/edu/wpi/grip/ui/ProjectBackupLoader.java b/ui/src/main/java/edu/wpi/grip/ui/ProjectBackupLoader.java new file mode 100644 index 0000000000..a47486cc73 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/ProjectBackupLoader.java @@ -0,0 +1,94 @@ +package edu.wpi.grip.ui; + +import edu.wpi.grip.core.GripFileManager; +import edu.wpi.grip.core.events.DirtiesSaveEvent; +import edu.wpi.grip.core.serialization.Project; + +import com.google.common.eventbus.EventBus; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.thoughtworks.xstream.XStreamException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Class handling loading project backups when the app is launched. This improves the UX by letting + * users resume where they left off. + */ +@Singleton +public class ProjectBackupLoader { + + private static final Logger logger = Logger.getLogger(ProjectBackupLoader.class.getName()); + + @Inject + private EventBus eventBus; + @Inject + private Project project; + + /** + * Loads the backup project, or the previous save if it's identical to the backup. If there was + * no previous save, the project will have no file associated with it. Does nothing if the backup + * file does not exist. + */ + public void loadBackupOrPreviousSave() { + if (GripFileManager.LAST_SAVE_FILE.exists()) { + // Load whichever is readable and more recent, backup or previous save + try { + File lastSaveFile = lastSaveFile(); + if (lastSaveFile != null) { + // Compare last save to backup + if (lastSaveFile.lastModified() >= GripFileManager.BACKUP_FILE.lastModified()) { + // Last save is more recent, load that one + logger.info("Loading the last save file"); + project.open(lastSaveFile); + } else if (GripFileManager.BACKUP_FILE.exists()) { + // Load backup, set the file to the last save file (instead of the backup), + // and post an event marking the save as dirty + loadBackup(); + project.setFile(Optional.of(lastSaveFile)); + eventBus.post(DirtiesSaveEvent.DIRTIES_SAVE_EVENT); + } + } else if (GripFileManager.BACKUP_FILE.exists()) { + // Couldn't read from the last save, just load the backup if possible + loadBackup(); + project.setFile(Optional.empty()); + } + } catch (XStreamException | IOException e) { + logger.log(Level.WARNING, "Could not open the last project file", e); + } + } else if (GripFileManager.BACKUP_FILE.exists()) { + // Load the backup, if possible + loadBackup(); + project.setFile(Optional.empty()); + } + } + + private File lastSaveFile() throws IOException { + List lines = Files.readAllLines(GripFileManager.LAST_SAVE_FILE.toPath()); + if (lines.size() == 1) { + return new File(lines.get(0)); + } else { + logger.warning("Unexpected data in last_save file: " + lines); + return null; + } + } + + private void loadBackup() { + try { + logger.info("Loading backup file"); + project.open(GripFileManager.BACKUP_FILE); + } catch (XStreamException | IOException e) { + logger.log(Level.WARNING, "Could not load backup file", e); + } + eventBus.post(DirtiesSaveEvent.DIRTIES_SAVE_EVENT); + } + +} + +