diff --git a/CHANGELOG.md b/CHANGELOG.md index 28cbc87..3a72555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Forel are documented here. Format loosely follows ### Added - Copy one or more rules to another watched folder without recreating their conditions and actions. - 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. - Move to folder and Copy to folder destinations now accept {year}, {month}, and {day} date tokens (e.g. ~/Archive/{year}/{month}), resolved from each file's creation date. The destination field can now also be typed into. ### Fixed diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index 5d19cdf..280750f 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -947,6 +947,8 @@ private struct ActionRow: View { .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .frame(minHeight: 32, alignment: .center) + case .compress: + ArchiveNameEditor(pattern: paramBinding(ActionParam.archiveName)) case .moveToTrash, .delete: Text("No parameters") .font(.system(size: 11)) @@ -1026,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)) @@ -1046,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 @@ -1368,6 +1416,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. The .zip extension is added automatically; choose a destination in Options.") + .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 5c1293b..7b278fe 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -659,6 +659,13 @@ 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: + let name = action.params[ActionParam.archiveName]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + 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 57357e1..a3788d2 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(action, path: path) } } @@ -342,6 +344,107 @@ public enum ActionExecutor { return Applied(newPath: plan.target, undo: .none) } + /// 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 = try compressionDirectory(action, path: path) + let fileName = try compressArchiveName(action, path: path) + 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) + } + } + + 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 { + 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 /// configured conflict resolution: `.rename` numbers the file instead; /// `.replace` sends whatever is already at `naiveDest` to the Trash @@ -1206,6 +1309,30 @@ public enum ActionExecutor { copiedPath: nil, isTerminal: false ) + case .compress: + 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) + // 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: description, + sourcePath: path, + targetPath: target, + status: .wouldRun, + finalPath: cleanup ? target : path, + copiedPath: cleanup ? nil : target, + isTerminal: false, + warning: warning.isEmpty ? nil : warning + ) } } @@ -1224,7 +1351,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..10681ec 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -195,6 +195,8 @@ 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 deleteSourceAfterCompression = "delete_source_after_compression" public static let tags = "tags" public static let color = "color" public static let script = "script" @@ -211,6 +213,7 @@ public enum ActionParam { public enum ActionParamKind: Sendable, Equatable { case folderPath case renamePattern + case archiveName case tags case colorLabel case script @@ -247,6 +250,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 +268,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" } } @@ -274,7 +279,7 @@ 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: return false @@ -302,6 +307,9 @@ public extension ActionKind { case .importToLibrary: return [ActionParamSpec(key: ActionParam.libraryType, kind: .libraryType), ActionParamSpec(key: ActionParam.targetPlaylist, kind: .playlist)] + case .compress: + return [ActionParamSpec(key: ActionParam.archiveName, kind: .archiveName), + ActionParamSpec(key: ActionParam.destination, kind: .folderPath)] case .moveToTrash, .delete, .uncompress: return [] } @@ -349,7 +357,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/Sources/ForelCore/Models/RuleValidator.swift b/Sources/ForelCore/Models/RuleValidator.swift index 670636a..9502611 100644 --- a/Sources/ForelCore/Models/RuleValidator.swift +++ b/Sources/ForelCore/Models/RuleValidator.swift @@ -48,6 +48,13 @@ 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") + _ = try ActionExecutor.archiveDestinationPath(action.params[ActionParam.destination]?.stringValue ?? "") + } 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/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/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index 8bb0138..18450e6 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -65,6 +65,139 @@ 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(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") + 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 moveToFolderExpandsDateTokensFromTheFileCreationDate() throws { let dir = TempDir() let file = dir.file("invoice.pdf", contents: "hello") 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") + } + } +} 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") }