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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>.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
Expand Down
96 changes: 96 additions & 0 deletions Sources/ForelApp/Views/RuleEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand All @@ -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<Bool> {
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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Sources/ForelApp/Views/RuleListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: " · "))
}
}

Expand Down
129 changes: 128 additions & 1 deletion Sources/ForelCore/Engine/ActionExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
}

Expand All @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ForelCore/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 10 additions & 2 deletions Sources/ForelCore/Models/RuleSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -211,6 +213,7 @@ public enum ActionParam {
public enum ActionParamKind: Sendable, Equatable {
case folderPath
case renamePattern
case archiveName
case tags
case colorLabel
case script
Expand Down Expand Up @@ -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"
}
}
Expand All @@ -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"
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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 []
}
Expand Down Expand Up @@ -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]),
Expand Down
7 changes: 7 additions & 0 deletions Sources/ForelCore/Models/RuleValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading