Indie app developer

Back to the top

Adding Shortcuts To An App - Part Two

In the last post we looked at starting from scratch with a blank Xcode project and adding a basic Shortcuts action that made text uppercase.

In this post we’re going to dive deeper and look at different types of parameters including handling arrays, enums, nested parameters and calculated options.

We’re going to be creating a new Shortcuts action that will accept multiple files of any type and rename them with a formatted date (either prepended or appended). We’ll also optionally offer to change the case and then output the files back into Shortcuts.

This project will be updated on GitHub with each post.

Let’s get started!


Step One

Navigate to the Intents.intentdefinition file, hit the + and let’s add a new intent called RenameFiles.

Screenshot 1

Step Two

Add a description for the intent and add our first parameter. We’ll call it files.

Set the type to File and since we want to be able to rename multiple files at once, tick Supports multiple values.

Under file type, we could leave it on Text or Image if we only wanted to support those types of files but we want to accept any files so we’ll select Custom.

Now we can define the UTIs (uniform type identifiers) of files we want to accept in our Files parameter.

UTIs are strings that Apple use to identify file types. If we wanted to allow only PDFs, we could add com.adobe.pdf or if we want to broadly support movie files we could use public.movie.

Screenshot 2

We want to support all file types so we’ll use the base type that encompasses all other UTIs: public.item.

Now let’s add a prompt and a validation error and untick Intent is eligible for Siri Suggestions as per the previous post.

Screenshot 3

Step Three

Next we’ll add a new parameter called dateFormat. This will be a pre-populated list of calculated values so we’ll tick Valid values are provided dynamically and add a validation error code.

Screenshot 4

Step Four

We’re going to give the user the option to change the case of the title when renaming. Let’s add a new Boolean parameter called changeCase. Add a prompt and check the default value is false.

Screenshot 5

Step Five

If the user elects to change the case then we want to give them the option of either uppercase or lowercase.

To do that, we’re going to add a new parameter called newCase that will be only be shown when our changeCase paratmeter is set to true.

Under type select Add new enum…. We’ll call our enum RenameCase and set the display name to Case. This will show in the title of the window when the user is choosing which case. Add lowercase and uppercase as our options.

Screenshot 6

Step Six

Head back to our Intent and notice the type is now Case. We can now set a default value to uppercase.

To make sure our new parameter only shows when changeCase is true, we’ll select parent parameter, has exact value and true.

Screenshot 7

It’s worth noting you can only have one level of nesting parent/child parameters. We couldn’t now have a new parameter that only shows if the “newCase” is “uppercase”, for example.


Step Seven

We’ll add a final parameter which allows the user to choose where the date is positioned in the new filename. We’ll call the parameter position and set the type to a new RenamePosition enum, containing prepend and append.

We’ll capitalise the display names since these are going to be the first words in our action’s summary.

Set the default value to Prepend.

Screenshot 8

Step Eight

Set the input and key parameters to files since the previous action is likely to be providing the files to rename to this one, so it makes sense to automatically populate the parameter.

In the intent, notice our summary has two supported combinations. This is because we have a parent parameter. We can now choose different summaries depending on whether changeCase toggled on.

We’ll use the same description for both summaries. We’ll leave the changeCase and case parameters out of the summary since they’re optional. They’ll show in the Shortcut action under a show more twirl-down.

Screenshot 9

Screenshot 10

Step Nine

Now we’ll add a response. For our result make sure the type is File since that’s what we’re outputting.

We’ll set the display name to Renamed Files, tick Supports multiple values since we’ll be outputting more than one and select result in the output.

Screenshot 11

Add an error property in case we need to show any errors to the user.

Screenshot 12

Step Ten

Now let’s write our intent handler. Create a new Swift file called renameFilesHandler.swift. I’ve put it in a group folder called Intents to keep them together.

Define our new RenameFilesIntentHandler class that inherits from NSObject and conforms to the automatically created RenameFilesIntentHandling protocol. Xcode can auto-populate our protocol stubs - we have a function to validate each parameter, one to provide options for our provideDate drop-down list and one to handle the intent.

Screenshot 13

Step Eleven

When validating a parameter that accepts multiple values, we need to return an array of resolution results.

In this code we’re creating an empty array of RenameFilesFilesResolutionResult.

If our Files parameter is empty then we return a single unsupported resolution result in our array which will show the error we defined.

If there are input files, we append a successful resolution result to the array for each one.

Note that files in Intents are described by objects called INFiles.

func resolveFiles(for intent: RenameFilesIntent, with completion: @escaping ([RenameFilesFilesResolutionResult]) -> Void) {
    var resultArray = [RenameFilesFilesResolutionResult]()
    let files = intent.files ?? []
    if files.isEmpty {
        resultArray.append(RenameFilesFilesResolutionResult.unsupported(forReason: .noFiles))
    } else {
        for file in files {
            resultArray.append(RenameFilesFilesResolutionResult.success(with: file))
        }
    }
    completion(resultArray)
}

Step Twelve

We’ll provide options for our dateFormat parameter by creating an array of strings with today’s date in three different formats (eg: 2020-04-01, 2020-04 & 2020).

We’ll then validate that the user has picked one of the options or we’ll show an error.

func provideDateFormatOptions(for intent: RenameFilesIntent, with completion: @escaping ([String]?, Error?) -> Void) {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale.current
    dateFormatter.calendar = Calendar.current
    dateFormatter.dateFormat = yyyy-MM-dd

    let fullDate = dateFormatter.string(from: Date())
    let yearsAndMonths = String(fullDate.dropLast(3))
    let yearOnly = String(fullDate.dropLast(6))
    
    let optionsArray: [String] = [fullDate, yearsAndMonths, yearOnly]
    
    completion(optionsArray, nil)
 }

func resolveDateFormat(for intent: RenameFilesIntent, with completion: @escaping (RenameFilesDateFormatResolutionResult) -> Void) {
    if let dateFormat = intent.dateFormat {
        completion(RenameFilesDateFormatResolutionResult.success(with: dateFormat))
    } else {
        completion(RenameFilesDateFormatResolutionResult.unsupported(forReason: .empty))
    }
}

Step Thirteen

It’s easy to resolve our non-Optional enum values.

func resolveNewCase(for intent: RenameFilesIntent, with completion: @escaping (RenameCaseResolutionResult) -> Void) {
    let newCase = intent.newCase
    completion(RenameCaseResolutionResult.success(with: newCase))
}

func resolvePosition(for intent: RenameFilesIntent, with completion: @escaping (RenamePositionResolutionResult) -> Void) {
    let position = intent.position
    completion(RenamePositionResolutionResult.success(with: position))
}

Step Fourteen

With our changeCase Bool parameter, we’ll default to false if the input is ambiguous.

func resolveChangeCase(for intent: RenameFilesIntent, with completion: @escaping (INBooleanResolutionResult) -> Void) {
    let changeCase = intent.changeCase?.boolValue ?? false
    completion(INBooleanResolutionResult.success(with: changeCase))
}

Step Fifteen

Now we’ll write the code to handle the main logic intent: renaming the files and outputting the results.

We’ll use all of our validated parameters to return an array of identical INFiles with new names, or we’ll display an error if there’s a problem.

func handle(intent: RenameFilesIntent, completion: @escaping (RenameFilesIntentResponse) -> Void) {
    let files = intent.files ?? []
    let position = intent.position
    let changeCase = intent.changeCase?.boolValue ?? false
    guard let dateFormat = intent.dateFormat else {
        completion(RenameFilesIntentResponse.failure(error: Please choose a valid date format))
        return
    }
    
    var outputArray = [INFile]()
    
    for file in files {
        var newName = file.filename
        
        if changeCase {
            let newCase = intent.newCase
            switch newCase {
            case .lowercase:
                newName = newName.lowercased()
            case .uppercase:
                newName = newName.uppercased()
            default:
                completion(RenameFilesIntentResponse.failure(error: An invalid case was selected))
                return
            }
        }

        switch position {
        case .append:
            guard let fileURL = file.fileURL else {
                completion(RenameFilesIntentResponse.failure(error: Couldnt get file URL of \(file.filename)))
                return
            }
            let filePath = fileURL.deletingPathExtension().lastPathComponent
            let nameNoExt = FileManager.default.displayName(atPath: filePath)
            let ext = fileURL.pathExtension
            newName = \(nameNoExt)_\(dateFormat).\(ext)
        case .prepend:
            newName = \(dateFormat)_\(newName)
        default:
            completion(RenameFilesIntentResponse.failure(error: An invalid position was selected))
            return
        }
        
        let renamedFile = INFile(data: file.data, filename: newName, typeIdentifier: file.typeIdentifier)
        outputArray.append(renamedFile)
    }
    completion(RenameFilesIntentResponse.success(result: outputArray))
}

Here’s the complete, commented intent handler:

import Intents

class RenameFilesIntentHandler: NSObject, RenameFilesIntentHandling {
    
    func resolveFiles(for intent: RenameFilesIntent, with completion: @escaping ([RenameFilesFilesResolutionResult]) -> Void) {
        // For paramters that accept multiple files, we need to pass an array of Resolution Results to the completion handler
        var resultArray = [RenameFilesFilesResolutionResult]()
        let files = intent.files ?? []
        if files.isEmpty {
            resultArray.append(RenameFilesFilesResolutionResult.unsupported(forReason: .noFiles))
        } else {
            for file in files {
                resultArray.append(RenameFilesFilesResolutionResult.success(with: file))
            }
        }
        completion(resultArray)
    }
    
    // this function will provide the drop-down list of options to choose from when tapping the “Date Format parameter in Shortcuts”
    func provideDateFormatOptions(for intent: RenameFilesIntent, with completion: @escaping ([String]?, Error?) -> Void) {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale.current
        dateFormatter.calendar = Calendar.current
        dateFormatter.dateFormat = yyyy-MM-dd

        let fullDate = dateFormatter.string(from: Date())
        let yearsAndMonths = String(fullDate.dropLast(3))
        let yearOnly = String(fullDate.dropLast(6))
        
        let optionsArray: [String] = [fullDate, yearsAndMonths, yearOnly]
        
        completion(optionsArray, nil)
     }

    func resolveDateFormat(for intent: RenameFilesIntent, with completion: @escaping (RenameFilesDateFormatResolutionResult) -> Void) {
        if let dateFormat = intent.dateFormat {
            completion(RenameFilesDateFormatResolutionResult.success(with: dateFormat))
        } else {
            completion(RenameFilesDateFormatResolutionResult.unsupported(forReason: .empty))
        }
    }
    
    func resolveNewCase(for intent: RenameFilesIntent, with completion: @escaping (RenameCaseResolutionResult) -> Void) {
        let newCase = intent.newCase
        completion(RenameCaseResolutionResult.success(with: newCase))
    }

    func resolvePosition(for intent: RenameFilesIntent, with completion: @escaping (RenamePositionResolutionResult) -> Void) {
        let position = intent.position
        completion(RenamePositionResolutionResult.success(with: position))
    }
    
    func resolveChangeCase(for intent: RenameFilesIntent, with completion: @escaping (INBooleanResolutionResult) -> Void) {
        let changeCase = intent.changeCase?.boolValue ?? false
        completion(INBooleanResolutionResult.success(with: changeCase))
    }
    
    func handle(intent: RenameFilesIntent, completion: @escaping (RenameFilesIntentResponse) -> Void) {
        let files = intent.files ?? []
        let position = intent.position
        let changeCase = intent.changeCase?.boolValue ?? false
        guard let dateFormat = intent.dateFormat else {
            // We can display errors to the user when problems occur
            completion(RenameFilesIntentResponse.failure(error: Please choose a valid date format))
            return
        }
        
        // The intent response expects an array of INFiles
        var outputArray = [INFile]()
        
        for file in files {
            var newName = file.filename
            
            // change the case of the filename if selected
            if changeCase {
                let newCase = intent.newCase
                switch newCase {
                case .lowercase:
                    newName = newName.lowercased()
                case .uppercase:
                    newName = newName.uppercased()
                default:
                    completion(RenameFilesIntentResponse.failure(error: An invalid case was selected))
                    return
                }
            }
            
            // append or prepend the selected date value
            switch position {
            case .append:
                // if appending the date, we need to split the extension from the name first
                guard let fileURL = file.fileURL else {
                    completion(RenameFilesIntentResponse.failure(error: Couldnt get file URL of \(file.filename)))
                    return
                }
                let filePath = fileURL.deletingPathExtension().lastPathComponent
                let nameNoExt = FileManager.default.displayName(atPath: filePath)
                let ext = fileURL.pathExtension
                newName = \(nameNoExt)_\(dateFormat).\(ext)
            case .prepend:
                newName = \(dateFormat)_\(newName)
            default:
                // We’ll show an error if for some reason one of our enum values hasn’t been selected
                completion(RenameFilesIntentResponse.failure(error: An invalid position was selected))
                return
            }
            
            // construct a new INFile with identical data and type identifier and the new file name
            let renamedFile = INFile(data: file.data, filename: newName, typeIdentifier: file.typeIdentifier)
            outputArray.append(renamedFile)
        }
        completion(RenameFilesIntentResponse.success(result: outputArray))
    }
}

Step Sixteen

Now let’s make sure our IntentHandler is called when the shortcut is run.

In IntentHandler.swift we’ll add a new case for RenameFilesIntent to our switch statement:

import Intents

class IntentHandler: INExtension {
    
    // When shortcuts are run, the relevant intent handler should to be returned
    override func handler(for intent: INIntent) -> Any {
        switch intent {
        case is MakeUppercaseIntent:
            return MakeUppercaseIntentHandler()
        case is RenameFilesIntent:
            return RenameFilesIntentHandler()
        default:
            // No intents should be unhandled so we’ll throw an error by default
            fatalError(No handler for this intent)
        }
    }
}

Step Seventeen

Let’s build and run, jump into the Shortcuts app and try our new action out!

Screenshot 13

Summary

In this post we’ve looked at parameters that:

  • accept multiple values
  • accept custom file types
  • are enums
  • are conditional with a child/parent relationship
  • provide a calculated list at run time

In the next post we’ll be looking at in-app intent handling, the visual list API, outputting custom types and supporting iOS 14’s new SwiftUI App protocol.


These are the other posts in the series:

  • Part 1: Creating a project and adding the first action
  • Part 2: Exploring parameters: arrays, enums, calculated lists and files
  • Part 3: In-app intent handling, custom output types, visual list API and using the SwiftUI app protocol
  • Part 4: Visual Lists in parameters and pushing data from Shortcuts into a SwiftUI view

The complete code for the tutorials is also on GitHub: