Making A Mac App Scriptable Tutorial

Allow users to write scripts to control your OS X app – giving it unprecedented usability. Discover how in this “Making a Mac App Scriptable Tutorial”. By Sarah Reichelt.

5 (1) · 1 Review

Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

Working With Nested Objects

In the sample app, the second column displays a list of tags assigned to each task. So far, you have no way of working with them via scripts – time to fix that!

Object specifiers can handle a hierarchy of objects. That’s what you have here, with the application owning the tasks and each task owning its tags.

As with the Task class, you need to make the Tag scriptable.

Open Tag.swift and make the following changes:

  • Change the class definition line to @objc(Tag) class Tag: NSObject {
  • Add the override keyword to init.
  • Add the object specifier method:
override var objectSpecifier: NSScriptObjectSpecifier {
  // 1
  guard let task = task else { return NSScriptObjectSpecifier() }

  // 2
  guard let taskClassDescription = task.classDescription as? NSScriptClassDescription else {
    return NSScriptObjectSpecifier()
  }
  
  // 3
  let taskSpecifier = task.objectSpecifier

  // 4
  let specifier = NSUniqueIDSpecifier(containerClassDescription: taskClassDescription,
    containerSpecifier: taskSpecifier, key: "tags", uniqueID: id)
  return specifier
}

The above code is relatively straightforward:

  1. Check that the tag has an assigned task.
  2. Check that the task has a class description of the correct class.
  3. Get the object specifier for the parent task.
  4. Construct the object specifier for the tag contained inside the task and return it.

Add the following to the SDEF file at the Insert tag class here comment:

<class name="tag" code="TaGg" description="A tag" inherits="item" plural="tags">
  <cocoa class="Tag"/>
  <property name="id" code="ID  " type="text" access="r"
    description="The unique identifier of the tag.">
    <cocoa key="uniqueID"/>
  </property>
  <property name="name" code="pnam" type="text" access="rw"
    description="The name of the tag.">
    <cocoa key="name"/>
  </property>
</class>

This is very similar to the data for the Task class, but a tag only has two exposed properties: id and name.

Now the Task section has to be edited to indicate that it contains tag elements.

Add the following code to the Task class XML, at the Insert element of tags here comment:

<element type="tag" access="rw">
  <cocoa key="tags"/>
</element>

Quit the app, then build and run the app again.

Go back to the Script Editor; if the Scriptable Tasks dictionary is open, close and re-open it. See if it contains information about tags.

If not, remove the Scriptable Tasks entry from the Library and add it again by dragging the app into the window:

Making a mac app scriptable tutorial: Scriptable Tasks Dictionary 2

Try one of the following scripts:

tell application "Scriptable Tasks"
  get the name of every tag of task 1
end tell

or

app = Application("Scriptable Tasks");
app.tasks[0].tags.name();

The app now lets you retrieve tags – but what about adding new ones?

You may have noticed in Tag.swift that each Tag object has a weak reference to its owning task. That helps create the links when getting the object specifier, so this task property must be set when assigning a new tag to a task.

Open Task.swift and add the following method to the Task class:

override func newScriptingObject(of objectClass: AnyClass,
                                 forValueForKey key: String,
                                 withContentsValue contentsValue: Any?,
                                 properties: [String: Any]) -> Any? {

  let tag: Tag = super.newScriptingObject(of: objectClass, forValueForKey: key,
                                          withContentsValue: contentsValue,
                                          properties: properties) as! Tag
  tag.task = self

  return tag
}

This method is sent to the container of the new object, which why you put it into the Task class and not the Tag class. The call is passed to super to get the new tag, and then the task property is assigned.

Quit and build and run your app. Now run the sample script 6. Tasks With Tags.scpt which lists tag names, lists the tasks with a specified tag, and deletes and create tags.

Adding Custom Commands

There is one more step you can take when making an app scriptable: adding custom commands. In earlier scripts, you toggled the completed flag of a task directly. But wouldn’t it be better – and safer – if scripts didn’t change the property directly, but instead used a command to do this?

Consider the following script:

mark the first task as "done"
mark task "Feed the cat" as "not done"

I’m sure you’re already reaching for the SDEF file and you would be correct: the command has to be defined there first.

There are two steps that need to happen here:

  1. Tell the application that this command exists and what its parameters will be.
  2. Tell the Task class that it responds to the command and what method to call to implement it.

Inside the Scriptable Tasks suite, but outside any class, add the following at the Insert command here comment:

<command name="mark" code="TaSktext">
  <direct-parameter description="One task" type="task"/>
  <parameter name="as" code="DFLG" description="'done' or 'not done'" type="text">
    <cocoa key="doneFlag"/>
  </parameter>
</command>

“Wait a minute!” you say. “Earlier you said that codes had to be four characters, and now I have one with eight? What’s going on here?”

When defining a method, you provide a two part code. This one combines the codes or types of the parameters – in this case a Task object with some text.

Inside the Task class definition, at the Insert responds-to command here comment, add the following code:

<responds-to command="mark">
  <cocoa method="markAsDone:"/>
</responds-to>

Now head back to Task.swift and add the following method:

func markAsDone(_ command: NSScriptCommand) {
  if let task = command.evaluatedReceivers as? Task,
    let doneFlag = command.evaluatedArguments?["doneFlag"] as? String {
    if self == task {
      if doneFlag == "done" {
        completed = true
      } else if doneFlag == "not done" {
        completed = false
      }
      // if doneFlag doesn't match either string, leave un-changed
    }
  }
}

The parameter to markAsDone(_:) is an NSScriptCommand which has two properties of interest: evaluatedReceivers and evaluatedArguments. From them, you try to get the task and the string parameter and use them to adjust the task accordingly.

Quit and build and run your app again. Check the dictionary in the Script Editor, and delete and re-import it if the mark command is not showing:

Making a mac app scriptable tutorial: Scriptable Tasks Dictionary 3

You should now be able to run the 7. Custom Command.scpt scripts and see your new command in operation.

Note: Swift 3 changed the way the commands are sent to the objects. AppleScript still works as expected, but the mark command does not work in JavaScript. I have added manual toggling of the completed property to the JavaScript version of 7. Custom Command.scpt but left the original there too. Hopefully it will work after an update.