diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs index f927d378..67fe55e0 100644 --- a/CompactGUI.Core/Compactor.cs +++ b/CompactGUI.Core/Compactor.cs @@ -1,5 +1,4 @@ - -using CompactGUI.Logging.Core; +using CompactGUI.Logging.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Win32.SafeHandles; @@ -25,11 +24,15 @@ public sealed class Compactor : ICompressor, IDisposable private long totalProcessedBytes = 0; private readonly SemaphoreSlim pauseSemaphore = new SemaphoreSlim(1, 2); private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + private readonly ConcurrentDictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); private ILogger _logger; private Analyser _analyser; + public IReadOnlyDictionary ProcessedFiles => processedFiles; + public int WorkItemCount { get; private set; } + public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger? logger = null) { workingDirectory = folderPath; @@ -51,15 +54,24 @@ private void InitializeCompressionInfoPointer() } - public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) { if(cancellationTokenSource.IsCancellationRequested) { return false; } CompactorLog.BuildingWorkingFilesList(_logger, workingDirectory); - var workingFiles = await BuildWorkingFilesList().ConfigureAwait(false); + var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); + WorkItemCount = workingFiles.Count; long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); totalProcessedBytes = 0; + processedFiles.Clear(); + + if (workingFiles.Count == 0) + { + progressMonitor?.Report(new CompressionProgress(100, string.Empty)); + CompactorLog.CompressionCompleted(_logger, 0); + return false; + } var sw = Stopwatch.StartNew(); @@ -101,8 +113,15 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca pauseSemaphore.Release(); var res = WOFCompressFile(file.FileName); + if (res == 0) + { + processedFiles.TryAdd(file.FileName, file.OriginalCompressionMode); + } Interlocked.Add(ref totalProcessedBytes, file.UncompressedSize); - progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName)); + var progressPercent = totalFilesSize <= 0 + ? 100 + : (int)((double)totalProcessedBytes / totalFilesSize * 100.0); + progressMonitor?.Report(new CompressionProgress(progressPercent, file.FileName)); } @@ -122,11 +141,19 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca } } - public async Task> BuildWorkingFilesList() + public async Task> BuildWorkingFilesList(IReadOnlyCollection? filesList = null) { + if (filesList is { Count: > 0 }) + { + return filesList + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(File.Exists) + .Select(file => new FileDetails(file, new FileInfo(file).Length, WOFCompressionAlgorithm.NO_COMPRESSION)) + .ToList(); + } + uint clusterSize = SharedMethods.GetClusterSize(workingDirectory); - var analysedFiles = await _analyser.GetAnalysedFilesAsync(cancellationTokenSource.Token) ?? []; return analysedFiles @@ -135,14 +162,13 @@ public async Task> BuildWorkingFilesList() && fl.UncompressedSize > clusterSize && ((fl.FileInfo != null && !excludedFileExtensions.Contains(fl.FileInfo.Extension)) || excludedFileExtensions.Contains(fl.FileName)) ) - .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize)) + .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize, fl.CompressionMode)) .ToList(); } - public void Pause() { CompactorLog.CompressionPaused(_logger); @@ -176,6 +202,6 @@ public void Dispose() } - public readonly record struct FileDetails(string FileName, long UncompressedSize); + public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode); -} \ No newline at end of file +} diff --git a/CompactGUI.Core/ICompressor.cs b/CompactGUI.Core/ICompressor.cs index 04054c96..fc4d4440 100644 --- a/CompactGUI.Core/ICompressor.cs +++ b/CompactGUI.Core/ICompressor.cs @@ -2,7 +2,7 @@ public interface ICompressor : IDisposable { - Task RunAsync(List filesList, + Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1); diff --git a/CompactGUI.Core/Uncompactor.cs b/CompactGUI.Core/Uncompactor.cs index 17f9a52a..28494457 100644 --- a/CompactGUI.Core/Uncompactor.cs +++ b/CompactGUI.Core/Uncompactor.cs @@ -23,8 +23,9 @@ public Uncompactor(ILogger? logger = null) _logger = logger ?? NullLogger.Instance; } - public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) { + filesList ??= []; int totalFiles = filesList.Count; if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount; ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = maxParallelism }; diff --git a/CompactGUI.Watcher/Watcher.vb b/CompactGUI.Watcher/Watcher.vb index 6d350614..ac448f56 100644 --- a/CompactGUI.Watcher/Watcher.vb +++ b/CompactGUI.Watcher/Watcher.vb @@ -1,4 +1,4 @@ -Imports System.Collections.ObjectModel +Imports System.Collections.ObjectModel Imports System.Collections.Specialized Imports System.Runtime Imports System.Text.Json @@ -288,7 +288,7 @@ Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipi existingItem.LastCheckedSize = analyser.CompressedBytes existingItem.LastUncompressedSize = analyser.UncompressedBytes existingItem.LastSystemModifiedDate = DateTime.Now - If isFreshlyCompressed AndAlso targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then + If targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then existingItem.CompressionLevel = targetCompressionLevel Else existingItem.CompressionLevel = WOFHelper.GetDominantCompressionMode(analysedFiles) diff --git a/CompactGUI/Application.xaml.vb b/CompactGUI/Application.xaml.vb index 4c17cc66..685951d9 100644 --- a/CompactGUI/Application.xaml.vb +++ b/CompactGUI/Application.xaml.vb @@ -74,6 +74,7 @@ Partial Public Class Application services.AddSingleton(Of INavigationService, NavigationService)() services.AddSingleton(Of CustomSnackBarService)() services.AddSingleton(Of IWindowService, WindowService)() + services.AddSingleton(Of CompressionResumeService)() services.AddSingleton(Of IUpdaterService, UpdaterService)() services.AddSingleton(Of IWikiService, WikiService)() diff --git a/CompactGUI/Components/Converters/IValueConverters.vb b/CompactGUI/Components/Converters/IValueConverters.vb index 01acbafc..135b6307 100644 --- a/CompactGUI/Components/Converters/IValueConverters.vb +++ b/CompactGUI/Components/Converters/IValueConverters.vb @@ -281,7 +281,7 @@ Public Class FolderStatusToColorConverter : Implements IValueConverter Select Case status Case ActionState.Idle Return New SolidColorBrush(ColorConverter.ConvertFromString("#92e7f1")) - Case ActionState.Analysing, ActionState.Working, ActionState.Paused + Case ActionState.Analysing, ActionState.Working, ActionState.Paused, ActionState.Undoing Return New SolidColorBrush(ColorConverter.ConvertFromString("#F1CE92")) Case ActionState.Results Return New SolidColorBrush(ColorConverter.ConvertFromString("#92F1AB")) @@ -305,6 +305,8 @@ Public Class FolderStatusToStringConverter : Implements IValueConverter Return LanguageHelper.GetString("Status_Analysing") Case ActionState.Working, ActionState.Paused Return LanguageHelper.GetString("Status_Working") + Case ActionState.Undoing + Return LanguageHelper.GetString("Status_Undoing") Case ActionState.Results Return LanguageHelper.GetString("Status_Compressed") Case Else @@ -406,7 +408,7 @@ Public Class FolderActionStateWorkingToVisibilityConverter Public Function Convert(value As Object, targetType As Type, parameter As Object, culture As CultureInfo) As Object Implements IValueConverter.Convert Dim status = CType(value, ActionState) Select Case status - Case ActionState.Working + Case ActionState.Working, ActionState.Undoing Return Visibility.Visible Case Else Return Visibility.Collapsed diff --git a/CompactGUI/Components/FolderActionStateTemplateSelector.vb b/CompactGUI/Components/FolderActionStateTemplateSelector.vb index b0283499..2395c54f 100644 --- a/CompactGUI/Components/FolderActionStateTemplateSelector.vb +++ b/CompactGUI/Components/FolderActionStateTemplateSelector.vb @@ -15,7 +15,7 @@ Return IdleTemplate Case ActionState.Analysing Return AnalysingTemplate - Case ActionState.Working, ActionState.Paused + Case ActionState.Working, ActionState.Paused, ActionState.Undoing Return CompressingTemplate Case ActionState.Results Return ResultsTemplate @@ -42,7 +42,7 @@ Public Class HomeViewStateTemplateSelector Return IdleTemplate Case ActionState.Analysing Return AnalysingTemplate - Case ActionState.Working, ActionState.Paused + Case ActionState.Working, ActionState.Paused, ActionState.Undoing Return CompressingTemplate Case ActionState.Results Return ResultsTemplate diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb index 815050f0..4e715792 100644 --- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb @@ -107,4 +107,5 @@ Public Enum ActionState Results Paused Waiting + Undoing End Enum \ No newline at end of file diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb new file mode 100644 index 00000000..12263b37 --- /dev/null +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -0,0 +1,28 @@ +' Decisions shown when stopping an active manual compression run. +Public Enum CompressionStopChoice + SaveProgress + UndoProgress + LeaveAsIs +End Enum + +' Decisions shown when a folder has a saved resumable compression session. +Public Enum CompressionResumeChoice + ResumeProgress + DiscardSavedProgress + Cancel +End Enum + +Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property HadWork As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class + +Public NotInheritable Class SavedCompressionSession + Public Property FolderPath As String = String.Empty + Public Property SelectedCompressionMode As Core.CompressionMode + Public Property SkipPoorlyCompressedFileTypes As Boolean + Public Property SkipUserSubmittedFiletypes As Boolean + Public Property WatchFolderForChanges As Boolean + Public Property SavedAt As DateTime = DateTime.Now +End Class diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb index 594c344c..eca472ce 100644 --- a/CompactGUI/Services/CompressableFolderService.vb +++ b/CompactGUI/Services/CompressableFolderService.vb @@ -1,3 +1,4 @@ +Imports System.Collections.Concurrent Imports System.Collections.ObjectModel Imports System.Threading @@ -15,56 +16,124 @@ Public Class CompressableFolderService Private Shared ReadOnly UncompactorLogger As ILogger = Application.GetService(Of ILogger(Of Uncompactor))() Private Shared ReadOnly AnalyserLogger As ILogger = Application.GetService(Of ILogger(Of Analyser))() + Private ReadOnly _resumeService As CompressionResumeService Private folderTokens As New Dictionary(Of CompressableFolder, CancellationTokenSource) + Private ReadOnly stopChoices As New ConcurrentDictionary(Of CompressableFolder, CompressionStopChoice) + + Public Sub New(resumeService As CompressionResumeService) + _resumeService = resumeService + End Sub - Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of Boolean) + Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of CompressionRunResult) folder.Compressor = New Compactor(folder.FolderName, WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode), GetSkipList(folder), folder.Analyser, CompactorLogger) Return Await RunCompressionAsync(folder, folder.Compressor, Nothing, True) - - End Function Public Async Function UncompressFolder(folder As CompressableFolder) As Task(Of Boolean) + _resumeService.RemoveSession(folder.FolderName) folder.Compressor = New Uncompactor(UncompactorLogger) Dim compressedFilesList = folder.AnalysisResults.Where(Function(rs) rs.CompressedSize < rs.UncompressedSize).Select(Of String)(Function(f) f.FileName).ToList - Return Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) + Dim result = Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) + Await AnalyseFolderAsync(folder) + Return result.Completed + End Function - End Function + Public Sub RequestCompressionStop(folder As CompressableFolder, choice As CompressionStopChoice) + If folder Is Nothing OrElse Not TypeOf folder.Compressor Is Compactor Then Return + If folder.FolderActionState <> ActionState.Working AndAlso folder.FolderActionState <> ActionState.Paused Then Return + + If choice = CompressionStopChoice.SaveProgress Then + _resumeService.SaveSession(folder) + Else + _resumeService.RemoveSession(folder.FolderName) + End If + + stopChoices(folder) = choice + + Try + folder.Compressor.Cancel() + Catch ex As ObjectDisposedException + 'The compression completed while the stop dialog was open. + End Try + End Sub - Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of Boolean) + Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of CompressionRunResult) folder.FolderActionState = ActionState.Working CancelEstimation(folder) Dim cts = New CancellationTokenSource() folderTokens(folder) = cts Dim progress As IProgress(Of CompressionProgress) = New Progress(Of CompressionProgress)(Sub(x) folder.CompressionProgress = x) + Dim runResult As New CompressionRunResult() progress.Report(New CompressionProgress(0, "")) - Dim res = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + Try + runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 + + If isCompressing Then + Dim stopChoice As CompressionStopChoice + If Not runResult.Completed AndAlso stopChoices.TryRemove(folder, stopChoice) Then + runResult.StopChoice = stopChoice + + If stopChoice = CompressionStopChoice.UndoProgress AndAlso TypeOf compressor Is Compactor Then + folder.FolderActionState = ActionState.Undoing + Await RestoreProcessedFilesAsync(folder, DirectCast(compressor, Compactor), progress) + End If + ElseIf runResult.Completed Then + stopChoices.TryRemove(folder, stopChoice) + _resumeService.RemoveSession(folder.FolderName) + End If + + folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork + Else + folder.FolderActionState = ActionState.Idle + folder.IsFreshlyCompressed = False + End If - If isCompressing Then - folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = res - Else - folder.FolderActionState = ActionState.Idle - folder.IsFreshlyCompressed = False - Await AnalyseFolderAsync(folder) - End If - compressor.Dispose() + Return runResult + Finally + compressor.Dispose() + + Dim ownedToken As CancellationTokenSource = Nothing + If folderTokens.TryGetValue(folder, ownedToken) AndAlso Object.ReferenceEquals(ownedToken, cts) Then + folderTokens.Remove(folder) + ownedToken.Dispose() + Else + cts.Dispose() + End If + End Try + End Function - folderTokens(folder).Dispose() - folderTokens.Remove(folder) + Private Async Function RestoreProcessedFilesAsync(folder As CompressableFolder, compactor As Compactor, progress As IProgress(Of CompressionProgress)) As Task + Dim processedFiles = compactor.ProcessedFiles.ToList() + If processedFiles.Count = 0 Then Return - Return res - End Function + Dim threadCount = GetThreadCount(folder) + For Each modeGroup In processedFiles.GroupBy(Function(item) item.Value) + Dim files = modeGroup.Select(Function(item) item.Key).ToList() + + Select Case modeGroup.Key + Case WOFCompressionAlgorithm.XPRESS4K, WOFCompressionAlgorithm.XPRESS8K, WOFCompressionAlgorithm.XPRESS16K, WOFCompressionAlgorithm.LZX + Using restorer As New Compactor(folder.FolderName, modeGroup.Key, Array.Empty(Of String), folder.Analyser, CompactorLogger) + Await restorer.RunAsync(files, progress, threadCount) + End Using + Case Else + Using uncompactor As New Uncompactor(UncompactorLogger) + Await uncompactor.RunAsync(files, progress, threadCount) + End Using + End Select + Next + End Function Public Async Function AnalyseFolderAsync(folder As CompressableFolder) As Task(Of Integer) diff --git a/CompactGUI/Services/CompressionResumeService.vb b/CompactGUI/Services/CompressionResumeService.vb new file mode 100644 index 00000000..f2ba4aae --- /dev/null +++ b/CompactGUI/Services/CompressionResumeService.vb @@ -0,0 +1,113 @@ +Imports System.Text.Json + +Imports CompactGUI.Core.Settings + +Imports Microsoft.Extensions.Logging + +Public NotInheritable Class CompressionResumeService + + Private ReadOnly _sessionsFile As IO.FileInfo + Private ReadOnly _logger As ILogger(Of CompressionResumeService) + Private ReadOnly _syncRoot As New Object + Private ReadOnly _jsonOptions As New JsonSerializerOptions With {.WriteIndented = True} + Private _sessions As Dictionary(Of String, SavedCompressionSession) + + Public Sub New(settingsService As ISettingsService, logger As ILogger(Of CompressionResumeService)) + _logger = logger + _sessionsFile = New IO.FileInfo(IO.Path.Combine(settingsService.DataFolder.FullName, "compression-resume.json")) + _sessions = LoadSessions() + End Sub + + Public Function TryGetSession(folderPath As String, ByRef session As SavedCompressionSession) As Boolean + Dim key = NormalizePath(folderPath) + + SyncLock _syncRoot + Dim stored As SavedCompressionSession = Nothing + If Not _sessions.TryGetValue(key, stored) Then Return False + + session = CloneSession(stored) + Return True + End SyncLock + End Function + + Public Sub SaveSession(folder As CompressableFolder) + Dim session = New SavedCompressionSession With { + .FolderPath = NormalizePath(folder.FolderName), + .SelectedCompressionMode = folder.CompressionOptions.SelectedCompressionMode, + .SkipPoorlyCompressedFileTypes = folder.CompressionOptions.SkipPoorlyCompressedFileTypes, + .SkipUserSubmittedFiletypes = folder.CompressionOptions.SkipUserSubmittedFiletypes, + .WatchFolderForChanges = folder.CompressionOptions.WatchFolderForChanges, + .SavedAt = DateTime.Now + } + + SyncLock _syncRoot + _sessions(session.FolderPath) = session + WriteSessions() + End SyncLock + End Sub + + Public Sub RemoveSession(folderPath As String) + Dim key = NormalizePath(folderPath) + + SyncLock _syncRoot + If _sessions.Remove(key) Then WriteSessions() + End SyncLock + End Sub + + Private Function LoadSessions() As Dictionary(Of String, SavedCompressionSession) + Dim loaded = New Dictionary(Of String, SavedCompressionSession)(StringComparer.OrdinalIgnoreCase) + + Try + If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() + If Not _sessionsFile.Exists Then Return loaded + + Dim json = IO.File.ReadAllText(_sessionsFile.FullName) + If String.IsNullOrWhiteSpace(json) Then Return loaded + + Dim entries = JsonSerializer.Deserialize(Of List(Of SavedCompressionSession))(json, _jsonOptions) + If entries Is Nothing Then Return loaded + + For Each entry In entries + If String.IsNullOrWhiteSpace(entry.FolderPath) Then Continue For + entry.FolderPath = NormalizePath(entry.FolderPath) + loaded(entry.FolderPath) = entry + Next + Catch ex As Exception + _logger.LogWarning(ex, "Could not load saved compression sessions from {SessionFile}", _sessionsFile.FullName) + End Try + + Return loaded + End Function + + Private Sub WriteSessions() + Try + If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() + Dim snapshot = _sessions.Values.OrderBy(Function(entry) entry.FolderPath, StringComparer.OrdinalIgnoreCase).ToList() + IO.File.WriteAllText(_sessionsFile.FullName, JsonSerializer.Serialize(snapshot, _jsonOptions)) + Catch ex As Exception + _logger.LogWarning(ex, "Could not save compression sessions to {SessionFile}", _sessionsFile.FullName) + End Try + End Sub + + Private Shared Function CloneSession(source As SavedCompressionSession) As SavedCompressionSession + Return New SavedCompressionSession With { + .FolderPath = source.FolderPath, + .SelectedCompressionMode = source.SelectedCompressionMode, + .SkipPoorlyCompressedFileTypes = source.SkipPoorlyCompressedFileTypes, + .SkipUserSubmittedFiletypes = source.SkipUserSubmittedFiletypes, + .WatchFolderForChanges = source.WatchFolderForChanges, + .SavedAt = source.SavedAt + } + End Function + + Private Shared Function NormalizePath(folderPath As String) As String + If String.IsNullOrWhiteSpace(folderPath) Then Return String.Empty + + Try + Return IO.Path.GetFullPath(folderPath).TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) + Catch ex As Exception + Return folderPath.TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) + End Try + End Function + +End Class diff --git a/CompactGUI/Services/WindowService.vb b/CompactGUI/Services/WindowService.vb index 764aefe4..bf098531 100644 --- a/CompactGUI/Services/WindowService.vb +++ b/CompactGUI/Services/WindowService.vb @@ -3,6 +3,8 @@ Sub MinimizeMainWindow() Sub HideMainWindow() Function ShowMessageBox(title As String, content As String) As Task(Of Boolean) + Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) + Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) End Interface @@ -39,4 +41,46 @@ Public Class WindowService Dim result = Await msgBox.ShowDialogAsync() Return result = Wpf.Ui.Controls.MessageBoxResult.Primary End Function + + Public Async Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) Implements IWindowService.ShowCompressionStopDialog + Dim msgBox = New Wpf.Ui.Controls.MessageBox With { + .Title = LanguageHelper.GetString("CompressionStop_Title"), + .Content = String.Format(LanguageHelper.GetString("CompressionStop_Message"), folderName), + .IsPrimaryButtonEnabled = True, + .IsSecondaryButtonEnabled = True, + .PrimaryButtonText = LanguageHelper.GetString("CompressionStop_SaveProgress"), + .SecondaryButtonText = LanguageHelper.GetString("CompressionStop_UndoProgress"), + .CloseButtonText = LanguageHelper.GetString("CompressionStop_LeaveAsIs") + } + + Select Case Await msgBox.ShowDialogAsync() + Case Wpf.Ui.Controls.MessageBoxResult.Primary + Return CompressionStopChoice.SaveProgress + Case Wpf.Ui.Controls.MessageBoxResult.Secondary + Return CompressionStopChoice.UndoProgress + Case Else + Return CompressionStopChoice.LeaveAsIs + End Select + End Function + + Public Async Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) Implements IWindowService.ShowResumeCompressionDialog + Dim msgBox = New Wpf.Ui.Controls.MessageBox With { + .Title = LanguageHelper.GetString("CompressionResume_Title"), + .Content = String.Format(LanguageHelper.GetString("CompressionResume_Message"), session.FolderPath, session.SelectedCompressionMode), + .IsPrimaryButtonEnabled = True, + .IsSecondaryButtonEnabled = True, + .PrimaryButtonText = LanguageHelper.GetString("CompressionResume_Resume"), + .SecondaryButtonText = LanguageHelper.GetString("CompressionResume_Discard"), + .CloseButtonText = LanguageHelper.GetString("UniCancel") + } + + Select Case Await msgBox.ShowDialogAsync() + Case Wpf.Ui.Controls.MessageBoxResult.Primary + Return CompressionResumeChoice.ResumeProgress + Case Wpf.Ui.Controls.MessageBoxResult.Secondary + Return CompressionResumeChoice.DiscardSavedProgress + Case Else + Return CompressionResumeChoice.Cancel + End Select + End Function End Class diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb index b4459eb8..46caa8a5 100644 --- a/CompactGUI/ViewModels/FolderViewModel.vb +++ b/CompactGUI/ViewModels/FolderViewModel.vb @@ -125,12 +125,20 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem End Sub Private Sub OnFolderPropertyChanged(sender As Object, e As PropertyChangedEventArgs) + Dim dispatcher = Application.Current?.Dispatcher + If dispatcher IsNot Nothing AndAlso Not dispatcher.CheckAccess() Then + dispatcher.BeginInvoke(Sub() OnFolderPropertyChanged(sender, e)) + Return + End If + If e.PropertyName = NameOf(Folder.FolderActionState) Then OnPropertyChanged(NameOf(IsAnalysing)) OnPropertyChanged(NameOf(IsNotResultsOrAnalysing)) OnPropertyChanged(NameOf(CompressionDisplayLevel)) OnPropertyChanged(NameOf(DisplayedFolderAfterSize)) OnPropertyChanged(NameOf(TotalFiles)) + PauseCommand.NotifyCanExecuteChanged() + CancelCommand.NotifyCanExecuteChanged() ElseIf e.PropertyName = NameOf(Folder.CompressionProgress) Then CompressionProgress = Folder.CompressionProgress.ProgressPercent @@ -178,10 +186,39 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem End If End Sub + Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + Private Function CanPause() As Boolean + Return CanControlCompression() + End Function + + Private Function CanCancel() As Boolean + Return CanControlCompression() + End Function + - Private Sub Cancel() - Folder.Compressor?.Cancel() - End Sub + Private Async Function Cancel() As Task + If Not TypeOf Folder.Compressor Is Core.Compactor Then + Folder.Compressor?.Cancel() + Return + End If + + If Folder.FolderActionState = ActionState.Working Then + Try + Folder.Compressor?.Pause() + Folder.FolderActionState = ActionState.Paused + Catch ex As OperationCanceledException + Return + Catch ex As ObjectDisposedException + Return + End Try + End If + + Dim choice = Await Application.GetService(Of IWindowService)().ShowCompressionStopDialog(Folder.DisplayName) + _compressableFolderService.RequestCompressionStop(Folder, choice) + End Function Private Async Function SubmitToWiki() As Task @@ -213,5 +250,4 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem -End Class - +End Class \ No newline at end of file diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index edc4a567..7827e8a3 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -1,6 +1,7 @@ Imports System.Collections.ObjectModel Imports System.Collections.Specialized Imports System.ComponentModel +Imports System.Runtime.ExceptionServices Imports CommunityToolkit.Mvvm.ComponentModel Imports CommunityToolkit.Mvvm.Input @@ -31,7 +32,6 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim value As FolderViewModel = Nothing Return If(_folderViewModels.TryGetValue(SelectedFolder, value), value, Nothing) - End Get End Property @@ -110,61 +110,75 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient HomeViewModelLog.AddingFolders(_logger, folderPaths) - Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray) + Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray()) Dim validFolders = folderPaths.Except(invalidFolders.InvalidFolders) + Dim foldersToResume As New List(Of CompressableFolder)() + Dim resumeService = Application.GetService(Of CompressionResumeService)() + Dim windowService = Application.GetService(Of IWindowService)() If invalidFolders.InvalidFolders.Count > 0 Then - 'TODO: Move this logger check to the snackbarService class? - HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(x) GetFolderVerificationMessage(x))) + HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(result) GetFolderVerificationMessage(result))) _snackbarService.ShowInvalidFoldersMessage(invalidFolders.InvalidFolders, invalidFolders.InvalidMessages) End If For Each folderName In validFolders + If Folders.Any(Function(folder) String.Equals(folder.FolderName, folderName, StringComparison.OrdinalIgnoreCase)) Then Continue For + + Dim savedSession As SavedCompressionSession = Nothing + Dim resumeChoice As CompressionResumeChoice? = Nothing + + If resumeService.TryGetSession(folderName, savedSession) Then + resumeChoice = Await windowService.ShowResumeCompressionDialog(savedSession) + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Cancel Then Continue For + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.DiscardSavedProgress Then + resumeService.RemoveSession(folderName) + savedSession = Nothing + End If + End If Dim newFolder As CompressableFolder = Await CompressableFolderFactory.CreateCompressableFolder(folderName) + If newFolder Is Nothing Then Continue For newFolder.CompressionOptions.WatchFolderForChanges = _settingsService.AppSettings.WatchFolderForChanges newFolder.CompressionOptions.SelectedCompressionMode = _settingsService.AppSettings.SelectedCompressionMode newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = _settingsService.AppSettings.SkipNonCompressable newFolder.CompressionOptions.SkipUserSubmittedFiletypes = _settingsService.AppSettings.SkipUserNonCompressable - If Not Folders.Any(Function(f) f.FolderName = newFolder.FolderName) Then - Folders.Add(newFolder) - Dim vm As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) - _folderViewModels.Add(newFolder, vm) - SelectedFolder = newFolder + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.ResumeProgress AndAlso savedSession IsNot Nothing Then + newFolder.CompressionOptions.SelectedCompressionMode = savedSession.SelectedCompressionMode + newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = savedSession.SkipPoorlyCompressedFileTypes + newFolder.CompressionOptions.SkipUserSubmittedFiletypes = savedSession.SkipUserSubmittedFiletypes + newFolder.CompressionOptions.WatchFolderForChanges = savedSession.WatchFolderForChanges End If - Dim res = Await _compressableFolderService.AnalyseFolderAsync(newFolder) - If TypeOf (newFolder) Is SteamFolder Then - Await CType(newFolder, SteamFolder).GetWikiResults() - Else - If _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then - HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) - Await _compressableFolderService.GetEstimatedCompression(newFolder) - End If + Folders.Add(newFolder) + Dim viewModel As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) + _folderViewModels.Add(newFolder, viewModel) + SelectedFolder = newFolder + Await _compressableFolderService.AnalyseFolderAsync(newFolder) + If TypeOf newFolder Is SteamFolder Then + Await CType(newFolder, SteamFolder).GetWikiResults() + ElseIf _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then + HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) + Await _compressableFolderService.GetEstimatedCompression(newFolder) End If - If _watcher.WatchedFolders.Any(Function(w) w.Folder = newFolder.FolderName) Then - Dim watchedFolder = _watcher.WatchedFolders.First(Function(w) w.Folder = newFolder.FolderName) + If _watcher.WatchedFolders.Any(Function(watched) watched.Folder = newFolder.FolderName) Then + Dim watchedFolder = _watcher.WatchedFolders.First(Function(watched) watched.Folder = newFolder.FolderName) newFolder.CompressionOptions.WatchFolderForChanges = True - If watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.ResumeProgress) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then newFolder.CompressionOptions.SelectedCompressionMode = Core.WOFHelper.CompressionModeFromWOFMode(watchedFolder.CompressionLevel) End If - End If - - + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.ResumeProgress Then foldersToResume.Add(newFolder) Next - + If foldersToResume.Count > 0 Then Await CompressFoldersAsync(foldersToResume) End Function - - Public Sub RemoveFolder(folder As CompressableFolder) If Not CanRemoveFolder() Then @@ -204,7 +218,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim retState As ActionState - If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused) Then + If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused OrElse f.FolderActionState = ActionState.Undoing) Then retState = ActionState.Working ElseIf Folders.Any(Function(f) f.FolderActionState = ActionState.Analysing) Then retState = ActionState.Analysing @@ -230,48 +244,72 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Private Async Function CompressAll() As Task + Dim foldersToCompress = Folders.Where(Function(folder) folder.FolderActionState = ActionState.Idle).ToList() + Await CompressFoldersAsync(foldersToCompress) + End Function - Await _watcher.DisableBackgrounding() + Private Async Function CompressFoldersAsync(foldersToCompress As IReadOnlyCollection(Of CompressableFolder)) As Task + If foldersToCompress Is Nothing OrElse foldersToCompress.Count = 0 Then Return + Await _watcher.DisableBackgrounding() Compressing = True Core.SharedMethods.PreventSleep() - Dim tasks As New List(Of Task)() - Dim foldersToCompress = Folders.Where(Function(f) f.FolderActionState = ActionState.Idle).ToList HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) - For Each folder In foldersToCompress - If folder.FolderActionState = ActionState.Idle Then - Await Task.Run(Async Function() - HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) - Dim ret = Await _compressableFolderService.CompressFolder(folder) - Dim analysis = Await _compressableFolderService.AnalyseFolderAsync(folder) - If _settingsService.AppSettings.ShowNotifications Then + Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim foldersWithCompressionWork As New HashSet(Of CompressableFolder)() + Dim capturedException As Exception = Nothing - Application.GetService(Of TrayNotifierService).Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + Try + For Each folder In foldersToCompress + If folder.FolderActionState = ActionState.Analysing OrElse folder.FolderActionState = ActionState.Working OrElse folder.FolderActionState = ActionState.Paused OrElse folder.FolderActionState = ActionState.Undoing Then Continue For - End If + Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) + Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) - Dim targetCompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, ret, targetCompressionLevel:=targetCompressionLevel) + 'Keep the orchestration on the WPF synchronization context. The compressor performs + 'its own asynchronous file work, while folder state and commands remain UI-thread-owned. + HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) + Dim runResult = Await _compressableFolderService.CompressFolder(folder) + If Not runResult.HadWork Then Continue For - 'For Each poorext In folder.PoorlyCompressedFiles - ' Debug.WriteLine($"{poorext.extension} : {poorext.totalFiles} with ratio of {poorext.cRatio}") - 'Next + foldersWithCompressionWork.Add(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) - Return True - End Function) - End If - Next - Compressing = False + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If - For Each folder In Folders.Where(Function(f) f.CompressionOptions.WatchFolderForChanges) - Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed - AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch) - Next + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + Next + + For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) + Dim isAlreadyWatched = _watcher.WatchedFolders.Any(Function(watched) String.Equals(watched.Folder, folder.FolderName, StringComparison.OrdinalIgnoreCase)) + If isAlreadyWatched AndAlso Not foldersWithCompressionWork.Contains(folder) Then Continue For + + Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed + Dim targetOverride = Core.WOFCompressionAlgorithm.NO_COMPRESSION + watcherTargetOverrides.TryGetValue(folder, targetOverride) + AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch, targetOverride) + Next + Catch ex As Exception + capturedException = ex + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + End Try - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + ExceptionDispatchInfo.Capture(capturedException).Throw() + End If End Function @@ -280,7 +318,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient End Function - Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean) + Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean, Optional targetOverride As Core.WOFCompressionAlgorithm = Core.WOFCompressionAlgorithm.NO_COMPRESSION) HomeViewModelLog.AddingFolderToWatcher(_logger, folder.FolderName) Dim newWatched = New Watcher.WatchedFolder(folder.FolderName, folder.DisplayName) @@ -292,7 +330,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient newWatched.LastCheckedSize = folder.CompressedBytes newWatched.LastSystemModifiedDate = DateTime.Now Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(w) w.Folder = folder.FolderName) - If wasCompressedInCurrentBatch Then + If targetOverride <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + newWatched.CompressionLevel = targetOverride + ElseIf wasCompressedInCurrentBatch Then newWatched.CompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) ElseIf existingWatched IsNot Nothing Then newWatched.CompressionLevel = existingWatched.CompressionLevel @@ -309,4 +349,4 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Application.GetService(Of CustomSnackBarService).ShowAddedToQueue() Await AddFoldersAsync({message.Value}) End Sub -End Class +End Class \ No newline at end of file diff --git a/CompactGUI/i18n/i18n.resx b/CompactGUI/i18n/i18n.resx index f410aaa2..3725d7be 100644 --- a/CompactGUI/i18n/i18n.resx +++ b/CompactGUI/i18n/i18n.resx @@ -578,4 +578,34 @@ Are you sure you want to exit? Mode + + Stop compression? + + + Choose what CompactGUI should do with the work already completed for {0}. Save progress keeps the files and remembers these settings for later. Undo progress restores only files changed during this run. Leave as is keeps the files but does not save a resume session. + + + Save progress + + + Undo progress + + + Leave as is + + + Resume saved compression? + + + CompactGUI saved a {1} compression session for {0}. Resume the remaining files now? + + + Resume + + + Discard saved progress + + + Undoing compression + \ No newline at end of file