Skip to content
Merged
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 @@ -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.
- 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
- Date Added conditions no longer crash when reading a file's timestamp.
Expand Down
47 changes: 31 additions & 16 deletions Sources/ForelApp/Design/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,29 +499,44 @@ struct GlassField: View {
}
}

/// A read-only folder path field with a "Choose…" button opening the native
/// macOS Finder directory picker. Used everywhere a folder is selected.
/// A folder path field with a "Choose…" button opening the native macOS
/// Finder directory picker, and an editable text field underneath so paths
/// with date tokens (e.g. `/Volumes/Archive/{year}/{month}`) can be typed.
/// Used everywhere a folder is selected.
struct FolderField: View {
let placeholder: String
@Binding var path: String
/// Shown under the field only while the user is typing in it.
var hint: String? = nil
@FocusState private var isEditing: Bool

var body: some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "folder").font(.system(size: 11)).foregroundStyle(ForelTheme.secondaryText)
Text(path.isEmpty ? placeholder : path)
.font(.system(size: 12))
.foregroundStyle(path.isEmpty ? ForelTheme.secondaryText : ForelTheme.primaryText)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "folder").font(.system(size: 11)).foregroundStyle(ForelTheme.secondaryText)
TextField(placeholder, text: $path)
.textFieldStyle(.plain)
.font(.system(size: 12))
.foregroundStyle(ForelTheme.primaryText)
.lineLimit(1)
.focused($isEditing)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(ForelTheme.surface))
.overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(ForelTheme.surfaceBorder))

Button("Choose…", action: choose).buttonStyle(SecondaryButtonStyle())
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(ForelTheme.surface))
.overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(ForelTheme.surfaceBorder))

Button("Choose…", action: choose).buttonStyle(SecondaryButtonStyle())
if let hint, isEditing {
Text(hint)
.font(.system(size: 10))
.foregroundStyle(ForelTheme.secondaryText)
.padding(.leading, 10)
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion Sources/ForelApp/Views/RuleEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,11 @@ private struct ActionRow: View {
@ViewBuilder private var actionParams: some View {
switch action.kind {
case .moveToFolder, .copyToFolder:
FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination))
FolderField(
placeholder: "Destination folder",
path: paramBinding(ActionParam.destination),
hint: "Add {year}, {month}, or {day} for dated folders, e.g. …/Archive/{year}/{month}"
)
case .rename:
RenamePatternEditor(pattern: paramBinding(ActionParam.pattern), cleanFileName: action.params[ActionParam.cleanFileName]?.boolValue == true)
case .addTag, .removeTag:
Expand Down
24 changes: 20 additions & 4 deletions Sources/ForelCore/Engine/ActionExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ public enum ActionExecutor {
public static func execute(_ action: Action, path: String) throws -> Applied {
switch action.kind {
case .moveToFolder:
let destDir = try stringParam(action, ActionParam.destination, "MoveToFolder")
let destDir = try expandedDestination(stringParam(action, ActionParam.destination, "MoveToFolder"), fileAtPath: path)
return try moveIntoDir(path: path, destDir: destDir, resolution: conflictResolution(action))
case .copyToFolder:
return try copyToFolder(action, path: path)
Expand Down Expand Up @@ -244,6 +244,22 @@ public enum ActionExecutor {
}
}

/// Resolves the date tokens a destination template may contain —
/// `{year}`, `{month}`, `{day}`, with month/day zero-padded — from the
/// file's creation date (the date a "sort my downloads by month" workflow
/// means; it survives moves). Unknown `{...}` sequences are left as-is.
private static func expandedDestination(_ template: String, fileAtPath: String) -> String {
guard template.contains("{") else { return template }
let attrs = try? FileManager.default.attributesOfItem(atPath: fileAtPath)
let date = (attrs?[.creationDate] as? Date) ?? (attrs?[.modificationDate] as? Date) ?? Date()
let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
var result = template
if let year = parts.year { result = result.replacingOccurrences(of: "{year}", with: String(year)) }
if let month = parts.month { result = result.replacingOccurrences(of: "{month}", with: String(format: "%02d", month)) }
if let day = parts.day { result = result.replacingOccurrences(of: "{day}", with: String(format: "%02d", day)) }
return result
}

/// Runs `body` as is. Only when it fails and the destination is on a disk
/// or share that isn't mounted, the error says so by name instead of the
/// file system's message, which can blame the startup disk.
Expand All @@ -263,7 +279,7 @@ public enum ActionExecutor {
}

private static func copyToFolder(_ action: Action, path: String) throws -> Applied {
let destDir = try stringParam(action, ActionParam.destination, "CopyToFolder")
let destDir = try expandedDestination(stringParam(action, ActionParam.destination, "CopyToFolder"), fileAtPath: path)
return try explainingDisconnectedVolume(destDir) {
try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
let fileName = (path as NSString).lastPathComponent
Expand Down Expand Up @@ -900,7 +916,7 @@ public enum ActionExecutor {

switch action.kind {
case .moveToFolder:
let destDir = action.params[ActionParam.destination]?.stringValue ?? ""
let destDir = expandedDestination(action.params[ActionParam.destination]?.stringValue ?? "", fileAtPath: path)

// A file already directly in its destination folder is a no-op,
// never a rename-to-avoid-itself collision — and since nothing
Expand Down Expand Up @@ -952,7 +968,7 @@ public enum ActionExecutor {
warning: destinationWarning(destDir)
)
case .copyToFolder:
let destDir = action.params[ActionParam.destination]?.stringValue ?? ""
let destDir = expandedDestination(action.params[ActionParam.destination]?.stringValue ?? "", fileAtPath: path)
let naiveTarget = (destDir as NSString).appendingPathComponent(fileName)
let resolution = conflictResolution(action)
let conflicts = FileManager.default.fileExists(atPath: naiveTarget)
Expand Down
44 changes: 44 additions & 0 deletions Tests/ForelCoreTests/ActionExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,50 @@ import Foundation
#expect(!FileManager.default.fileExists(atPath: (dir.path as NSString).appendingPathComponent("report-archived.txt.txt")))
}

@Test func moveToFolderExpandsDateTokensFromTheFileCreationDate() throws {
let dir = TempDir()
let file = dir.file("invoice.pdf", contents: "hello")
let calendar = Calendar.current
let created = calendar.date(from: DateComponents(year: 2026, month: 3, day: 5, hour: 12))!
try FileManager.default.setAttributes([.creationDate: created], ofItemAtPath: file)
let destination = dir.dir("Archive/{year}/{month}-{day}")

let action = makeAction(.moveToFolder, .object([ActionParam.destination: .string(destination)]))
let plan = try ActionExecutor.plan(action, path: file)
let expectedDir = (dir.path as NSString).appendingPathComponent("Archive/2026/03-05")
#expect(plan.targetPath == (expectedDir as NSString).appendingPathComponent("invoice.pdf"))

let applied = try ActionExecutor.execute(action, path: file)
#expect(applied.newPath == (expectedDir as NSString).appendingPathComponent("invoice.pdf"))
#expect(try String(contentsOfFile: applied.newPath, encoding: .utf8) == "hello")
}

@Test func copyToFolderExpandsDateTokensAndKeepsTheSource() throws {
let dir = TempDir()
let file = dir.file("shot.png", contents: "png")
let calendar = Calendar.current
let created = calendar.date(from: DateComponents(year: 2026, month: 7, day: 14, hour: 12))!
try FileManager.default.setAttributes([.creationDate: created], ofItemAtPath: file)
let destination = dir.dir("Screenshots/{year}/{month}")

let action = makeAction(.copyToFolder, .object([ActionParam.destination: .string(destination)]))
let applied = try ActionExecutor.execute(action, path: file)

#expect(FileManager.default.fileExists(atPath: file))
#expect(applied.copiedPath == (dir.path as NSString).appendingPathComponent("Screenshots/2026/07/shot.png"))
}

@Test func destinationTokensWithUnknownBracesAreLeftAlone() throws {
let dir = TempDir()
let file = dir.file("note.txt", contents: "hi")
let destination = dir.dir("Keep {literal}/{year}")

let applied = try ActionExecutor.execute(makeAction(.moveToFolder, .object([ActionParam.destination: .string(destination)])), path: file)

let expectedDir = (dir.path as NSString).appendingPathComponent("Keep {literal}/\(Calendar.current.component(.year, from: Date()))")
#expect(applied.newPath == (expectedDir as NSString).appendingPathComponent("note.txt"))
}

@Test func uncompressZipExtractsSingleRootItemAndMovesArchiveAway() throws {
let dir = TempDir()
let staging = dir.dir("staging")
Expand Down
Loading