From 60594f08cf6e4865fee83a0a90a6bf09279248b3 Mon Sep 17 00:00:00 2001 From: Chris Wenn Date: Sat, 24 Oct 2026 01:56:45 +0400 Subject: [PATCH 1/4] feat: add Compress action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Compress action alongside Uncompress (issue #49, on the README roadmap). It zips the matched file or folder into .zip next to it via ZIPFoundation's zipItem, numbering the archive when the name is taken (same "name (1).ext" convention as move/copy). The source stays in place, like a copy, and Undo is not offered — consistent with Uncompress. No options popover: an existing archive is always worked around by numbering, never skipped or replaced. Dry Run reports the archive path, including the rename, as a copied artifact. --- CHANGELOG.md | 1 + Sources/ForelApp/Views/RuleEditorView.swift | 5 ++ Sources/ForelApp/Views/RuleListView.swift | 2 + Sources/ForelCore/Engine/ActionExecutor.swift | 47 ++++++++++++- Sources/ForelCore/Models/Models.swift | 1 + Sources/ForelCore/Models/RuleSchema.swift | 8 ++- .../ForelCoreTests/ActionExecutorTests.swift | 70 +++++++++++++++++++ 7 files changed, 130 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf84ed..5fe0199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Forel are documented here. Format loosely follows ### Added - Date conditions now support hours, with an "is not in the last" option for excluding recently added, created, or modified files. +- New Compress action: zips the matched file or folder into ".zip" next to it, numbering the archive if one already exists. ### Fixed - Date Added conditions no longer crash when reading a file's timestamp. diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index 0ed76fd..a35e1c4 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -941,6 +941,11 @@ private struct ActionRow: View { .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .frame(minHeight: 32, alignment: .center) + case .compress: + Text("Creates \u{201C}.zip\u{201D} next to the matched file or folder.") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + .frame(minHeight: 32, alignment: .center) case .moveToTrash, .delete: Text("No parameters") .font(.system(size: 11)) diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index a898ace..c946ba1 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -567,6 +567,8 @@ private struct RuleDetails: View { return ("import to \(library)", playlist.isEmpty ? nil : playlist) case .uncompress: return ("uncompress ZIP", MoveConflictResolution(rawValue: action.params[ActionParam.onConflict]?.stringValue ?? "")?.label) + case .compress: + return ("create ZIP", nil) } } diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index 97605ac..0313ac9 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -219,6 +219,8 @@ public enum ActionExecutor { return try importToLibrary(action, path: path) case .uncompress: return try uncompress(action, path: path) + case .compress: + return try compress(path: path) } } @@ -326,6 +328,31 @@ public enum ActionExecutor { return Applied(newPath: plan.target, undo: .none) } + /// Zips `path` (file or folder) into `.zip` beside it, numbering the + /// name when that archive already exists. The source is left in place, + /// exactly like a copy — only the archive is the new artifact. + private static func compress(path: String) throws -> Applied { + let dir = (path as NSString).deletingLastPathComponent + let fileName = compressArchiveName(path) + let dest = try resolveDestination( + naiveDest: (dir as NSString).appendingPathComponent(fileName), + dir: dir, + fileName: fileName, + resolution: .rename + ) + do { + try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: dest)) + } catch { + throw ActionError("Could not create ZIP archive: \(error)") + } + return Applied(newPath: path, undo: .none, copiedPath: dest) + } + + private static func compressArchiveName(_ path: String) -> String { + let baseName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension + return baseName + ".zip" + } + /// Resolves the actual path a move/copy should write to, given the /// configured conflict resolution: `.rename` numbers the file instead; /// `.replace` sends whatever is already at `naiveDest` to the Trash @@ -1190,6 +1217,24 @@ public enum ActionExecutor { copiedPath: nil, isTerminal: false ) + case .compress: + let dir = (path as NSString).deletingLastPathComponent + let fileName = compressArchiveName(path) + let naiveTarget = (dir as NSString).appendingPathComponent(fileName) + let conflicts = FileManager.default.fileExists(atPath: naiveTarget) + // Compress has no options, so an existing archive is always + // worked around by numbering the new one, never skipped. + let target = try resolveDestination(naiveDest: naiveTarget, dir: dir, fileName: fileName, resolution: .rename) + return ActionPlan( + kind: action.kind, + description: conflicts ? "Compress to \(target) (renamed to avoid an existing archive)" : "Compress to \(target)", + sourcePath: path, + targetPath: target, + status: .wouldRun, + finalPath: path, + copiedPath: target, + isTerminal: false + ) } } @@ -1208,7 +1253,7 @@ public enum ActionExecutor { let pattern = action.params[ActionParam.pattern]?.stringValue ?? "" guard let newName = try? applyRenamePattern(pattern, path: path) else { return true } return (path as NSString).lastPathComponent != newName - case .moveToFolder, .copyToFolder, .moveToTrash, .delete, .runScript, .runShortcut, .openApplication, .importToLibrary, .uncompress: + case .moveToFolder, .copyToFolder, .moveToTrash, .delete, .runScript, .runShortcut, .openApplication, .importToLibrary, .uncompress, .compress: return true } } diff --git a/Sources/ForelCore/Models/Models.swift b/Sources/ForelCore/Models/Models.swift index c73059c..ec253b7 100644 --- a/Sources/ForelCore/Models/Models.swift +++ b/Sources/ForelCore/Models/Models.swift @@ -131,6 +131,7 @@ public enum ActionKind: String, Codable, Equatable, Sendable { case runShortcut = "run_shortcut" case openApplication = "open_application" case importToLibrary = "import_to_library" + case compress case uncompress public init(dbValue: String) { diff --git a/Sources/ForelCore/Models/RuleSchema.swift b/Sources/ForelCore/Models/RuleSchema.swift index 0ab4538..0f25013 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -247,6 +247,7 @@ public extension ActionKind { case .runShortcut: return "Run shortcut" case .openApplication: return "Open application" case .importToLibrary: return "Import to library" + case .compress: return "Compress" case .uncompress: return "Uncompress" } } @@ -264,6 +265,7 @@ public extension ActionKind { case .runShortcut: return "square.stack.3d.up" case .openApplication: return "app" case .importToLibrary: return "tray.full" + case .compress: return "archivebox" case .uncompress: return "doc.zipper" } } @@ -276,7 +278,7 @@ public extension ActionKind { switch self { case .moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress: return true - case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete: + case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete, .compress: return false } } @@ -302,7 +304,7 @@ public extension ActionKind { case .importToLibrary: return [ActionParamSpec(key: ActionParam.libraryType, kind: .libraryType), ActionParamSpec(key: ActionParam.targetPlaylist, kind: .playlist)] - case .moveToTrash, .delete, .uncompress: + case .moveToTrash, .delete, .uncompress, .compress: return [] } } @@ -349,7 +351,7 @@ public enum RuleSchema { public static let conditionKinds: [ConditionKind] = conditionKindGroups.flatMap(\.kinds) public static let actionKindGroups: [ActionKindGroup] = [ - ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .rename, .uncompress]), + ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .rename, .compress, .uncompress]), ActionKindGroup(title: "Tags", kinds: [.addTag, .removeTag, .setColorLabel]), ActionKindGroup(title: "Automation", kinds: [.runScript, .runShortcut, .openApplication]), ActionKindGroup(title: "Disposal", kinds: [.moveToTrash, .delete]), diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index b57d736..d5616b2 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -65,6 +65,76 @@ import Foundation #expect(!FileManager.default.fileExists(atPath: (dir.path as NSString).appendingPathComponent("report-archived.txt.txt"))) } + @Test func compressFileCreatesZipBesideItAndKeepsTheSource() throws { + let dir = TempDir() + let file = dir.file("report.txt", contents: "hello") + + let action = makeAction(.compress, .object([:])) + let plan = try ActionExecutor.plan(action, path: file) + #expect(plan.status == .wouldRun) + #expect(plan.targetPath == (dir.path as NSString).appendingPathComponent("report.zip")) + #expect(plan.finalPath == file) + + let applied = try ActionExecutor.execute(action, path: file) + + #expect(applied.newPath == file) + #expect(applied.copiedPath == (dir.path as NSString).appendingPathComponent("report.zip")) + #expect(applied.undo == .none) + #expect(FileManager.default.fileExists(atPath: file)) + try assertZip(at: applied.copiedPath!, contains: ["report.txt"], with: ["report.txt": "hello"]) + } + + @Test func compressFolderArchivesItsContentsRecursively() throws { + let dir = TempDir() + let folder = dir.dir("Export") + try "a".write(toFile: (folder as NSString).appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + let nested = dir.dir("Export/Nested") + try "b".write(toFile: (nested as NSString).appendingPathComponent("b.txt"), atomically: true, encoding: .utf8) + + let applied = try ActionExecutor.execute(makeAction(.compress, .object([:])), path: folder) + + #expect(applied.copiedPath == (dir.path as NSString).appendingPathComponent("Export.zip")) + #expect(FileManager.default.fileExists(atPath: folder)) + // Folder archives keep the folder itself as the wrapper entry, like Finder. + try assertZip(at: applied.copiedPath!, contains: ["Export/a.txt", "Export/Nested/b.txt"], with: ["Export/a.txt": "a", "Export/Nested/b.txt": "b"]) + } + + @Test func compressWithExistingArchiveRenamesTheNewOne() throws { + let dir = TempDir() + let file = dir.file("report.txt", contents: "new") + let existing = (dir.path as NSString).appendingPathComponent("report.zip") + try "old".write(toFile: existing, atomically: true, encoding: .utf8) + + let plan = try ActionExecutor.plan(makeAction(.compress, .object([:])), path: file) + #expect(plan.targetPath == (dir.path as NSString).appendingPathComponent("report (1).zip")) + #expect(plan.description.contains("renamed")) + + let applied = try ActionExecutor.execute(makeAction(.compress, .object([:])), path: file) + + #expect(applied.copiedPath == (dir.path as NSString).appendingPathComponent("report (1).zip")) + try assertZip(at: applied.copiedPath!, contains: ["report.txt"], with: ["report.txt": "new"]) + #expect(try String(contentsOfFile: existing, encoding: .utf8) == "old") + } + + /// Extracts `zip` and checks it contains exactly `names`, each with the + /// given contents. + private func assertZip(at zip: String, contains names: [String], with contents: [String: String]) throws { + let out = TempDir() + try FileManager.default.unzipItem(at: URL(fileURLWithPath: zip), to: URL(fileURLWithPath: out.path)) + var seen: Set = [] + let enumerator = FileManager.default.enumerator(atPath: out.path) + while let item = enumerator?.nextObject() as? String { + let full = (out.path as NSString).appendingPathComponent(item) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: full, isDirectory: &isDir), !isDir.boolValue else { continue } + seen.insert(item) + if let expected = contents[item] { + #expect(try String(contentsOfFile: full, encoding: .utf8) == expected) + } + } + #expect(seen == Set(names)) + } + @Test func uncompressZipExtractsSingleRootItemAndMovesArchiveAway() throws { let dir = TempDir() let staging = dir.dir("staging") From a7ba02a023ba3599ed5af71aa861324fe5fe5fd8 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 26 Oct 2026 10:03:01 +0100 Subject: [PATCH 2/4] Compress now reduces archive size using Deflate instead of storing files without compression. --- Sources/ForelCore/Engine/ActionExecutor.swift | 2 +- .../ForelCoreTests/ActionExecutorTests.swift | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index 03a89ec..13e53d6 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -357,7 +357,7 @@ public enum ActionExecutor { resolution: .rename ) do { - try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: dest)) + try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: dest), compressionMethod: .deflate) } catch { throw ActionError("Could not create ZIP archive: \(error)") } diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index 7f92f92..18450e6 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -99,6 +99,69 @@ import Foundation try assertZip(at: applied.copiedPath!, contains: ["Export/a.txt", "Export/Nested/b.txt"], with: ["Export/a.txt": "a", "Export/Nested/b.txt": "b"]) } + @Test(arguments: ["preview", "manual", "watcher"], [false, true]) + func compressUsesCompressionAcrossExecutionPaths(execution: String, isFolder: Bool) throws { + let dir = TempDir() + let watched = dir.dir("Watched") + let contents = String(repeating: "Compressible file contents\n", count: 10_000) + let source: String + let archivedName: String + let original: String + if isFolder { + source = dir.dir("Watched/Export") + _ = dir.dir("Watched/Export/Nested") + original = dir.file("Watched/Export/Nested/report.txt", contents: contents) + archivedName = "Export/Nested/report.txt" + } else { + source = dir.file("Watched/report.txt", contents: contents) + original = source + archivedName = "report.txt" + } + let archivePath = watched + (isFolder ? "/Export.zip" : "/report.zip") + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: watched) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Compress") + rule.conditions = [isFolder + ? makeCondition(.kind, .is, "folder", ruleId: rule.id) + : makeCondition(.extension_, .is, "txt", ruleId: rule.id)] + rule.actions = [makeAction(.compress, .object([:]), ruleId: rule.id)] + try db.insertRule(rule) + let rules = try db.executionRules(folderID: folder.id, isPro: false) + + if execution == "watcher" { + let coordinator = WatcherCoordinator(db: db, settleInterval: 0.01) + coordinator.enqueue(event: .pathArrived(source)) + coordinator.waitForPendingEvents() + let history = try db.listHistory() + #expect(history.count == 1) + #expect(history.first?.status == .applied) + } else { + let entries = RuleEngine.walkEntries(root: watched, maxDepth: RuleEngine.maxRuleDepth(rules)) + #expect(entries.map(\.path) == [source]) + for entry in entries { + if execution == "preview" { + let preview = try #require(RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: rules, isPro: false)) + #expect(preview.rules.first?.actions.first?.targetPath == archivePath) + } else { + let result = RuleEngine.run(path: entry.path, depth: entry.depth, rules: rules, batchId: "manual", root: watched, isPro: false) + #expect(result.history.count == 1) + #expect(result.history.first?.status == .applied) + } + } + } + + #expect(try String(contentsOfFile: original, encoding: .utf8) == contents) + if execution == "preview" { + #expect(!FileManager.default.fileExists(atPath: archivePath)) + } else { + let attributes = try FileManager.default.attributesOfItem(atPath: archivePath) + let size = try #require(attributes[.size] as? NSNumber) + #expect(size.intValue < contents.utf8.count / 10) + try assertZip(at: archivePath, contains: [archivedName], with: [archivedName: contents]) + } + } + @Test func compressWithExistingArchiveRenamesTheNewOne() throws { let dir = TempDir() let file = dir.file("report.txt", contents: "new") From 542121cc09dbcb3f7a8d914101f8c87d359ceeab Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 26 Oct 2026 10:10:53 +0100 Subject: [PATCH 3/4] Customize ZIP archive names with source names, the current date, or a timestamp. --- Sources/ForelApp/Views/RuleEditorView.swift | 53 ++++++++- Sources/ForelApp/Views/RuleListView.swift | 3 +- Sources/ForelCore/Engine/ActionExecutor.swift | 52 +++++++-- Sources/ForelCore/Models/RuleSchema.swift | 6 +- Sources/ForelCore/Models/RuleValidator.swift | 6 + Tests/ForelCoreTests/ArchiveNamingTests.swift | 106 ++++++++++++++++++ 6 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 Tests/ForelCoreTests/ArchiveNamingTests.swift diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index 7aa99bf..e5c10d1 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -948,10 +948,7 @@ private struct ActionRow: View { .foregroundStyle(ForelTheme.secondaryText) .frame(minHeight: 32, alignment: .center) case .compress: - Text("Creates \u{201C}.zip\u{201D} next to the matched file or folder.") - .font(.system(size: 11)) - .foregroundStyle(ForelTheme.secondaryText) - .frame(minHeight: 32, alignment: .center) + ArchiveNameEditor(pattern: paramBinding(ActionParam.archiveName)) case .moveToTrash, .delete: Text("No parameters") .font(.system(size: 11)) @@ -1373,6 +1370,54 @@ private struct TagTokensEditor: View { } } +private struct ArchiveNameEditor: View { + @Binding var pattern: String + + private let tokens = [ + (value: "{name}", label: "Source name"), + (value: "{current_date}", label: "Current date"), + (value: "{timestamp}", label: "Timestamp"), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GlassField(placeholder: "Archive name, e.g. {name}-{timestamp}.zip", text: $pattern) + .accessibilityLabel("Archive name") + HStack(spacing: 6) { + ForEach(tokens, id: \.value) { token in + Button(token.label) { insert(token.value) } + .buttonStyle(.bordered) + .controlSize(.mini) + .help("Insert \(token.value)") + } + } + Text(preview) + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + .textSelection(.enabled) + Text("Empty uses {name}.zip. Saved next to the source; .zip is added automatically.") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var preview: String { + do { + return "Preview: \(try ActionExecutor.archiveFileName(pattern: pattern, sourceName: "file"))" + } catch { + return String(describing: error) + } + } + + private func insert(_ token: String) { + var base = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + if base.lowercased().hasSuffix(".zip") { base = String(base.dropLast(4)) } + let separator = base.isEmpty || base.hasSuffix("-") || base.hasSuffix("_") ? "" : "-" + pattern = base + separator + token + ".zip" + } +} + private struct RenamePatternEditor: View { @Binding var pattern: String let cleanFileName: Bool diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index 6736e9b..e5dc9cd 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -660,7 +660,8 @@ private struct RuleDetails: View { case .uncompress: return ("uncompress ZIP", MoveConflictResolution(rawValue: action.params[ActionParam.onConflict]?.stringValue ?? "")?.label) case .compress: - return ("create ZIP", nil) + let name = action.params[ActionParam.archiveName]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ("create ZIP", name.isEmpty ? "{name}.zip" : name) } } diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index 13e53d6..298247b 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -220,7 +220,7 @@ public enum ActionExecutor { case .uncompress: return try uncompress(action, path: path) case .compress: - return try compress(path: path) + return try compress(action, path: path) } } @@ -344,12 +344,12 @@ public enum ActionExecutor { return Applied(newPath: plan.target, undo: .none) } - /// Zips `path` (file or folder) into `.zip` beside it, numbering the - /// name when that archive already exists. The source is left in place, + /// Zips `path` beside it using the configured name (default `.zip`), + /// numbering the name when that archive already exists. The source is left in place, /// exactly like a copy — only the archive is the new artifact. - private static func compress(path: String) throws -> Applied { + private static func compress(_ action: Action, path: String) throws -> Applied { let dir = (path as NSString).deletingLastPathComponent - let fileName = compressArchiveName(path) + let fileName = try compressArchiveName(action, path: path) let dest = try resolveDestination( naiveDest: (dir as NSString).appendingPathComponent(fileName), dir: dir, @@ -364,9 +364,43 @@ public enum ActionExecutor { return Applied(newPath: path, undo: .none, copiedPath: dest) } - private static func compressArchiveName(_ path: String) -> String { - let baseName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension - return baseName + ".zip" + private static func compressArchiveName(_ action: Action, path: String) throws -> String { + let name = (path as NSString).lastPathComponent + let attributes = try? FileManager.default.attributesOfItem(atPath: path) + let isFolder = (attributes?[.type] as? FileAttributeType) == .typeDirectory + return try archiveFileName( + pattern: action.params[ActionParam.archiveName]?.stringValue ?? "", + sourceName: isFolder ? name : (name as NSString).deletingPathExtension + ) + } + + /// Resolves an archive name for execution, validation, and editor previews. + /// Dates use the local time zone; ZIP is appended when omitted. + public static func archiveFileName(pattern: String, sourceName: String, at date: Date = Date()) throws -> String { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + let template = trimmed.isEmpty ? "{name}.zip" : trimmed + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + let day = formatter.string(from: date) + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + // Insert the source name last so braces in its name stay literal. + var result = template + .replacingOccurrences(of: "{current_date}", with: day) + .replacingOccurrences(of: "{timestamp}", with: formatter.string(from: date)) + .replacingOccurrences(of: "{name}", with: sourceName) + guard !result.isEmpty, result != ".", result != "..", + !result.contains("/"), !result.contains("\0"), + !result.hasSuffix("."), !result.hasSuffix(" ") else { + throw ActionError("Archive name must be a filename, without '/' or null characters, and cannot end with a dot or space.") + } + if !result.lowercased().hasSuffix(".zip") { result += ".zip" } + guard result.utf8.count <= 255 else { + throw ActionError("Archive name is too long (maximum 255 bytes including .zip).") + } + return result } /// Resolves the actual path a move/copy should write to, given the @@ -1235,7 +1269,7 @@ public enum ActionExecutor { ) case .compress: let dir = (path as NSString).deletingLastPathComponent - let fileName = compressArchiveName(path) + let fileName = try compressArchiveName(action, path: path) let naiveTarget = (dir as NSString).appendingPathComponent(fileName) let conflicts = FileManager.default.fileExists(atPath: naiveTarget) // Compress has no options, so an existing archive is always diff --git a/Sources/ForelCore/Models/RuleSchema.swift b/Sources/ForelCore/Models/RuleSchema.swift index 0f25013..54df84d 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -195,6 +195,7 @@ public enum ActionParam { public static let destination = "destination" public static let onConflict = "on_conflict" public static let pattern = "pattern" + public static let archiveName = "archive_name" public static let tags = "tags" public static let color = "color" public static let script = "script" @@ -211,6 +212,7 @@ public enum ActionParam { public enum ActionParamKind: Sendable, Equatable { case folderPath case renamePattern + case archiveName case tags case colorLabel case script @@ -304,7 +306,9 @@ public extension ActionKind { case .importToLibrary: return [ActionParamSpec(key: ActionParam.libraryType, kind: .libraryType), ActionParamSpec(key: ActionParam.targetPlaylist, kind: .playlist)] - case .moveToTrash, .delete, .uncompress, .compress: + case .compress: + return [ActionParamSpec(key: ActionParam.archiveName, kind: .archiveName)] + case .moveToTrash, .delete, .uncompress: return [] } } diff --git a/Sources/ForelCore/Models/RuleValidator.swift b/Sources/ForelCore/Models/RuleValidator.swift index 670636a..a874615 100644 --- a/Sources/ForelCore/Models/RuleValidator.swift +++ b/Sources/ForelCore/Models/RuleValidator.swift @@ -48,6 +48,12 @@ public enum RuleValidator { if action.params[ActionParam.pattern]?.stringValue?.trimmingCharacters(in: .whitespaces).isEmpty != false { return Issue(message: "Rename pattern cannot be empty") } + case .compress: + do { + _ = try ActionExecutor.archiveFileName(pattern: action.params[ActionParam.archiveName]?.stringValue ?? "", sourceName: "file") + } catch { + return Issue(message: String(describing: error)) + } case .addTag, .removeTag: if tags(in: action).isEmpty { return Issue(message: "At least one tag is required") diff --git a/Tests/ForelCoreTests/ArchiveNamingTests.swift b/Tests/ForelCoreTests/ArchiveNamingTests.swift new file mode 100644 index 0000000..a6f9b27 --- /dev/null +++ b/Tests/ForelCoreTests/ArchiveNamingTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import ForelCore + +@Suite struct ArchiveNamingTests { + @Test func namesSupportLiteralsSourceNamesAndLocalDates() throws { + let date = try #require(Calendar(identifier: .gregorian).date(from: + DateComponents(year: 2026, month: 10, day: 26, hour: 14, minute: 35, second: 7))) + #expect(try ActionExecutor.archiveFileName(pattern: "Backup", sourceName: "report", at: date) == "Backup.zip") + #expect(try ActionExecutor.archiveFileName(pattern: "Backup.ZIP", sourceName: "report", at: date) == "Backup.ZIP") + #expect(try ActionExecutor.archiveFileName(pattern: "", sourceName: "report", at: date) == "report.zip") + #expect(try ActionExecutor.archiveFileName(pattern: " ", sourceName: "report", at: date) == "report.zip") + #expect(try ActionExecutor.archiveFileName(pattern: "{name}-{current_date}-{timestamp}", sourceName: "report", at: date) + == "report-2026-10-26-2026-10-26_14-35-07.zip") + #expect(try ActionExecutor.archiveFileName(pattern: "{name}", sourceName: "{timestamp}", at: date) == "{timestamp}.zip") + } + + @Test(arguments: ["../outside", "/tmp/outside.zip", ".", "..", "bad\0name", "bad.", String(repeating: "é", count: 126)]) + func invalidNamesAreRejectedBeforeWriting(pattern: String) throws { + let dir = TempDir() + let file = dir.file("report.txt", contents: "original") + let action = makeAction(.compress, .object([ActionParam.archiveName: .string(pattern)])) + #expect(!RuleValidator.validate([action]).isEmpty) + #expect(throws: ActionError.self) { try ActionExecutor.plan(action, path: file) } + #expect(throws: ActionError.self) { try ActionExecutor.execute(action, path: file) } + #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path) == ["report.txt"]) + } + + @Test(arguments: ["preview", "manual", "watcher"], [false, true]) + func savedNamesAndConflictsWorkAcrossExecutionPaths(execution: String, isFolder: Bool) throws { + let dir = TempDir() + let watched = dir.dir("Watched") + let source: String + let original: String + let sourceName: String + let entryName: String + if isFolder { + source = dir.dir("Watched/Project.2026") + original = dir.file("Watched/Project.2026/report.txt", contents: "original") + sourceName = "Project.2026" + entryName = "Project.2026/report.txt" + } else { + source = dir.file("Watched/report.txt", contents: "original") + original = source + sourceName = "report" + entryName = "report.txt" + } + let existing = dir.file("Watched/\(sourceName)-backup.zip", contents: "existing archive") + let expected = watched + "/\(sourceName)-backup (1).zip" + let folder = WatchedFolder(path: watched) + var rule = makeRule(folderId: folder.id, name: "Compress") + rule.conditions = [isFolder + ? makeCondition(.kind, .is, "folder", ruleId: rule.id) + : makeCondition(.extension_, .is, "txt", ruleId: rule.id)] + rule.actions = [makeAction(.compress, .object([ActionParam.archiveName: .string("{name}-backup")]), ruleId: rule.id)] + let databasePath = dir.path + "/rules.db" + do { + let db = try Database(path: databasePath) + try db.insertFolder(folder) + try db.insertRule(rule) + } + let db = try Database(path: databasePath) + #expect(try db.listRules(folderId: folder.id).first?.actions == rule.actions) + let rules = try db.executionRules(folderID: folder.id, isPro: false) + #expect(RuleValidator.validate(rules[0].actions).isEmpty) + if execution == "watcher" { + let coordinator = WatcherCoordinator(db: db, settleInterval: 0.01) + coordinator.enqueue(event: .pathArrived(source)) + coordinator.waitForPendingEvents() + let history = try db.listHistory() + #expect(history.count == 1) + #expect(history.first?.status == .applied) + #expect(history.first?.resultPath == expected) + } else { + let entries = RuleEngine.walkEntries(root: watched, maxDepth: RuleEngine.maxRuleDepth(rules)) + for entry in entries { + if execution == "preview" { + let preview = RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: rules, isPro: false) + if entry.path == source { + #expect(preview?.rules.first?.actions.first?.targetPath == expected) + } else { + #expect(preview == nil) + } + } else { + let result = RuleEngine.run(path: entry.path, depth: entry.depth, rules: rules, batchId: "manual", root: watched, isPro: false) + if entry.path == source { + #expect(result.history.count == 1) + #expect(result.history.first?.status == .applied) + #expect(result.history.first?.resultPath == expected) + } else { + #expect(result.history.isEmpty) + } + } + } + } + #expect(try String(contentsOfFile: original, encoding: .utf8) == "original") + #expect(try String(contentsOfFile: existing, encoding: .utf8) == "existing archive") + if execution == "preview" { + #expect(!FileManager.default.fileExists(atPath: expected)) + } else { + let extracted = TempDir() + try FileManager.default.unzipItem(at: URL(fileURLWithPath: expected), to: URL(fileURLWithPath: extracted.path)) + #expect(try String(contentsOfFile: extracted.path + "/" + entryName, encoding: .utf8) == "original") + } + } +} From d96351cc6c787765822be0f2a0c72de96519c8f0 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 26 Oct 2026 10:14:56 +0100 Subject: [PATCH 4/4] Wip --- Sources/ForelApp/Views/RuleEditorView.swift | 48 +++++++++- Sources/ForelApp/Views/RuleListView.swift | 6 +- Sources/ForelCore/Engine/ActionExecutor.swift | 92 ++++++++++++++----- Sources/ForelCore/Models/RuleSchema.swift | 8 +- Sources/ForelCore/Models/RuleValidator.swift | 1 + ...tcherCoordinator+DisconnectedVolumes.swift | 2 +- Tests/ForelCoreTests/RuleSchemaTests.swift | 2 +- 7 files changed, 130 insertions(+), 29 deletions(-) diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index e5c10d1..280750f 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -1028,6 +1028,8 @@ private struct ActionOptionsView: View { conflictResolutionOptions case .rename: renameOptions + case .compress: + compressionOptions default: Text("No options for this action.") .font(.system(size: 12)) @@ -1048,6 +1050,50 @@ private struct ActionOptionsView: View { } } + private var compressionOptions: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Destination") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ForelTheme.secondaryText) + FolderField( + placeholder: "Next to the source (default)", + path: paramBinding(ActionParam.destination), + hint: "Supports {year}, {month}, and {day}." + ) + if action.params[ActionParam.destination]?.stringValue?.isEmpty == false { + Button("Use source folder") { paramBinding(ActionParam.destination).wrappedValue = "" } + .buttonStyle(.link) + } + Text("Leave empty to save next to the source. Existing ZIPs are kept; new archives are numbered.") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Divider() + Toggle("Delete source after compression", isOn: deleteSourceBinding) + .toggleStyle(.switch) + .controlSize(.small) + .font(.system(size: 12)) + if ActionExecutor.deletesSourceAfterCompression(action) { + Label("After the ZIP is created successfully, the original file or folder and all its contents are permanently deleted. This cannot be undone. Following actions apply to the ZIP.", systemImage: "exclamationmark.triangle") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var deleteSourceBinding: Binding { + Binding( + get: { ActionExecutor.deletesSourceAfterCompression(action) }, + set: { enabled in + var params: [String: JSONValue] = [:] + if case .object(let existing) = action.params { params = existing } + params[ActionParam.deleteSourceAfterCompression] = .bool(enabled) + action.params = .object(params) + } + ) + } + private var conflictResolutionOptions: some View { let defaultResolution = action.kind == .importToLibrary ? MoveConflictResolution.skip.rawValue : MoveConflictResolution.rename.rawValue let options = action.kind == .importToLibrary @@ -1395,7 +1441,7 @@ private struct ArchiveNameEditor: View { .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .textSelection(.enabled) - Text("Empty uses {name}.zip. Saved next to the source; .zip is added automatically.") + Text("Empty uses {name}.zip. The .zip extension is added automatically; choose a destination in Options.") .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index e5dc9cd..7b278fe 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -661,7 +661,11 @@ private struct RuleDetails: View { return ("uncompress ZIP", MoveConflictResolution(rawValue: action.params[ActionParam.onConflict]?.stringValue ?? "")?.label) case .compress: let name = action.params[ActionParam.archiveName]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return ("create ZIP", name.isEmpty ? "{name}.zip" : name) + let destination = action.params[ActionParam.destination]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var details = [name.isEmpty ? "{name}.zip" : name] + if !destination.isEmpty { details.append("to \(destination)") } + if ActionExecutor.deletesSourceAfterCompression(action) { details.append("permanently delete source after success") } + return ("create ZIP", details.joined(separator: " · ")) } } diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index 298247b..a3788d2 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -344,24 +344,66 @@ public enum ActionExecutor { return Applied(newPath: plan.target, undo: .none) } - /// Zips `path` beside it using the configured name (default `.zip`), - /// numbering the name when that archive already exists. The source is left in place, - /// exactly like a copy — only the archive is the new artifact. + /// Creates a ZIP using the configured name and destination. Cleanup only + /// removes the source after the archive has been written successfully. private static func compress(_ action: Action, path: String) throws -> Applied { - let dir = (path as NSString).deletingLastPathComponent + let dir = try compressionDirectory(action, path: path) let fileName = try compressArchiveName(action, path: path) - let dest = try resolveDestination( - naiveDest: (dir as NSString).appendingPathComponent(fileName), - dir: dir, - fileName: fileName, - resolution: .rename - ) - do { - try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: dest), compressionMethod: .deflate) - } catch { - throw ActionError("Could not create ZIP archive: \(error)") + return try explainingDisconnectedVolume(dir) { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let dest = try resolveDestination( + naiveDest: (dir as NSString).appendingPathComponent(fileName), + dir: dir, fileName: fileName, resolution: .rename + ) + do { + try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: dest), compressionMethod: .deflate) + } catch { + // A failed write can leave an incomplete archive. Keep the + // source and remove only the new archive we tried to create. + try? FileManager.default.removeItem(atPath: dest) + throw ActionError("Could not create ZIP archive. The source was kept. \(error)") + } + if deletesSourceAfterCompression(action) { + do { + try FileManager.default.removeItem(atPath: path) + } catch { + throw ActionError("ZIP created at \(dest), but the source could not be deleted: \(error)") + } + return Applied(newPath: dest, undo: .none) + } + return Applied(newPath: path, undo: .none, copiedPath: dest) } - return Applied(newPath: path, undo: .none, copiedPath: dest) + } + + public static func deletesSourceAfterCompression(_ action: Action) -> Bool { + action.params[ActionParam.deleteSourceAfterCompression]?.boolValue == true + } + + /// Empty means next to the source. Custom destinations must be absolute; + /// a relative path must never silently resolve against the app's directory. + static func archiveDestinationPath(_ value: String) throws -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let expanded = (trimmed as NSString).expandingTildeInPath + guard expanded.hasPrefix("/"), !expanded.contains("\0") else { + throw ActionError("Archive destination must be an absolute folder path or start with ~/.") + } + return (expanded as NSString).standardizingPath + } + + private static func compressionDirectory(_ action: Action, path: String) throws -> String { + let configured = try archiveDestinationPath(action.params[ActionParam.destination]?.stringValue ?? "") + let directory = configured.map { expandedDestination($0, fileAtPath: path) } + ?? (path as NSString).deletingLastPathComponent + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { + let source = URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().pathComponents + let destination = URL(fileURLWithPath: directory).standardizedFileURL.resolvingSymlinksInPath().pathComponents + guard !destination.starts(with: source) else { + throw ActionError("Choose an archive destination outside the source folder. A ZIP inside it would include itself and could be deleted with the source.") + } + } + return directory } private static func compressArchiveName(_ action: Action, path: String) throws -> String { @@ -1268,22 +1310,28 @@ public enum ActionExecutor { isTerminal: false ) case .compress: - let dir = (path as NSString).deletingLastPathComponent + let dir = try compressionDirectory(action, path: path) let fileName = try compressArchiveName(action, path: path) let naiveTarget = (dir as NSString).appendingPathComponent(fileName) let conflicts = FileManager.default.fileExists(atPath: naiveTarget) - // Compress has no options, so an existing archive is always - // worked around by numbering the new one, never skipped. + // Existing archives are always preserved by numbering the new one. let target = try resolveDestination(naiveDest: naiveTarget, dir: dir, fileName: fileName, resolution: .rename) + let cleanup = deletesSourceAfterCompression(action) + var description = "Compress to \(target)" + if conflicts { description += " (renamed to avoid an existing archive)" } + if cleanup { description += "; permanently delete source after success" } + let warning = [destinationWarning(dir), cleanup ? "The original file or folder and all its contents will be permanently deleted after the ZIP is created. This cannot be undone. Following actions apply to the ZIP." : nil] + .compactMap { $0 }.joined(separator: " ") return ActionPlan( kind: action.kind, - description: conflicts ? "Compress to \(target) (renamed to avoid an existing archive)" : "Compress to \(target)", + description: description, sourcePath: path, targetPath: target, status: .wouldRun, - finalPath: path, - copiedPath: target, - isTerminal: false + finalPath: cleanup ? target : path, + copiedPath: cleanup ? nil : target, + isTerminal: false, + warning: warning.isEmpty ? nil : warning ) } } diff --git a/Sources/ForelCore/Models/RuleSchema.swift b/Sources/ForelCore/Models/RuleSchema.swift index 54df84d..10681ec 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -196,6 +196,7 @@ public enum ActionParam { public static let onConflict = "on_conflict" public static let pattern = "pattern" public static let archiveName = "archive_name" + public static let deleteSourceAfterCompression = "delete_source_after_compression" public static let tags = "tags" public static let color = "color" public static let script = "script" @@ -278,9 +279,9 @@ public extension ActionKind { /// have none, instead of showing an empty "No options" popover. var hasOptions: Bool { switch self { - case .moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress: + case .moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress, .compress: return true - case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete, .compress: + case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete: return false } } @@ -307,7 +308,8 @@ public extension ActionKind { return [ActionParamSpec(key: ActionParam.libraryType, kind: .libraryType), ActionParamSpec(key: ActionParam.targetPlaylist, kind: .playlist)] case .compress: - return [ActionParamSpec(key: ActionParam.archiveName, kind: .archiveName)] + return [ActionParamSpec(key: ActionParam.archiveName, kind: .archiveName), + ActionParamSpec(key: ActionParam.destination, kind: .folderPath)] case .moveToTrash, .delete, .uncompress: return [] } diff --git a/Sources/ForelCore/Models/RuleValidator.swift b/Sources/ForelCore/Models/RuleValidator.swift index a874615..9502611 100644 --- a/Sources/ForelCore/Models/RuleValidator.swift +++ b/Sources/ForelCore/Models/RuleValidator.swift @@ -51,6 +51,7 @@ public enum RuleValidator { case .compress: do { _ = try ActionExecutor.archiveFileName(pattern: action.params[ActionParam.archiveName]?.stringValue ?? "", sourceName: "file") + _ = try ActionExecutor.archiveDestinationPath(action.params[ActionParam.destination]?.stringValue ?? "") } catch { return Issue(message: String(describing: error)) } diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator+DisconnectedVolumes.swift b/Sources/ForelCore/Watcher/WatcherCoordinator+DisconnectedVolumes.swift index 586a12b..73c0997 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator+DisconnectedVolumes.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator+DisconnectedVolumes.swift @@ -86,7 +86,7 @@ extension WatcherCoordinator { /// `history` were headed to and that aren't connected. static func disconnectedVolumes(in history: [HistoryEntry], rules: [Rule]) -> [String] { var volumes = Set() - for entry in history where entry.status == .failed && [.moveToFolder, .copyToFolder].contains(entry.actionKind) { + for entry in history where entry.status == .failed && [.moveToFolder, .copyToFolder, .compress].contains(entry.actionKind) { guard let rule = rules.first(where: { $0.id == entry.ruleId }) else { continue } for action in rule.actions where action.kind == entry.actionKind { guard let destination = action.params[ActionParam.destination]?.stringValue, diff --git a/Tests/ForelCoreTests/RuleSchemaTests.swift b/Tests/ForelCoreTests/RuleSchemaTests.swift index 2dccea1..2419d2f 100644 --- a/Tests/ForelCoreTests/RuleSchemaTests.swift +++ b/Tests/ForelCoreTests/RuleSchemaTests.swift @@ -46,7 +46,7 @@ import Foundation } @Test func hasOptionsMatchesActionsThatExposeAnOptionsPopover() { - let expectedWithOptions: Set = [.moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress] + let expectedWithOptions: Set = [.moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress, .compress] for kind in RuleSchema.actionKinds { #expect(kind.hasOptions == expectedWithOptions.contains(kind), "\(kind) hasOptions mismatch") }