diff --git a/CHANGELOG.md b/CHANGELOG.md index caf84ed..e5c5584 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. +- 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. diff --git a/Sources/ForelApp/Design/Components.swift b/Sources/ForelApp/Design/Components.swift index 7c22bbd..e062611 100644 --- a/Sources/ForelApp/Design/Components.swift +++ b/Sources/ForelApp/Design/Components.swift @@ -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) + } } } diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index 0ed76fd..191592c 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -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: diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index 97605ac..57357e1 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -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) @@ -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. @@ -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 @@ -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 @@ -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) diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index b57d736..8bb0138 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -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")