AutoShelf - Auto-organize files on your Mac | Product Hunt AutoShelf - Auto-organize files on your Mac | Product Hunt
← Back to journey

13 Actions, One Pipeline: Building an Extensible File Action System

When a file matches a rule's conditions, AutoShelf runs each action in sequence through a pipeline. Each action is an asynchronous operation with error handling.

13 Actions, One Pipeline: Building an Extensible File Action System

AutoShelf ships with 13 actions. Move, copy, trash, rename, tag, archive to zip, optimize image, duplicate and optimize, copy to Photos, move to Photos, convert image, convert video, convert audio.

When I started, I had three actions: move, copy, and trash. The list grew organically as users asked for more. The challenge was designing the action system to be extensible from the start, so adding a new action did not require rewriting the entire pipeline.

The Action Enum

Every action is a case in the RuleAction enum. Some actions carry associated values, like the destination folder bookmark for move operations or the pattern string for renames.

enum RuleAction: Codable, Equatable, Sendable {
    case moveTo(bookmarkData: Data)
    case copyTo(bookmarkData: Data)
    case trash
    case rename(pattern: String)
    case addTag(String)
    case archiveToZip(bookmarkData: Data)
    case optimizeAndOverwrite
    case duplicateAndOptimize(bookmarkData: Data)
    case copyToPhotos
    case moveToPhotos
    case convertImage(format: String)
    case convertVideo(config: String)
    case convertAudio(format: String)
}

The bookmarkData values are security-scoped bookmarks, encoded as base64 strings in the JSON serialization. This keeps the rule data self-contained. You can export a rule, send it to someone else, and when they import it, the bookmarks resolve (or prompt for re-selection if they are stale).

The Dispatch Pipeline

The FileOperator actor has a single execute method that switches on the action and dispatches to the appropriate handler:

func execute(action: RuleAction, on fileURL: URL) async throws -> (description: String, resultURL: URL?) {
    switch action {
    case .moveTo(let bookmarkData):
        return try await moveToFolder(bookmarkData, file: fileURL)
    case .copyTo(let bookmarkData):
        return try await copyToFolder(bookmarkData, file: fileURL)
    case .trash:
        return try await trashFile(fileURL)
    case .rename(let pattern):
        return try await renameFile(fileURL, pattern: pattern)
    case .addTag(let tag):
        return try await addTag(tag, to: fileURL)
    case .archiveToZip(let bookmarkData):
        return try await archiveToZip(fileURL, destinationDir: resolveOnly(bookmarkData))
    case .optimizeAndOverwrite:
        return try await optimizeImage(at: fileURL, overwrite: true)
    case .duplicateAndOptimize(let bookmarkData):
        return try await optimizeImage(at: fileURL, destinationDir: resolveOnly(bookmarkData))
    case .copyToPhotos, .moveToPhotos:
        return try await importToPhotos(fileURL, move: action == .moveToPhotos)
    case .convertImage(let format):
        return try await convertImage(at: fileURL, to: format)
    case .convertVideo(let config):
        return try await convertVideo(at: fileURL, config: config)
    case .convertAudio(let format):
        return try await convertAudio(at: fileURL, to: format)
    }
}

Each handler is a separate method. This keeps the code organized and testable. Adding a new action means adding a new case to the enum and a new method to the actor. Nothing else changes.

The Predict Result Feature

Before executing an action, the engine can predict where the file will end up. This is used for the "processed files" tracking, so the engine does not try to process a file that was just moved by another rule.

func predictResultPath(action: RuleAction, for fileURL: URL) -> URL? {
    switch action {
    case .moveTo(let bookmarkData):
        guard let dest = resolveOnly(bookmarkData) else { return nil }
        return dest.appendingPathComponent(fileURL.lastPathComponent)
    case .trash:
        return nil // Trash is a black hole
    case .rename(let pattern):
        let newName = applyPattern(pattern, to: fileURL)
        return fileURL.deletingLastPathComponent().appendingPathComponent(newName)
    default:
        return nil
    }
}

This prevents feedback loops where Rule A moves a file, triggering Rule B which matches the moved file and moves it again.

Multi-Action Chains

One rule can have multiple actions. A file that matches a "Clean up downloads" rule might be tagged as "Archived" and then moved to an archive folder. The actions execute in order, and each action sees the result of the previous one.

for action in rule.actions {
    let result = try await fileOperator.execute(action: action, on: currentURL)
    if let resultURL = result.resultURL {
        currentURL = resultURL
    }
}

This is gated behind the Pro purchase. Free users get single-action rules. Pro users can chain actions for complex workflows.

The Three Categories of Actions

The 13 actions fall into three categories:

Simple file operations: moveTo, copyTo, trash, rename, addTag. These use only Foundation's FileManager and NSURL resource values. They are fast, reliable, and work on any file type.

Media operations: optimizeAndOverwrite, duplicateAndOptimize, convertImage, convertVideo, convertAudio. These use Core Graphics, ImageIO, and AVFoundation. They are slower because they process file contents, but they give users powerful media management capabilities.

Platform integration: archiveToZip, copyToPhotos, moveToPhotos. These bridge into other system services. ZIPFoundation for archiving, Photos framework for media library integration.

Static Metadata for Actions

To support the rule editor UI without hardcoding action properties everywhere, each action type carries static metadata:

enum ActionType: String, CaseIterable, Codable, Sendable {
    case moveTo, copyTo, trash, rename, addTag, archiveToZip,
         optimizeAndOverwrite, duplicateAndOptimize,
         copyToPhotos, moveToPhotos,
         convertImage, convertVideo, convertAudio

    var isProOnly: Bool {
        switch self {
        case .optimizeAndOverwrite, .duplicateAndOptimize,
             .copyToPhotos, .moveToPhotos,
             .convertImage, .convertVideo, .convertAudio:
            return true
        default:
            return false
        }
    }

    var needsDestinationFolder: Bool {
        switch self {
        case .moveTo, .copyTo, .archiveToZip, .duplicateAndOptimize:
            return true
        default:
            return false
        }
    }

    var needsTextValue: Bool {
        switch self {
        case .rename, .addTag, .convertImage, .convertVideo, .convertAudio:
            return true
        default:
            return false
        }
    }
}

This metadata drives the UI. If an action needs a destination folder, the editor shows a folder picker. If it needs text input, it shows a text field. If it is Pro-only, it shows a locked badge.

Adding a New Action

Here is what it takes to add a new action:

  1. Add a case to RuleAction enum with associated values
  2. Add a case to ActionType enum with static metadata
  3. Add a handler method to FileOperator
  4. Update the execute switch to dispatch to the new handler
  5. Add the UI for the action's configuration in RuleEditorDrawer
  6. Add the localization string

This usually takes me 2-3 hours for a simple action like "add tag" and a day for something complex like video conversion. The pipeline design makes it predictable and low-risk.

What I Learned

The single-dispatch pattern (one switch statement that routes to handlers) has been remarkably stable. I have not had to change the dispatch logic since I wrote it. Every new action just adds a new case and a new method.

The actor isolation also proved to be the right choice. File operations are inherently sequential for the same file, but could be parallel for different files. The actor serializes access to the processed files set while allowing concurrent downloads and other system operations to proceed independently.

The only action I regret is archiveToZip. It requires an external dependency (ZIPFoundation) and the use case is narrow. Most users just want to move or trash files. The archive feature is used by maybe 5% of power users. I keep it because it is one of the templates ("Auto-delete DMGs" uses a simple trash, not archive), but I would not build it again.