diff --git a/Sources/Data/Microsoft.Psi.Data/Dataset.cs b/Sources/Data/Microsoft.Psi.Data/Dataset.cs index d458d009a..a01e11162 100644 --- a/Sources/Data/Microsoft.Psi.Data/Dataset.cs +++ b/Sources/Data/Microsoft.Psi.Data/Dataset.cs @@ -26,22 +26,67 @@ public class Dataset /// public const string DefaultName = "Untitled Dataset"; + private string name; + /// /// Initializes a new instance of the class. /// /// The name of the new dataset. Default is . + /// An optional filename that indicates the location to save the dataset.. + /// Whether the dataset automatically autosave changes if a path is given (optional, default is false). + /// Indicates whether to use full or relative store paths (optional, default is true). [JsonConstructor] - public Dataset(string name = Dataset.DefaultName) + public Dataset(string name = Dataset.DefaultName, string filename = "", bool autoSave = false, bool useRelativePaths = true) { this.Name = name; + this.Filename = filename; + this.AutoSave = autoSave; + this.UseRelativePaths = useRelativePaths; this.InternalSessions = new List(); + if (this.AutoSave && filename == string.Empty) + { + throw new ArgumentException("filename needed to be provided for autosave dataset."); + } } + /// + /// Event raise when the dataset's structure changed. + /// + public event EventHandler DatasetChanged; + /// /// Gets or sets the name of this dataset. /// [DataMember] - public string Name { get; set; } + public string Name + { + get => this.name; + set + { + this.name = value; + this.OnDatasetChanged(); + } + } + + /// + /// Gets or sets the current save path of this dataset. + /// + public string Filename { get; set; } + + /// + /// Gets or sets a value indicating whether autosave is enabled. + /// + public bool AutoSave { get; set; } + + /// + /// Gets or sets a value indicating whether to use full or relative store paths. + /// + public bool UseRelativePaths { get; set; } + + /// + /// Gets a value indicating whether changes to this dataset have been saved. + /// + public bool HasUnsavedChanges { get; private set; } = false; /// /// Gets the originating time interval (earliest to latest) of the messages in this dataset. @@ -76,8 +121,9 @@ public Dataset(string name = Dataset.DefaultName) /// Loads a dataset from the specified file. /// /// The name of the file that contains the dataset to be loaded. + /// A value to indicate whether to enable autosave (optional, default is false). /// The newly loaded dataset. - public static Dataset Load(string filename) + public static Dataset Load(string filename, bool autoSave = false) { var serializer = JsonSerializer.Create( new JsonSerializerSettings() @@ -92,7 +138,10 @@ public static Dataset Load(string filename) }); using var jsonFile = File.OpenText(filename); using var jsonReader = new JsonTextReader(jsonFile); - return serializer.Deserialize(jsonReader); + var dataset = serializer.Deserialize(jsonReader); + dataset.AutoSave = autoSave; + dataset.Filename = filename; + return dataset; } /// @@ -118,6 +167,7 @@ public Session CreateSession(string sessionName = Session.DefaultName) { var session = new Session(this, sessionName); this.InternalSessions.Add(session); + this.OnDatasetChanged(); return session; } @@ -128,6 +178,7 @@ public Session CreateSession(string sessionName = Session.DefaultName) public void RemoveSession(Session session) { this.InternalSessions.Remove(session); + this.OnDatasetChanged(); } /// @@ -145,20 +196,37 @@ public void Append(Dataset inputDataset) newSession.AddStorePartition(StreamReader.Create(p.StoreName, p.StorePath, p.StreamReaderTypeName), p.Name); } } + + this.OnDatasetChanged(); } /// - /// Saves this dataset to the specified file. + /// Saves this dataset. /// - /// The name of the file to save this dataset into. - /// Indicates whether to use full or relative store paths. - public void Save(string filename, bool useRelativePaths = true) + /// The filename that indicates the location to save the dataset. + /// Indicates whether to use full or relative store paths (optional, default is true). + public void SaveAs(string filename, bool useRelativePaths = true) { + this.Filename = filename; + this.UseRelativePaths = useRelativePaths; + this.Save(); + } + + /// + /// Saves this dataset. + /// + public void Save() + { + if (this.Filename == string.Empty) + { + throw new ArgumentException("filename to save the dataset must be set before save operation."); + } + var serializer = JsonSerializer.Create( new JsonSerializerSettings() { // pass the dataset filename in the context to allow relative store paths to be computed using the RelativePathConverter - Context = useRelativePaths ? new StreamingContext(StreamingContextStates.File, filename) : default, + Context = this.UseRelativePaths ? new StreamingContext(StreamingContextStates.File, this.Filename) : default, Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, @@ -166,9 +234,10 @@ public void Save(string filename, bool useRelativePaths = true) TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple, SerializationBinder = new SafeSerializationBinder(), }); - using var jsonFile = File.CreateText(filename); + using var jsonFile = File.CreateText(this.Filename); using var jsonWriter = new JsonTextWriter(jsonFile); serializer.Serialize(jsonWriter, this); + this.HasUnsavedChanges = false; } /// @@ -414,6 +483,25 @@ public void AddSessionsFromPsiStores(string path, string partitionName = null) } } + /// + /// Method called when structure of the dataset changed. + /// + public virtual void OnDatasetChanged() + { + if (this.AutoSave) + { + this.Save(); + } + else + { + this.HasUnsavedChanges = true; + } + + // raise the event. + EventHandler handler = this.DatasetChanged; + handler?.Invoke(this, EventArgs.Empty); + } + /// /// Adds a session to this dataset and updates its originating time interval. /// @@ -427,6 +515,7 @@ private void AddSession(Session session) } this.InternalSessions.Add(session); + this.OnDatasetChanged(); } [OnDeserialized] diff --git a/Sources/Data/Microsoft.Psi.Data/Session.cs b/Sources/Data/Microsoft.Psi.Data/Session.cs index e64b6216b..115dbe7ad 100644 --- a/Sources/Data/Microsoft.Psi.Data/Session.cs +++ b/Sources/Data/Microsoft.Psi.Data/Session.cs @@ -42,6 +42,11 @@ private Session() { } + /// + /// Event invoked when the structure of the session changed. + /// + public event EventHandler SessionChanged; + /// /// Gets the dataset that this session belongs to. /// @@ -64,6 +69,7 @@ public string Name } this.name = value; + this.OnSessionChanged(); } } @@ -293,6 +299,17 @@ await Task.Run( public void RemovePartition(IPartition partition) { this.InternalPartitions.Remove(partition); + this.OnSessionChanged(); + } + + /// + /// Method called when structure of the session changed. + /// + protected virtual void OnSessionChanged() + { + this.Dataset?.OnDatasetChanged(); + EventHandler handler = this.SessionChanged; + handler?.Invoke(this, EventArgs.Empty); } /// @@ -308,6 +325,7 @@ private void AddPartition(IPartition partition) } this.InternalPartitions.Add(partition); + this.OnSessionChanged(); } [OnDeserialized] diff --git a/Sources/Data/Test.Psi.Data/DatasetTests.cs b/Sources/Data/Test.Psi.Data/DatasetTests.cs index a930f5006..4af740841 100644 --- a/Sources/Data/Test.Psi.Data/DatasetTests.cs +++ b/Sources/Data/Test.Psi.Data/DatasetTests.cs @@ -227,7 +227,7 @@ public void DatasetAbsolutePaths() // save dataset with absolute store paths string datasetFile = Path.Combine(StorePath, "dataset.pds"); - dataset.Save(datasetFile, false); + dataset.SaveAs(datasetFile, false); // create Temp sub-folder var tempFolder = Path.Combine(StorePath, "Temp"); @@ -257,7 +257,7 @@ public void DatasetRelativePaths() // save dataset with relative store paths string datasetFile = Path.Combine(StorePath, "dataset.pds"); - dataset.Save(datasetFile, true); + dataset.SaveAs(datasetFile, true); // create Temp sub-folder var tempFolder = Path.Combine(StorePath, "Temp"); @@ -423,6 +423,118 @@ await session.CreateDerivedPsiPartitionAsync( } } + [TestMethod] + [Timeout(60000)] + public void DatasetAutoSave() + { + var datasetPath = Path.Join(StorePath, "autosave.pds"); + + var dataset = new Dataset("autosave", datasetPath, autoSave: true); + dataset.Name = "autosave-saved"; + GenerateTestStore("store1", StorePath); + + var session1 = dataset.CreateSession("test-session1"); + var session2 = dataset.AddSessionFromPsiStore("store1", StorePath); + session1.Name = "no-longer-test-session1"; + + // open the dataset file as a different dataset and validate information + var sameDataset = Dataset.Load(datasetPath); + Assert.AreEqual("autosave-saved", sameDataset.Name); + Assert.AreEqual(2, sameDataset.Sessions.Count); + Assert.AreEqual("no-longer-test-session1", sameDataset.Sessions[0].Name); + Assert.AreEqual(session2.Name, sameDataset.Sessions[1].Name); + + // remove a session and verify changes are saved. + dataset.RemoveSession(session1); + sameDataset = Dataset.Load(datasetPath); + Assert.AreEqual(1, sameDataset.Sessions.Count); + Assert.AreEqual(session2.Name, sameDataset.Sessions[0].Name); + Assert.AreEqual(1, sameDataset.Sessions[0].Partitions.Count); + Assert.AreEqual(session2.OriginatingTimeInterval.Left, sameDataset.Sessions[0].OriginatingTimeInterval.Left); + Assert.AreEqual(session2.OriginatingTimeInterval.Right, sameDataset.Sessions[0].OriginatingTimeInterval.Right); + + // now we edit the session and we want to make sure the changes stick! + GenerateTestStore("store3", StorePath); + session2.AddPsiStorePartition("store3", StorePath); + sameDataset = Dataset.Load(datasetPath); + Assert.AreEqual(session2.Name, sameDataset.Sessions[0].Name); + Assert.AreEqual(2, sameDataset.Sessions[0].Partitions.Count); + Assert.AreEqual("store3", sameDataset.Sessions[0].Partitions[1].Name); + } + + [TestMethod] + [Timeout(60000)] + public void DatasetAutoSaveAsync() + { + var datasetPath = Path.Join(StorePath, "autosave.pds"); + GenerateTestStore("base1", StorePath); + GenerateTestStore("base2", StorePath); + var dataset = new Dataset("autosave", datasetPath, autoSave: true); + dataset.AddSessionFromPsiStore("base1", StorePath, "s1"); + dataset.AddSessionFromPsiStore("base2", StorePath, "s2"); + Assert.AreEqual(1, dataset.Sessions[0].Partitions.Count()); + Assert.AreEqual(1, dataset.Sessions[1].Partitions.Count()); + Task.Run(async () => + { + await dataset.CreateDerivedPartitionAsync( + (_, importer, exporter) => + { + importer.OpenStream("Root").Select(x => x * x).Write("RootSquared", exporter); + }, + "derived", + true, + "derived-store"); + }).Wait(); // wait for the async function to finish + + // open the dataset file as a different dataset and validate information + var sameDataset = Dataset.Load(datasetPath); + Assert.AreEqual(2, sameDataset.Sessions.Count); + Assert.AreEqual(2, sameDataset.Sessions[0].Partitions.Count()); + Assert.AreEqual(2, sameDataset.Sessions[1].Partitions.Count()); + Assert.AreEqual("derived", sameDataset.Sessions[1].Partitions[1].Name); + Assert.AreEqual("derived-store", sameDataset.Sessions[1].Partitions[1].StoreName); + } + + [TestMethod] + [Timeout(60000)] + public void DatasetUnsavedChanges() + { + var dataset = new Dataset("autosave"); + dataset.CreateSession("test-session1"); + Assert.IsTrue(dataset.HasUnsavedChanges); + dataset.SaveAs("unsave.pds"); + Assert.IsTrue(!dataset.HasUnsavedChanges); + } + + [TestMethod] + [Timeout(60000)] + public void DatasetChangeEvent() + { + var sessionEventCalled = false; + var datasetEventCalled = false; + var dataset = new Dataset("autosave"); + + GenerateTestStore("base1", StorePath); + var session1 = dataset.AddSessionFromPsiStore("base1", StorePath, "session1"); + dataset.DatasetChanged += (s, e) => + { + Assert.AreEqual(e, EventArgs.Empty); + datasetEventCalled = true; + }; + session1.SessionChanged += (s, e) => + { + Assert.AreEqual(e, EventArgs.Empty); + sessionEventCalled = true; + }; + + // the following change should cause both events to be called. + session1.Name = "new name"; + + // validate if the events were called. + Assert.IsTrue(datasetEventCalled); + Assert.IsTrue(sessionEventCalled); + } + private static void GenerateTestStore(string storeName, string storePath) { using var p = Pipeline.Create(); diff --git a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindow.xaml b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindow.xaml index 55f88c453..3b3318130 100644 --- a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindow.xaml +++ b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindow.xaml @@ -65,7 +65,7 @@ - + @@ -83,7 +83,7 @@ - diff --git a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindowViewModel.cs b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindowViewModel.cs index 635e91dbd..23ab50db7 100644 --- a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindowViewModel.cs +++ b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/MainWindowViewModel.cs @@ -84,7 +84,7 @@ public class MainWindowViewModel : ObservableObject private RelayCommand jumpLeftCommand; private RelayCommand openStoreCommand; private RelayCommand openDatasetCommand; - private RelayCommand saveDatasetCommand; + private RelayCommand saveDatasetAsCommand; private RelayCommand insertTimelinePanelCommand; private RelayCommand insert1CellInstantPanelCommand; private RelayCommand insert2CellInstantPanelCommand; @@ -339,7 +339,7 @@ public RelayCommand OpenStoreCommand if (result == true) { string filename = dlg.FileName; - await VisualizationContext.Instance.OpenDatasetAsync(filename); + await VisualizationContext.Instance.OpenDatasetAsync(filename, autoSave: this.AppSettings.AutoSaveDatasets); this.EnsureDerivedStreamTreeNodesExist(); } }); @@ -373,7 +373,7 @@ public RelayCommand OpenDatasetCommand if (result == true) { string filename = dlg.FileName; - await VisualizationContext.Instance.OpenDatasetAsync(filename); + await VisualizationContext.Instance.OpenDatasetAsync(filename, autoSave: this.AppSettings.AutoSaveDatasets); this.EnsureDerivedStreamTreeNodesExist(); } }); @@ -388,13 +388,13 @@ public RelayCommand OpenDatasetCommand /// [Browsable(false)] [IgnoreDataMember] - public RelayCommand SaveDatasetCommand + public RelayCommand SaveDatasetAsCommand { get { - if (this.saveDatasetCommand == null) + if (this.saveDatasetAsCommand == null) { - this.saveDatasetCommand = new RelayCommand( + this.saveDatasetAsCommand = new RelayCommand( async () => { SaveFileDialog dlg = new SaveFileDialog @@ -409,12 +409,12 @@ public RelayCommand SaveDatasetCommand string filename = dlg.FileName; // this should be a relatively quick operation so no need to show progress - await VisualizationContext.Instance.DatasetViewModel.SaveAsync(filename); + await VisualizationContext.Instance.DatasetViewModel.SaveAsAsync(filename); } }); } - return this.saveDatasetCommand; + return this.saveDatasetAsCommand; } } diff --git a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/PsiStudioSettings.cs b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/PsiStudioSettings.cs index 2a4fd4061..d2b9fe4b4 100644 --- a/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/PsiStudioSettings.cs +++ b/Sources/Tools/PsiStudio/Microsoft.Psi.PsiStudio/PsiStudioSettings.cs @@ -38,6 +38,7 @@ public PsiStudioSettings() this.ShowTimingRelativeToSessionStart = false; this.ShowTimingRelativeToSelectionStart = false; this.CurrentLayoutName = null; + this.AutoSaveDatasets = false; this.AdditionalAssemblies = null; } @@ -101,6 +102,11 @@ public PsiStudioSettings() /// public bool ShowTimingRelativeToSelectionStart { get; set; } + /// + /// Gets or sets a value indicating whether to set any open dataset object into autosave mode. + /// + public bool AutoSaveDatasets { get; set; } + /// /// Gets or sets the list of add-in assemblies. /// diff --git a/Sources/Visualization/Microsoft.Psi.Visualization.Windows/ViewModels/DatasetViewModel.cs b/Sources/Visualization/Microsoft.Psi.Visualization.Windows/ViewModels/DatasetViewModel.cs index 1a796bbc3..0bd3b02c8 100644 --- a/Sources/Visualization/Microsoft.Psi.Visualization.Windows/ViewModels/DatasetViewModel.cs +++ b/Sources/Visualization/Microsoft.Psi.Visualization.Windows/ViewModels/DatasetViewModel.cs @@ -428,9 +428,10 @@ public AuxiliaryStreamInfo ShowAuxiliaryStreamInfo /// Loads a dataset from the specified file. /// /// The name of the file that contains the dataset to be loaded. + /// A value to indicate whether to enable the autosave feature. /// The newly loaded dataset view model. - public static DatasetViewModel Load(string filename) => - new DatasetViewModel(Dataset.Load(filename)) + public static DatasetViewModel Load(string filename, bool autoSave = false) => + new DatasetViewModel(Dataset.Load(filename, autoSave)) { FileName = filename, }; @@ -439,12 +440,13 @@ public static DatasetViewModel Load(string filename) => /// Asynchronously loads a dataset from the specified file. /// /// The name of the file that contains the dataset to be loaded. + /// A value to indicate whether to enable the dataset's autosave feature. /// /// A task that represents the asynchronous operation. The value of the TResult parameter /// contains the newly loaded dataset view model. /// - public static Task LoadAsync(string filename) => - Task.Run(() => Load(filename)); + public static Task LoadAsync(string filename, bool autoSave = false) => + Task.Run(() => Load(filename, autoSave)); /// /// Creates a new dataset from an existing data store. @@ -599,9 +601,9 @@ public void CreateSessionFromStore(string sessionName, IStreamReader streamReade /// Saves this dataset to the specified file. /// /// The name of the file to save this dataset into. - public void Save(string filename) + public void SaveAs(string filename) { - this.dataset.Save(filename); + this.dataset.SaveAs(filename); this.FileName = filename; } @@ -610,8 +612,8 @@ public void Save(string filename) /// /// The name of the file to save this dataset into. /// A task that represents the asynchronous operation. - public Task SaveAsync(string filename) => - Task.Run(() => this.Save(filename)); + public Task SaveAsAsync(string filename) => + Task.Run(() => this.SaveAs(filename)); /// /// Removes the specified session from the underlying dataset. diff --git a/Sources/Visualization/Microsoft.Psi.Visualization.Windows/VisualizationContext.cs b/Sources/Visualization/Microsoft.Psi.Visualization.Windows/VisualizationContext.cs index c694af1f1..9d3167e4d 100644 --- a/Sources/Visualization/Microsoft.Psi.Visualization.Windows/VisualizationContext.cs +++ b/Sources/Visualization/Microsoft.Psi.Visualization.Windows/VisualizationContext.cs @@ -203,7 +203,7 @@ public async Task RunDatasetBatchProcessingTaskAsync(DatasetViewModel datasetVie // if the dataset has a known associated file, save it. if (datasetViewModel.FileName != null) { - await datasetViewModel.SaveAsync(datasetViewModel.FileName); + await datasetViewModel.SaveAsAsync(datasetViewModel.FileName); } } catch (InvalidOperationException) @@ -263,7 +263,7 @@ public async Task RunSessionBatchProcessingTask(SessionViewModel sessionViewMode // if the dataset has a known associated file, save it. if (sessionViewModel.DatasetViewModel.FileName != null) { - await sessionViewModel.DatasetViewModel.SaveAsync(sessionViewModel.DatasetViewModel.FileName); + await sessionViewModel.DatasetViewModel.SaveAsAsync(sessionViewModel.DatasetViewModel.FileName); } } catch (InvalidOperationException) @@ -352,8 +352,9 @@ public void VisualizeStream(StreamTreeNode streamTreeNode, VisualizerMetadata vi /// /// Fully qualified path to dataset file. /// Indicates whether to show the status window. + /// Indicates whether to enable autosave. /// A task that represents the asynchronous operation. - public async Task OpenDatasetAsync(string filename, bool showStatusWindow = true) + public async Task OpenDatasetAsync(string filename, bool showStatusWindow = true, bool autoSave = false) { var loadDatasetTask = default(Task); if (showStatusWindow) @@ -374,7 +375,7 @@ public async Task OpenDatasetAsync(string filename, bool showStatusWindow = true }); // start the load dataset task - loadDatasetTask = this.LoadDatasetOrStoreAsync(filename, progress); + loadDatasetTask = this.LoadDatasetOrStoreAsync(filename, progress, autoSave); try { @@ -389,7 +390,7 @@ public async Task OpenDatasetAsync(string filename, bool showStatusWindow = true } else { - loadDatasetTask = this.LoadDatasetOrStoreAsync(filename); + loadDatasetTask = this.LoadDatasetOrStoreAsync(filename, autoSave: autoSave); } try @@ -473,7 +474,7 @@ public void DisplayObjectProperties(object requestingObject) this.RequestDisplayObjectProperties?.Invoke(this, new RequestDisplayObjectPropertiesEventArgs(requestingObject)); } - private async Task LoadDatasetOrStoreAsync(string filename, IProgress<(string, double)> progress = null) + private async Task LoadDatasetOrStoreAsync(string filename, IProgress<(string, double)> progress = null, bool autoSave = false) { try { @@ -481,7 +482,7 @@ private async Task LoadDatasetOrStoreAsync(string filename, IProgress<(string, d if (fileInfo.Extension == ".pds") { progress?.Report(("Loading dataset...", 0.5)); - this.DatasetViewModel = await DatasetViewModel.LoadAsync(filename); + this.DatasetViewModel = await DatasetViewModel.LoadAsync(filename, autoSave); } else {