diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d36e6491fc7..67e1899c5d2c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,7 @@ + diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs index 0bff8f7ce6f3..ecff3a898fed 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs @@ -43,6 +43,11 @@ public override async Task ExecuteAsync(object? parameter = null) var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); + Encoding? detectedEncoding = null; + if (isArchiveEncodingUndetermined) + { + detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path)); + } var password = string.Empty; Encoding? encoding = null; @@ -51,7 +56,8 @@ public override async Task ExecuteAsync(object? parameter = null) { IsArchiveEncrypted = isArchiveEncrypted, IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, - ShowPathSelection = true + ShowPathSelection = true, + DetectedEncoding = detectedEncoding, }; decompressArchiveDialog.ViewModel = decompressArchiveViewModel; diff --git a/src/Files.App/Data/Contracts/IStorageArchiveService.cs b/src/Files.App/Data/Contracts/IStorageArchiveService.cs index 27487eb1763c..ee0ee550bfdc 100644 --- a/src/Files.App/Data/Contracts/IStorageArchiveService.cs +++ b/src/Files.App/Data/Contracts/IStorageArchiveService.cs @@ -59,10 +59,17 @@ public interface IStorageArchiveService /// /// Gets the value that indicates whether the archive file's encoding is undetermined. /// - /// The archive file path to check if the item is encrypted. + /// The archive file path to check if the encoding is undetermined. /// True if the archive file's encoding is undetermined; otherwise, false. Task IsEncodingUndeterminedAsync(string archiveFilePath); + /// + /// Detect encoding for a zip file whose encoding is undetermined. + /// + /// The archive file path to detect encoding + /// Null if the archive file doesn't need to detect encoding or its encoding can't be detected; otherwise, the encoding detected. + Task DetectEncodingAsync(string archiveFilePath); + /// /// Gets the instance from the archive file path. /// diff --git a/src/Files.App/Data/Items/EncodingItem.cs b/src/Files.App/Data/Items/EncodingItem.cs index c342c64fc9c8..24c79bc35bf2 100644 --- a/src/Files.App/Data/Items/EncodingItem.cs +++ b/src/Files.App/Data/Items/EncodingItem.cs @@ -22,7 +22,7 @@ public sealed class EncodingItem /// Initializes a new instance of the class. /// /// The code of the language. - public EncodingItem(string code) + public EncodingItem(string? code) { if (string.IsNullOrEmpty(code)) { @@ -36,6 +36,45 @@ public EncodingItem(string code) } } - public override string ToString() => Name; + public EncodingItem(Encoding encoding, string name) + { + Encoding = encoding; + Name = name; + } + + public static EncodingItem[] Defaults = new string?[] { + null,//System Default + "UTF-8", + + //All possible Windows system encodings + //reference: https://en.wikipedia.org/wiki/Windows_code_page + //East Asian + "shift_jis", //Japanese + "gb2312", //Simplified Chinese + "big5", //Traditional Chinese + "ks_c_5601-1987", //Korean + + //Southeast Asian + "Windows-1258", //Vietnamese + "Windows-874", //Thai + + //Middle East + "Windows-1256", //Arabic + "Windows-1255", //Hebrew + "Windows-1254", //Turkish + + //European + "Windows-1252", //Western European + "Windows-1250", //Central European + "Windows-1251", //Cyrillic + "Windows-1253", //Greek + "Windows-1257", //Baltic + + "macintosh", + } + .Select(x => new EncodingItem(x)) + .ToArray(); + + public override string ToString() => Name; } } diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 78196a615a92..6ed8fc5d9166 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -95,6 +95,7 @@ + diff --git a/src/Files.App/Services/Storage/StorageArchiveService.cs b/src/Files.App/Services/Storage/StorageArchiveService.cs index 1b30e1adaddf..b1ef04a72a87 100644 --- a/src/Files.App/Services/Storage/StorageArchiveService.cs +++ b/src/Files.App/Services/Storage/StorageArchiveService.cs @@ -6,8 +6,8 @@ using ICSharpCode.SharpZipLib.Zip; using SevenZip; using System.IO; -using System.Linq; using System.Text; +using UtfUnknown; using Windows.Storage; using Windows.Win32; @@ -90,7 +90,8 @@ public async Task CompressAsync(ICompressArchiveModel compressionModel) /// public Task DecompressAsync(string archiveFilePath, string destinationFolderPath, string password = "", Encoding? encoding = null) { - if(encoding == null){ + if (encoding == null) + { return DecompressAsyncWithSevenZip(archiveFilePath, destinationFolderPath, password); } else @@ -203,10 +204,10 @@ async Task DecompressAsyncWithSharpZipLib(string archiveFilePath, string d string.IsNullOrEmpty(destinationFolderPath)) return false; using var zipFile = new ZipFile(archiveFilePath, StringCodec.FromEncoding(encoding)); - if(zipFile is null) + if (zipFile is null) return false; - - if(!string.IsNullOrEmpty(password)) + + if (!string.IsNullOrEmpty(password)) zipFile.Password = password; // Initialize a new in-progress status card @@ -214,11 +215,11 @@ async Task DecompressAsyncWithSharpZipLib(string archiveFilePath, string d archiveFilePath.CreateEnumerable(), destinationFolderPath.CreateEnumerable(), ReturnResult.InProgress); - + // Check if the decompress operation canceled if (statusCard.CancellationToken.IsCancellationRequested) return false; - + StatusCenterItemProgressModel fsProgress = new( statusCard.ProgressEventSource, enumerationCompleted: true, @@ -233,51 +234,52 @@ async Task DecompressAsyncWithSharpZipLib(string archiveFilePath, string d { long processedBytes = 0; int processedFiles = 0; - - foreach (ZipEntry zipEntry in zipFile) + await Task.Run(async () => { - if (statusCard.CancellationToken.IsCancellationRequested) + foreach (ZipEntry zipEntry in zipFile) { - isSuccess = false; - break; - } - - if (!zipEntry.IsFile) - { - continue; // Ignore directories - } + if (statusCard.CancellationToken.IsCancellationRequested) + { + isSuccess = false; + break; + } - string entryFileName = zipEntry.Name; - string fullZipToPath = Path.Combine(destinationFolderPath, entryFileName); - string directoryName = Path.GetDirectoryName(fullZipToPath); + if (!zipEntry.IsFile) + { + continue; // Ignore directories + } - if (!Directory.Exists(directoryName)) - { - Directory.CreateDirectory(directoryName); - } + string entryFileName = zipEntry.Name; + string fullZipToPath = Path.Combine(destinationFolderPath, entryFileName); + string directoryName = Path.GetDirectoryName(fullZipToPath); - byte[] buffer = new byte[4096]; // 4K is a good default - using (Stream zipStream = zipFile.GetInputStream(zipEntry)) - using (FileStream streamWriter = File.Create(fullZipToPath)) - { - await ThreadingService.ExecuteOnUiThreadAsync(() => + if (!Directory.Exists(directoryName)) { - fsProgress.FileName = entryFileName; - fsProgress.Report(); - }); + Directory.CreateDirectory(directoryName); + } - StreamUtils.Copy(zipStream, streamWriter, buffer); - } - processedBytes += zipEntry.Size; - if (fsProgress.TotalSize > 0) - { - fsProgress.Report(processedBytes / (double)fsProgress.TotalSize * 100); + byte[] buffer = new byte[4096]; // 4K is a good default + using (Stream zipStream = zipFile.GetInputStream(zipEntry)) + using (FileStream streamWriter = File.Create(fullZipToPath)) + { + await ThreadingService.ExecuteOnUiThreadAsync(() => + { + fsProgress.FileName = entryFileName; + fsProgress.Report(); + }); + + StreamUtils.Copy(zipStream, streamWriter, buffer); + } + processedBytes += zipEntry.Size; + if (fsProgress.TotalSize > 0) + { + fsProgress.Report(processedBytes / (double)fsProgress.TotalSize * 100); + } + processedFiles++; + fsProgress.AddProcessedItemsCount(1); + fsProgress.Report(); } - processedFiles++; - fsProgress.AddProcessedItemsCount(1); - fsProgress.Report(); - } - + }); if (!statusCard.CancellationToken.IsCancellationRequested) { isSuccess = true; @@ -321,7 +323,7 @@ await ThreadingService.ExecuteOnUiThreadAsync(() => return isSuccess; } - + /// public string GenerateArchiveNameFromItems(IReadOnlyList items) { @@ -355,7 +357,7 @@ public async Task IsEncodingUndeterminedAsync(string archiveFilePath) { using (ZipFile zipFile = new ZipFile(archiveFilePath)) { - return !zipFile.Cast().All(entry=>entry.IsUnicodeText); + return !zipFile.Cast().All(entry => entry.IsUnicodeText); } } catch (Exception ex) @@ -365,6 +367,42 @@ public async Task IsEncodingUndeterminedAsync(string archiveFilePath) } } + public async Task DetectEncodingAsync(string archiveFilePath) + { + //Temporarily using cp437 to decode zip file + //because SharpZipLib requires an encoding when decoding + //and cp437 contains all bytes as character + //which means that we can store any byte array as cp437 string losslessly + var cp437 = Encoding.GetEncoding(437); + try + { + using (ZipFile zipFile = new ZipFile(archiveFilePath, StringCodec.FromEncoding(cp437))) + { + var fileNameBytes = cp437.GetBytes( + String.Join("\n", + zipFile.Cast() + .Where(e => !e.IsUnicodeText) + .Select(e => e.Name) + ) + ); + var detectionResult = CharsetDetector.DetectFromBytes(fileNameBytes); + if (detectionResult.Detected != null && detectionResult.Detected.Confidence > 0.5) + { + return detectionResult.Detected.Encoding; + } + else + { + return null; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"SharpZipLib error: {ex.Message}"); + return null; + } + } + /// public async Task GetSevenZipExtractorAsync(string archiveFilePath, string password = "") { diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index c829bb40622f..741f191eb73b 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -2099,6 +2099,9 @@ Encoding + + {0} (detected) + Path diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs index b1ff52bc2b35..63ea6b6ca442 100644 --- a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs @@ -44,6 +44,16 @@ public bool IsArchiveEncodingUndetermined set => SetProperty(ref isArchiveEncodingUndetermined, value); } + private Encoding? detectedEncoding; + public Encoding? DetectedEncoding + { + get => detectedEncoding; + set { + SetProperty(ref detectedEncoding, value); + RefreshEncodingOptions(); + } + } + private bool showPathSelection; public bool ShowPathSelection { @@ -53,19 +63,27 @@ public bool ShowPathSelection public DisposableArray? Password { get; private set; } - public EncodingItem[] EncodingOptions { get; set; } = new string?[] { - null,//System Default - "UTF-8", - "shift_jis", - "gb2312", - "big5", - "ks_c_5601-1987", - "Windows-1252", - "macintosh", - } - .Select(x=>new EncodingItem(x)) - .ToArray(); + public EncodingItem[] EncodingOptions { get; set; } = EncodingItem.Defaults; public EncodingItem SelectedEncoding { get; set; } + void RefreshEncodingOptions() + { + if (detectedEncoding != null) + { + EncodingOptions = EncodingItem.Defaults + .Prepend(new EncodingItem( + detectedEncoding, + string.Format(Strings.EncodingDetected.GetLocalizedResource(), detectedEncoding.EncodingName) + )) + .ToArray(); + } + else + { + EncodingOptions = EncodingItem.Defaults; + } + SelectedEncoding = EncodingOptions.FirstOrDefault(); + } + + public IRelayCommand PrimaryButtonClickCommand { get; private set; }