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 @@ -13,6 +13,7 @@ All notable changes to Forel are documented here. Format loosely follows
### Added
- Added an Open Application action that launches a chosen app, with an option to pass the matched file to it.
- Watcher notifications: a new toggle in Settings lets you enable or disable system notifications when automatic rules process files.
- Move to Folder, Copy to Folder, and Rename actions now support {year}, {month}, and {day} tokens — e.g. a destination of Backup/{year}-{month} automatically sorts files into monthly folders, creating them as needed.

### Changed
- Settings now opens in its own window, with a "Settings…" item under the Forel menu and the standard ⌘, shortcut, instead of being a pane inside the main window.
Expand Down
169 changes: 153 additions & 16 deletions Sources/ForelApp/Views/RuleEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ private struct ActionRow: View {
@ViewBuilder private var actionParams: some View {
switch action.kind {
case .moveToFolder, .copyToFolder:
FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination))
DestinationFolderField(path: paramBinding(ActionParam.destination))
case .rename:
RenamePatternEditor(pattern: paramBinding(ActionParam.pattern), cleanFileName: action.params[ActionParam.cleanFileName]?.boolValue == true)
case .addTag, .removeTag:
Expand Down Expand Up @@ -1290,6 +1290,122 @@ private struct TagTokensEditor: View {
}
}

/// An editable destination path for Move/Copy actions, with a "Choose…"
/// button for picking a literal base folder and token chips for embedding
/// dynamic subfolders — e.g. `{year}-{month}` under a chosen base resolves
/// to `.../2026-07`, auto-created on the way in (`ActionExecutor` already
/// creates any missing destination folder).
private struct DestinationFolderField: View {
@Binding var path: String

private let tokens: [(placeholder: String, label: String)] = [
("{year}", "year"),
("{month}", "month"),
("{day}", "day"),
("{current_date}", "current date"),
]

var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
GlassField(placeholder: "Destination folder, e.g. ~/Downloads/Backup/{year}-{month}", text: $path)
Button("Choose…", action: choose).buttonStyle(SecondaryButtonStyle())
}

if hasDestination {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(tokens, id: \.placeholder) { token in
tokenButton(token)
}
Spacer(minLength: 0)
}
}
}

if !preview.isEmpty {
Text(preview)
.font(.system(size: 11))
.foregroundStyle(ForelTheme.secondaryText)
}
}
}

private var preview: String {
let candidate = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !candidate.isEmpty else { return "" }
return "Preview: \(TokenExpander.previewExpand(candidate))"
}

/// A token chip appended to an empty destination would leave a bare
/// `{year}` with no base folder to sit under, which isn't a usable
/// path — chips only make sense once a destination has been chosen or
/// typed.
private var hasDestination: Bool {
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

private func choose() {
if let chosen = FolderPicker.choose(startingAt: path) {
path = chosen
}
}

private func tokenButton(_ token: (placeholder: String, label: String)) -> some View {
let isActive = path.contains(token.placeholder)
return Button {
toggle(token.placeholder)
} label: {
Text(token.label)
.font(.system(size: 12, weight: .medium))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(isActive ? ForelTheme.accent.opacity(0.22) : ForelTheme.surface))
.overlay(Capsule().strokeBorder(isActive ? ForelTheme.accent : ForelTheme.surfaceBorder))
.foregroundStyle(isActive ? ForelTheme.accent : ForelTheme.secondaryText)
}
.buttonStyle(.plain)
}

/// Chips join with `/` (a new path component) rather than `-`, matching
/// what makes sense for a *folder path* — unlike `RenamePatternEditor`'s
/// chips, which join with `-` for a single filename segment.
private func toggle(_ token: String) {
if path.contains(token) {
path = removePathToken(token, from: path)
} else {
insert(token)
}
}

private func insert(_ token: String) {
guard !path.contains(token) else { return }
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
path = token
} else {
path = "\(trimmed)\(trimmed.hasSuffix("/") ? "" : "/")\(token)"
}
}

private func removePathToken(_ token: String, from value: String) -> String {
var updated = value.replacingOccurrences(of: token, with: "")
while updated.contains("//") {
updated = updated.replacingOccurrences(of: "//", with: "/")
}
while updated.contains("--") {
updated = updated.replacingOccurrences(of: "--", with: "-")
}
updated = updated
.replacingOccurrences(of: "/-", with: "/")
.replacingOccurrences(of: "-/", with: "/")
if updated.count > 1, updated.hasSuffix("/") {
updated.removeLast()
}
return updated.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

private struct RenamePatternEditor: View {
@Binding var pattern: String
let cleanFileName: Bool
Expand All @@ -1300,6 +1416,9 @@ private struct RenamePatternEditor: View {
("{date_modified}", "date modified"),
("{date_created}", "date created"),
("{current_date}", "current date"),
("{year}", "year"),
("{month}", "month"),
("{day}", "day"),
("{size}", "size"),
]

Expand All @@ -1316,22 +1435,21 @@ private struct RenamePatternEditor: View {
}
}

Text(preview)
.font(.system(size: 11))
.foregroundStyle(ForelTheme.secondaryText)
if !preview.isEmpty {
Text(preview)
.font(.system(size: 11))
.foregroundStyle(ForelTheme.secondaryText)
}
}
}

private var preview: String {
let candidate = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !candidate.isEmpty else { return "Preview: " }
guard !candidate.isEmpty else { return "" }
if candidate.contains("/") {
return "⚠️ Pattern contains '/' which is not allowed in filenames"
}
let previewName = candidate
.replacingOccurrences(of: "{name}", with: "file")
.replacingOccurrences(of: "{extension}", with: "txt")
.replacingOccurrences(of: "{current_date}", with: dateString())
let previewName = TokenExpander.previewExpand(candidate)
let displayName = cleanFileName ? ActionExecutor.cleanFileName(previewName) : previewName
if displayName == "." || displayName == ".." {
return "⚠️ Pattern resolves to '\(displayName)' which is not a valid filename"
Expand All @@ -1345,16 +1463,10 @@ private struct RenamePatternEditor: View {
return "Preview: \(displayName)"
}

private func dateString() -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.string(from: Date())
}

private func tokenButton(_ token: (placeholder: String, label: String)) -> some View {
let isActive = pattern.contains(token.placeholder)
return Button {
insert(token.placeholder)
toggle(token.placeholder)
} label: {
Text(token.label)
.font(.system(size: 12, weight: .medium))
Expand All @@ -1367,6 +1479,14 @@ private struct RenamePatternEditor: View {
.buttonStyle(.plain)
}

private func toggle(_ token: String) {
if pattern.contains(token) {
pattern = removePatternToken(token, from: pattern)
} else {
insert(token)
}
}

private func insert(_ token: String) {
guard !pattern.contains(token) else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
Expand All @@ -1376,6 +1496,23 @@ private struct RenamePatternEditor: View {
pattern = "\(trimmed)\(trimmed.hasSuffix("-") || trimmed.hasSuffix(".") ? "" : "-")\(token)"
}
}

private func removePatternToken(_ token: String, from value: String) -> String {
var updated = value.replacingOccurrences(of: token, with: "")
while updated.contains("--") {
updated = updated.replacingOccurrences(of: "--", with: "-")
}
updated = updated
.replacingOccurrences(of: "-.", with: ".")
.replacingOccurrences(of: ".-", with: ".")
if updated.hasPrefix("-") || updated.hasPrefix(".") {
updated.removeFirst()
}
if updated.hasSuffix("-") || updated.hasSuffix(".") {
updated.removeLast()
}
return updated.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

private enum DateValueFormatter {
Expand Down
61 changes: 20 additions & 41 deletions Sources/ForelCore/Engine/ActionExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,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(action, path: path, "MoveToFolder")
return try moveIntoDir(path: path, destDir: destDir, resolution: conflictResolution(action))
case .copyToFolder:
return try copyToFolder(action, path: path)
Expand Down Expand Up @@ -222,6 +222,15 @@ public enum ActionExecutor {
return value
}

/// Resolves a Move/Copy action's destination folder, substituting any
/// `{year}`/`{month}`/`{day}`/etc. tokens against `path` via
/// `TokenExpander` — e.g. `.../Backup/{year}-{month}` becomes
/// `.../Backup/2026-07`.
private static func expandedDestination(_ action: Action, path: String, _ kind: String) throws -> String {
let raw = try stringParam(action, ActionParam.destination, kind)
return try TokenExpander.expand(raw, path: path)
}

/// Moves `path` into `destDir` (created if needed). `resolution` decides
/// what happens if a file with the same name is already there (see
/// `resolveDestination`). Trash/delete have no user-facing conflict
Expand All @@ -236,7 +245,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(action, path: path, "CopyToFolder")
try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
let fileName = (path as NSString).lastPathComponent
let naiveDest = (destDir as NSString).appendingPathComponent(fileName)
Expand Down Expand Up @@ -871,7 +880,8 @@ public enum ActionExecutor {

switch action.kind {
case .moveToFolder:
let destDir = action.params[ActionParam.destination]?.stringValue ?? ""
let rawDestDir = action.params[ActionParam.destination]?.stringValue ?? ""
let destDir = try TokenExpander.expand(rawDestDir, path: 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 @@ -922,7 +932,8 @@ public enum ActionExecutor {
isTerminal: true
)
case .copyToFolder:
let destDir = action.params[ActionParam.destination]?.stringValue ?? ""
let rawDestDir = action.params[ActionParam.destination]?.stringValue ?? ""
let destDir = try TokenExpander.expand(rawDestDir, path: path)
let naiveTarget = (destDir as NSString).appendingPathComponent(fileName)
let resolution = conflictResolution(action)
let conflicts = FileManager.default.fileExists(atPath: naiveTarget)
Expand Down Expand Up @@ -1312,44 +1323,12 @@ public enum ActionExecutor {
(path as NSString).standardizingPath
}

private static func formatFileSize(_ bytes: UInt64) -> String {
let kb: Double = 1024
let mb = 1024 * kb
let gb = 1024 * mb
let value = Double(bytes)
if value >= gb { return String(format: "%.1fGB", value / gb) }
if value >= mb { return String(format: "%.1fMB", value / mb) }
if value >= kb { return String(format: "%.1fKB", value / kb) }
return "\(bytes)B"
}

/// Substitutes tokens in rename patterns. Supported tokens: `{name}`,
/// `{extension}`, `{date_created}`, `{date_modified}`, `{current_date}`, `{size}`.
/// Substitutes tokens in rename patterns via `TokenExpander`. Supported
/// tokens: `{name}`, `{extension}`, `{current_date}`, `{year}`,
/// `{month}`, `{day}`, `{date_created}`, `{date_modified}`, `{size}`.
private static func applyRenamePattern(_ pattern: String, path: String) throws -> String {
let url = URL(fileURLWithPath: path)
let stem = url.deletingPathExtension().lastPathComponent
let ext = url.pathExtension
let today = Date()

let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "yyyy-MM-dd"
dayFormatter.timeZone = .current

var result = pattern
.replacingOccurrences(of: "{name}", with: stem)
.replacingOccurrences(of: "{extension}", with: ext)
.replacingOccurrences(of: "{current_date}", with: dayFormatter.string(from: today))

if result.contains("{date_modified}") || result.contains("{date_created}") || result.contains("{size}") {
let attrs = try FileManager.default.attributesOfItem(atPath: path)
let modified = (attrs[.modificationDate] as? Date) ?? Date()
let created = (attrs[.creationDate] as? Date) ?? Date()
let size = (attrs[.size] as? UInt64) ?? 0
result = result
.replacingOccurrences(of: "{date_modified}", with: dayFormatter.string(from: modified))
.replacingOccurrences(of: "{date_created}", with: dayFormatter.string(from: created))
.replacingOccurrences(of: "{size}", with: formatFileSize(size))
}
let ext = URL(fileURLWithPath: path).pathExtension
var result = try TokenExpander.expand(pattern, path: path)

if result.isEmpty {
throw ActionError("rename pattern produced empty filename")
Expand Down
Loading
Loading