Posts in shortcuts
2020.7 Beta Release

The latest beta of Toolbox Pro is now available to download.

This will be a significant update with a lot more power under the hood because of some new technologies in Shortcuts on iOS14.

It is, however, also a work-in-progress with some caveats you should be aware of.

IMG_9194.PNG

New Tools

New tools in this version include Pick A Colour, Is Silent Mode On, Pluralise Words, Is Premium Unlocked, Find/Download Unsplash Photos, Find Pexels Videos, Get what3words Address & Build URLs (from components).

I'm still adding new examples to demonstrate how to use them.

More Power

Many powerful actions in Toolbox Pro previously required opening the Toolbox Pro app before returning to Shortcuts due to memory limitations.

In this version, many of these actions no longer have this shortcoming, with the entire action being performed directly inside of Shortcuts.

Some of the actions adapted to take advantage include Create Icon, Create Matte, Detect Faces (with new landmark features), Detect Motion, Detect QR Code, Blur Images, Filter Images, Get Text From Images (now supports more languages) and Scale Image.

Saving, getting and listing the contents of bookmarked folders (outside of the Shortcuts sandbox folder) can now be done right inside Shortcuts too and these tools are all now free!

Tools that manipulate lists like sorting, shuffling or removing duplicates can now handle many more items at once.

Get Text From Audio can now also transcribe many more languages and can optionally perform more detailed recognition online (with a 1-minute limit).

The Caveat

One big issue I’m butting my head against at the moment it that tools which output arrays of custom objects (like the new Quick Menu tool or Find Movies) play really badly when you add a ‘Choose From List’ or ‘Quick Look’ action after them.

It may seem like your device is unresponsive as it takes 7 seconds (!) for each menu item in the list to appear.

One workaround is to change the output type to one of the object’s parameters instead, which works as expected but you lose the nice ability to see images along with text and subtitles in the list.

I’m hoping to have a fix for this soon.

UPDATE 16/12/20: This bug appears to be fixed in Shortcuts on iOS 14.4 developer beta 1. Once 14.4 is public, I will be aiming to release this new version of Toolbox Pro.

Compatibility

I’ve tried to maintain compatibility as much as possible so as not to break your older shortcuts.

For example, if you already have a shortcut that uses the iOS13 Get Text From Image tool, your shortcut should continue to work by bouncing to Toolbox Pro to perform the action but you’ll see a label on the action saying it’s been deprecated.

 
IMG_9186.PNG
 

When you search for Get Text From Images in the Shortcuts action library, you’ll now see the new action, which won’t require Toolbox Pro to be opened.

Feedback

As always, let me know if you have any problems or suggestions either through TestFlight (by taking a screenshot in the Toolbox Pro app) or send me an email.

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.

Xcode 2020-04-09 at 00.20.03@2x.png

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.

CleanShot 2020-04-09 at 00.28.08@2x.png

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.

Xcode 2020-04-09 at 00.23.03@2x.png

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.

Xcode 2020-04-09 at 00.40.45@2x.png

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.

Xcode 2020-04-09 at 00.45.25@2x.png

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.

Xcode 2020-04-09 at 00.47.54@2x.png

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.

Xcode 2020-04-09 at 09.11.24@2x.png

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.

Xcode 2020-04-09 at 09.15.07@2x.png

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.

2020-04-09 12.20.25.gif
ezgif-1-0ca7d3e0e19b.gif

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. Add an error property in case we need to show any errors to the user.

CleanShot 2020-04-09 at 01.13.14@2x.png

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.

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: “Couldn’t 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: “Couldn’t 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!

Mockups 3.png

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:

Parts 1 & 2 (iOS 13)

Parts 3 & 4 (iOS 14)