Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,13 @@ minutes, Ghostscript 5, Pandoc 2 and calibre 10 (it made the 640-page novel's PD

## Archives

Folders can be packed directly as ZIP, 7Z, TAR, TAR.GZ, TAR.BZ2 or TAR.XZ. The Folders donut offers the existing
ZIP preset (one archive per folder) and One ZIP (one archive containing all selected files/folders), alongside ISO.
Other container formats can be added from the palette; single-file GZ/BZ2/XZ are not offered for folders.
Folder contents are stored under the selected folder's name, retaining Unicode names and empty directories.
7Z stages directory inputs through the same link-skipping copy used for renamed items, so nested symbolic links
and junctions cannot add files outside the selected tree. Custom folder donut layouts are preserved on upgrade.

The archive engine (`Archives/ArchiveConverter.cs`, id `archive`) uses Filee's own copy of the 7-Zip console program
(`7z.exe` + `7z.dll`, taken from the official x64 MSI with `msiexec /a` by `build/fetch-engines.ps1 -Only 7zip` and
bundled with the installer), never a 7-Zip installed on the PC. It runs as a separate process (LGPL).
Expand Down
5 changes: 2 additions & 3 deletions src/Filee.App/Services/RadialController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -630,11 +630,10 @@ private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.
}

/// <summary>
/// What <paramref name="preset"/> converts of <paramref name="files"/>: folders only go into "Compress into one
/// archive"; any other preset converts the files beside them (issue #186).
/// Keeps folders for direct folder actions and combined archives; other presets convert only the files beside them.
/// </summary>
internal static IReadOnlyList<string> SourcesFor(Preset preset, IReadOnlyList<string> files) =>
preset.PacksIntoOneArchive || preset.IsCopyToFolder || preset.TargetFormat == "iso" ? files : [.. files.Where(path => !Directory.Exists(path))];
preset.PacksIntoOneArchive || BuiltInData.AcceptsFolders(preset) ? files : [.. files.Where(path => !Directory.Exists(path))];

// ───────── Progress ring ─────────

Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Cli/Cli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private static async Task<int> ConvertAsync(CliOptions options, CliHost host, Te

var missing = new List<string>();
var files = ExpandInputs(options.Inputs, options.Recursive, missing,
foldersAsInputs: preset.TargetFormat == "iso" || preset.PacksIntoOneArchive);
foldersAsInputs: BuiltInData.AcceptsFolders(preset) || preset.PacksIntoOneArchive);
foreach (var path in missing)
errors.WriteLine($"filee: not found: {path}");
if (missing.Count > 0)
Expand Down
4 changes: 2 additions & 2 deletions src/Filee.Core/Presets/BuiltInData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public static class BuiltInData
public const string SameAsSource = "*";

/// <summary>Whether a conversion target accepts a folder directly.</summary>
public static bool SupportsFolderTarget(string target) => target == "iso";
public static bool SupportsFolderTarget(string target) => target is "iso" or "zip" or "7z" or "tar" or "tgz" or "tbz2" or "txz";

/// <summary>Whether a preset accepts folders without converting their individual files.</summary>
public static bool AcceptsFolders(Preset preset) => !preset.IsGroup
Expand Down Expand Up @@ -769,7 +769,7 @@ public static List<ToolbarProfile> CreateProfiles()
Id = "folders",
NameKey = "profile.folders",
Extensions = [],
PresetIds = ["to-iso"],
PresetIds = ["to-iso", "to-zip", "zip-all"],
BackPresetIds = [],
};
profiles.Insert(profiles.FindIndex(p => p.Id == "archives") + 1, folders);
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable<TriggerGesture> gest
public sealed class AppSettings
{
/// <summary>Current schema version. Increase when the format changes and add a migration.</summary>
public const int CurrentSchemaVersion = 78;
public const int CurrentSchemaVersion = 79;

public int SchemaVersion { get; set; } = CurrentSchemaVersion;

Expand Down
7 changes: 7 additions & 0 deletions src/Filee.Core/Settings/UserDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,13 @@ public static bool ApplyToLibrary(int from, List<Preset> presets, List<ToolbarPr
// claims stay put. (Schema 78 may be renumbered if another change takes it first.)
if (from < 78)
changed |= LearnExtensions(profiles, out _, ["exe", "dll"]);
// v79: restore folder compression (#1345) on the unchanged ISO-only default, preserving custom layouts.
if (from < 79 && profiles.FirstOrDefault(p => p.Id == "folders") is { } folders
&& folders.PresetIds.SequenceEqual(new[] { "to-iso" }) && (folders.BackPresetIds?.Count ?? 0) == 0)
{
foreach (var id in new[] { "to-zip", "zip-all" })
if (presets.Any(p => p.Id == id)) { folders.PresetIds.Add(id); changed = true; }
}
return changed;
}

Expand Down
16 changes: 16 additions & 0 deletions src/Filee.Engines/Archives/ArchiveConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using Filee.Core.Conversion;
using Filee.Core.Formats;
using Filee.Core.Platform;
using Filee.Core.Presets;
using Filee.Engines.Infrastructure;

Expand Down Expand Up @@ -37,6 +38,8 @@ .. from source in SingleFileSources
from target in ArchivePacker.SingleFileFormats
select new ConversionEdge(source, target),
.. ArchiveExtractor.ReadableFormats.Select(source => new ConversionEdge(source, "iso", DirectOnly: true)),
.. new[] { "zip", "7z", "tar", "tgz", "tbz2", "txz" }
.Select(target => new ConversionEdge(FormatRegistry.Folder, target, DirectOnly: true)),
];

public EngineStatus GetStatus()
Expand All @@ -47,6 +50,19 @@ public EngineStatus GetStatus()

public async Task<IReadOnlyList<string>> ConvertAsync(ConversionStep step, IProgress<double>? progress, CancellationToken cancellationToken)
{
if (step.From == FormatRegistry.Folder)
{
var folder = new DirectoryInfo(Path.GetFullPath(Path.TrimEndingDirectorySeparator(step.InputPath)));
if (!folder.Exists) throw new DirectoryNotFoundException(folder.FullName);
if (folder.Parent is null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new ConversionRefusedException("error.unsupported_source", folder.FullName);
var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
if (output is null) return [];
var mark = MarkOfTheWeb.ReadFirst([folder.FullName], cancellationToken: cancellationToken);
await CombineAsync([folder.FullName], output, step.To, step.Preset, step.WorkDirectory, progress, cancellationToken);
if (mark is not null) MarkOfTheWeb.TryWrite(output, mark);
return [output];
}
var sevenZip = _sevenZip ??= SevenZip.Locate();
var extractor = new ArchiveExtractor(sevenZip);
if (step.To == FormatRegistry.Folder)
Expand Down
7 changes: 6 additions & 1 deletion src/Filee.Engines/Archives/ArchivePacker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ private static async Task<IReadOnlyList<string>> NamedAsAsync(IReadOnlyList<Pack
var paths = new List<string>();
foreach (var item in items)
{
if (string.Equals(Path.GetFileName(item.SourcePath), item.EntryName, StringComparison.Ordinal))
// 7-Zip follows junctions inside directories. Always stage folders through the link-skipping copy.
if (!Directory.Exists(item.SourcePath) && string.Equals(Path.GetFileName(item.SourcePath), item.EntryName, StringComparison.Ordinal))
{
paths.Add(item.SourcePath);
continue;
Expand Down Expand Up @@ -133,9 +134,13 @@ private static async Task CopyFileAsync(string source, string target, Cancellati
/// <summary>Copies a folder tree without following links.</summary>
private static async Task CopyFolderAsync(DirectoryInfo source, string target, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (source.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new IOException($"Cannot pack a linked folder: {source.FullName}");
Directory.CreateDirectory(target);
foreach (var info in source.EnumerateFileSystemInfos())
{
cancellationToken.ThrowIfCancellationRequested();
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
continue;
var destination = Path.Combine(target, info.Name);
Expand Down
45 changes: 45 additions & 0 deletions tests/Filee.App.Tests/FolderArchives1345Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Avalonia.Headless.XUnit;
using Filee.App.Services;
using Filee.App.ViewModels;
using Filee.App.ViewModels.Pages;
using Filee.Core.Localization;
using Filee.Core.Presets;
using Filee.Core.Settings;

namespace Filee.App.Tests;

public sealed class FolderArchives1345Tests
{
[AvaloniaFact]
public void Folder_wheel_and_editor_offer_zip_and_one_zip()
{
TestServices.EnsureInitialized("zh-TW");
var root = Directory.CreateTempSubdirectory("filee-folder-1345-ui-").FullName;
try
{
var folder = Directory.CreateDirectory(Path.Combine(root, "photos.jpg")).FullName;
var store = new UserDataStore(Path.Combine(root, "settings"));
store.Load();
var loc = AppHost.Get<ILocalizer>();
var radial = new RadialViewModel(store, loc, AppHost.Get<PresetAvailability>());
radial.Load([folder], clickMode: false);
Assert.Equal(["to-iso", "to-zip", "zip-all"], radial.Items.Select(item => item.Id));
var archiveAvailable = new Filee.Engines.Archives.ArchiveConverter().GetStatus().IsAvailable;
foreach (var id in new[] { "to-zip", "zip-all" })
{
var preset = store.Presets.Single(p => p.Id == id);
var item = radial.Items.Single(i => i.Id == id);
Assert.Equal(id == "zip-all" || archiveAvailable, item.IsEnabled);
Assert.Equal([folder], RadialController.SourcesFor(preset, [folder]));
var editor = new PresetEditorViewModel(preset, loc, folderInput: true);
Assert.Equal("zip", editor.Target.Value);
Assert.True(editor.ShowArchive);
Assert.False(editor.ShowReplaceOriginal);
Assert.Equal(id == "zip-all", editor.CombineIntoOne);
Assert.DoesNotContain(editor.Targets, target => target.Value is "gz" or "bz2" or "xz" or "png");
Assert.Contains(editor.Targets, target => target.Value == "7z");
}
}
finally { Directory.Delete(root, true); }
}
}
15 changes: 8 additions & 7 deletions tests/Filee.App.Tests/FolderIso632Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ public async Task Folder_toolbar_passes_folder_context_to_new_and_existing_prese
var dialog = Assert.Single(windows.Main!.OwnedWindows.OfType<PresetEditorWindow>());
var editor = Assert.IsType<PresetEditorViewModel>(dialog.DataContext);
Assert.True(editor.FolderInput);
Assert.Single(editor.Targets);
Assert.Equal(7, editor.Targets.Count);
dialog.Close();
await editing.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
Expand All @@ -185,13 +185,14 @@ public async Task Folder_toolbar_passes_folder_context_to_new_and_existing_prese
}

[AvaloniaFact]
public void Folder_editor_only_offers_iso_without_compression_or_filesystem_options()
public void Folder_editor_offers_folder_archives_but_keeps_iso_compression_hidden()
{
TestServices.EnsureInitialized("en");
var loc = AppHost.Get<ILocalizer>();
var preset = new Preset { Name = "Folder action", TargetFormat = "iso", Output = { ReplaceOriginal = true } };
var editor = new PresetEditorViewModel(preset, loc, folderInput: true);
Assert.Equal("iso", Assert.Single(editor.Targets).Value);
Assert.Equal("iso", editor.Target.Value);
Assert.Equal(7, editor.Targets.Count);
Assert.False(editor.ShowReplaceOriginal);
// Cancel has not changed the saved preset.
Assert.Equal("iso", preset.TargetFormat);
Expand All @@ -209,7 +210,7 @@ public void Folder_editor_only_offers_iso_without_compression_or_filesystem_opti
var newFolder = new PresetEditorViewModel(new Preset { TargetFormat = "png" }, loc, folderInput: true);
Assert.Equal("iso", newFolder.Target.Value);
var availability = AppHost.Get<PresetAvailability>();
foreach (var target in new[] { "zip", "7z", "tar", "tgz", "tbz2", "txz", "gz", "bz2", "xz", "w64", "eac3" })
foreach (var target in new[] { "gz", "bz2", "xz", "w64", "eac3" })
{
var incompatible = new Preset { TargetFormat = target, Archive = { CombineIntoOne = true } };
Assert.Equal(PresetUse.Unusable,
Expand All @@ -230,7 +231,7 @@ public void Folder_group_editor_only_offers_folder_actions_without_removing_exis
var editor = new GroupEditorViewModel(group, store, AppHost.Get<ILocalizer>(), folderInput: true);
Assert.Single(editor.Members);
Assert.Contains(editor.Available, t => t.Value == "to-iso");
Assert.DoesNotContain(editor.Available, t => t.Value == "zip-all");
Assert.Contains(editor.Available, t => t.Value == "zip-all");
Assert.All(editor.Available, t => Assert.True(BuiltInData.AcceptsFolders(store.FindPreset(t.Value)!)));
editor.ToAdd = editor.Available.Single(t => t.Value == "to-iso");
editor.AddCommand.Execute(null);
Expand Down Expand Up @@ -308,7 +309,7 @@ public void Folder_input_opens_the_iso_donut_and_editor_shows_no_compression()
var vm = new RadialViewModel(store, AppHost.Get<ILocalizer>(), AppHost.Get<PresetAvailability>());
vm.Load([folder], clickMode: false);
Assert.Equal("Folders", AppHost.Get<ILocalizer>()[store.Profiles.Single(p => p.Id == "folders").NameKey!]);
Assert.Single(vm.Items);
Assert.Equal(3, vm.Items.Count);
var item = Assert.Single(vm.Items, i => i.Id == "to-iso");
Assert.Equal("to-iso", item.Id);
Assert.True(item.IsEnabled, item.DisabledReason);
Expand All @@ -324,7 +325,7 @@ public void Folder_input_opens_the_iso_donut_and_editor_shows_no_compression()
Assert.Null(view.FindControl<Border>("IsoCard"));
Assert.False(view.FindControl<Border>("ArchiveCard")!.IsVisible);
var targets = view.FindControl<ComboBox>("TargetBox")!;
Assert.Equal(1, targets.ItemCount);
Assert.Equal(7, targets.ItemCount);
Assert.DoesNotContain(targets.Items.OfType<Choice<string>>(), t => t.Value is "w64" or "eac3" or "gz" or "bz2" or "xz");
Assert.Null(view.FindControl<ComboBox>("IsoStandardBox"));
RenderTests.Pump();
Expand Down
15 changes: 15 additions & 0 deletions tests/Filee.Cli.Tests/CliTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ namespace Filee.Cli.Tests;
[Collection("Cli")] // shares EngineEnvironment state with EnginesCommandTests
public sealed class CliTests : IDisposable
{
[Fact]
public async Task Folder_to_zip_keeps_the_folder_tree_instead_of_converting_its_individual_files()
{
Assert.SkipUnless(new Filee.Engines.Archives.ArchiveConverter().GetStatus().IsAvailable, "Bundled 7-Zip is required.");
var folder = Directory.CreateDirectory(Path.Combine(_folder, "photos.jpg")).FullName;
Directory.CreateDirectory(Path.Combine(folder, "empty"));
await File.WriteAllTextAsync(Path.Combine(folder, "input.txt"), "folder contents", TestContext.Current.CancellationToken);
var result = await Run("convert", folder, "--to", "zip", "--output", _folder);
Assert.True(result.Code == 0, result.Errors + result.Output);
using var archive = System.IO.Compression.ZipFile.OpenRead(folder + ".zip");
Assert.NotNull(archive.GetEntry("photos.jpg/empty/"));
using var reader = new StreamReader(archive.GetEntry("photos.jpg/input.txt")!.Open());
Assert.Equal("folder contents", reader.ReadToEnd());
}

[Fact]
public async Task Folder_to_iso_uses_the_folder_itself_and_preserves_a_dotted_name()
{
Expand Down
4 changes: 2 additions & 2 deletions tests/Filee.Core.Tests/BackFaceTests.SlotTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public partial class BackFaceTests
["subtitles"] = (["to-srt", "to-vtt"], ["zip-all"]),
["vector"] = (["to-png", "to-pdf", "to-svg", "to-jpg", "to-webp"], ["to-tiff", "to-ico", "to-icns", "to-dxf", "zip-all"]),
["archives"] = (["extract", "zip-all", "to-zip", "to-7z"], []),
["folders"] = (["to-iso"], []),
["folders"] = (["to-iso", "to-zip", "zip-all"], []),
["cad"] = (["to-pdf", "to-png", "to-svg", "to-dxf", "to-dwg"], ["to-jpg", "merge-pdf", "zip-all"]),
["fonts"] = (["to-woff2", "to-woff", "to-ttf", "to-otf"], ["zip-all"]),
["data"] = (["to-json", "to-yaml", "to-xml", "to-csv", "to-xlsx"], ["to-ods", "to-html", "to-md", "zip-all"]),
Expand Down Expand Up @@ -69,7 +69,7 @@ public void A_new_library_has_only_conversions_on_its_fronts()
foreach (var profile in BuiltInData.CreateProfiles())
foreach (var id in profile.PresetIds)
foreach (var work in WorkOf(profile.Id, id, presets))
Assert.True(work == 'V' || work == 'B' && profile.Id is "archives" or "mixed", $"{profile.Id}/front: {id} is {work}");
Assert.True(work == 'V' || work == 'B' && profile.Id is "archives" or "folders" or "mixed", $"{profile.Id}/front: {id} is {work}");
}

[Fact]
Expand Down
49 changes: 49 additions & 0 deletions tests/Filee.Core.Tests/FolderArchives1345Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Filee.Core.Presets;
using Filee.Core.Settings;

namespace Filee.Core.Tests;

public sealed class FolderArchives1345Tests
{
[Theory]
[InlineData("zip")]
[InlineData("7z")]
[InlineData("tar")]
[InlineData("tgz")]
[InlineData("tbz2")]
[InlineData("txz")]
public void Folder_presets_accept_only_container_archives(string target)
{
Assert.True(BuiltInData.AcceptsFolders(new Preset { TargetFormat = target }));
foreach (var single in new[] { "gz", "bz2", "xz", "pdf" })
Assert.False(BuiltInData.AcceptsFolders(new Preset { TargetFormat = single }));
}

[Fact]
public void Default_and_existing_iso_only_folders_offer_zip_and_one_zip()
{
var presets = BuiltInData.CreatePresets();
var profiles = BuiltInData.CreateProfiles();
var folders = profiles.Single(p => p.Id == "folders");
Assert.Equal(["to-iso", "to-zip", "zip-all"], folders.PresetIds);
folders.PresetIds = ["to-iso"];
Assert.True(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
Assert.Equal(["to-iso", "to-zip", "zip-all"], folders.PresetIds);
Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
Assert.Single(presets, p => p.Id == "to-zip");
}

[Fact]
public void Migration_preserves_custom_or_deleted_folder_profiles()
{
var presets = BuiltInData.CreatePresets();
var profiles = BuiltInData.CreateProfiles();
var folders = profiles.Single(p => p.Id == "folders");
folders.PresetIds = ["zip-all", "to-iso"];
Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
Assert.Equal(["zip-all", "to-iso"], folders.PresetIds);
profiles.Remove(folders);
Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
Assert.DoesNotContain(profiles, p => p.Id == "folders");
}
}
2 changes: 1 addition & 1 deletion tests/Filee.Core.Tests/FolderIso632Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public void Migration_adds_the_folder_donut_without_enabling_iso_on_archives_or_
Assert.True(SettingsMigrations.ApplyToLibrary(73, presets, profiles));
Assert.Equal(presets.FindIndex(p => p.Id == "zip-all") - 1, presets.FindIndex(p => p.Id == "to-iso"));
Assert.Equal(original, archives.PresetIds);
Assert.Equal(["to-iso"],
Assert.Equal(["to-iso", "to-zip", "zip-all"],
profiles.Single(p => p.Id == "folders").PresetIds);
presets.Single(p => p.Id == "to-iso").Name = "My ISO";
Assert.False(SettingsMigrations.ApplyToLibrary(73, presets, profiles));
Expand Down
Loading
Loading