Do you need training or consultancy? Get in touch!

OpenWhisk web actions

The first way that you learn to call your OpenWhisk action over HTTP is a POST request that is authenticated using your API key. This key allows all sorts of write access to your account, so you never release it.

If you want to access the action over HTTP without the API key, you have two choices: Web Actions or API Gateway.

This article discusses how to use Web Actions as they are more useful today.

Enabling Web Actions

Web Actions are provide an endpoint to your action. The format of the URL is:

The fully qualified name for your action can be found using wsk action list. For my ping action, this is /19FT_dev/P1/ping.

Note that if your action is in the default package, e.g. it's name is /19FT_dev/hello, then you need to use /19FT_dev/default/hello.

The type is one of: http, json, text

Enable access

To enable an action for web access, you need to annotate the action with the web-export key. This is done using the --annotation switch to ask action update:

That's all that we need to do, so we can now test it:

Calling via Curl

Success! As we used the .json extension, OpenWhisk automatically set the status code to 200, sent our returned dictionary as JSON and set the correct Content-Type header. What about if we need to change the status code though?

Sending data to the client

To set your own status code and HTTP headers, you need to use the .http extension on the URL and change the data array that you return from your action.

To recap from my previous post, our action currently looks like this:

ping.swift

In this action we simply return a dictionary of the data we want to appear as JSON in the body. To use the http web action type, we return a dictionary with three keys: code, headers and body:

code

Number of the HTTP status code e.g. 200.
headers

Dictionary of HTTP headers to send. The key is the header's name and the value is the header's value.
body

HTTP body as a string. The format depends on the Content-Type header that you have set. If the format is a binary one, then the string must be base64 encoded. OpenWhisk uses Spray, so consult their list to find out which are considered binary and which are text.

Most importantly, note that JSON content types are considered binary by Spray!

Let's change our action to send XML:

ping.swift

(Don't forget to update your action whenever you change it using wsk action update)

As the XML content types are considered text, we can just set the string containing our XML for the body element of our dictionary.

Let's test it:

Note, that if you the Accept header doesn't contain the content type that is set in your headers, then the Web Action controller will send an error back to the client.

JSON

For application/json, we have to convert our dictionary to a JSON string and then base64 encode it. OpenWhisk's Swift environment includes SwiftyJSON, so this isn't too hard:

ping.swift

OpenWhisk's Swift envionment includes the WhiskJsonUtils class that has some useful JSON related methods. In our case, we use the dictionaryToJsonString() method to create the JSON string from our dictionary. We then convert this to a base64 encoded string using the Foundation Data class's base64EncodedString() method.

Proving that it works:

Reading headers

We've talked about sending data from our action to the HTTP client, but what about receiving data from the client? This information is provided to you in the args parameter that is passed to your main() method. You can inspect args with this code:

env.swift:

Run it as POST request, with a very simple JSON payload:

As you can see, our body data ({"foo": "bar}) is just an element in the args dictionary. However, OpenWhisk has also given us some __ow_* properties with useful information. __ow_meta_verb tells us the METHOD of the HTTP message that the client sent and __ow_meta_headers contains the HTTP headers. Note that the keys are normalised to lower case.

Armed with this knowledge, it's possible to ensure that your action works with the HTTP method(s) that you want it to and by reading the headers, you can implement things like authentication via the Authorization header or read the Accept header to ensure you return data in the right format.

Fin

That's it. OpenWhisk Web Actions provide a very easy way to create HTTP endpoints that can be called by clients via the GET, PUT, POST or DELETE methods without needing your API key.

In comparison to API Gateway, you have less control over the name of the URL, but in exchange you are able to set the HTTP status code and custom headers, which isn't possible in API Gateway today. Over time, I expect API Gateway to gain more features, but until it supports the ability to set the status code, I recommend Web Actions.

Serverless Swift on OpenWhisk

I'm interested in serverless computing and as I write Swift, the OpenWhisk platform comes up high when you Google. This turns out to be a really good choice as OpenWhisk is Open Source so I can read the source code (and have done!). In principle, I can also run my own instance of it if I need to to for regulatory reasons, or just to avoid vendor lock-in.

Commercially, the whole point of Serverless (aka Functions as a Service) is that it deal with everything infrastructure related other than the function I am writing, and so I actually host my OpenWhisk functions with IBM's Bluemix.

In a serverless environment we write separate functions that are event driven. Each function can even be in a different language as they are all independent. Also, our functions are stateless which, as an API person, I'm comfortable with. There's more than one way to trigger a function, but I've started with the simplest: an HTTP request.

Far more information is in the docs and I can't recommend the OpenWhisk-Team Slack channel enough; very helpful people on there.

This is my intro post on getting going with OpenWhisk mainly so that all the info I need is in one place!

Notes on Getting Started

There's plenty of blog posts about getting started with OpenWhisk on Bluemix, so this is mostly an aide-memoir for myself as I had to go back on a couple of things that I didn't understand the first time.

Create a Bluemix account:

  • Log into your Bluemix account or create one.
  • OpenWhisk is only provisioned in the US South region
  • Make a note of your organisation and space, you'll need them later!
    (If you're setting up Bluemix for the first time, call your first space "dev")

Set up OpenWhisk:

You should now have a working wsk command line tool. wsk is remarkably helpful. Add -h and it'll give you help.

First Swift action

As OpenWhisk is serverless, we have a single entry function to an action. In Swift has this signature:

This means that we receive a dictionary of arguments (which are called parameters elsewhere in OpenWhisk) and must return a dictionary. (In Swift, a dictionary is what is called an associative array or hash in other languages.). The returned dictionary is the data returned to the caller.

First action

To create an action, we need a swift source file. This can have any name, but my general rule of thumb is to name it the same as the action name. As OpenWhisk looks quite APIish, we'll create a "ping" action and so our file is called ping.swift

ping.swift

Packages

Always put your actions in packages. These work a little like namespaces in that you can group together related actions, triggers, rules and what not. You can also attach default parameters to packages that are then available to every action which can be very useful. Interestingly, you can "import" a namespace into another one (known as binding) and when you do so, you can override the parameter values for that package for this specific binding.

To create a package called "P1":

Upload to OpenWhisk

To upload your function to OpenWhisk:

(You can also use create in place of update, but as update will create the action if it doesn't
exist, you may as well just always use update.)

Viewing all actions

To view your actions, list them:

In this case, I have one action, list. It's fully qualified name is "/19FT_dev/P1/ping" and as it's part of a private package, it's private and written in Swift 3. The language information is provided as OpenWhisk supports Java, NodeJS and Python actions in addition to Swift.

Running the action

There are many ways to run the action. The first way is to use the wsk tool's action invoke command:

The blocking parameter tells the command to wait until the action completes before returning. If you leave it out, then the action is invoked, but you don't get the result as it's in "fire and forget" mode.

Alternatively, you can use curl.

To do this, you make a POST request to https://openwhisk.ng.bluemix.net/api/v1/namespaces/{NAMESPACE}/actions/{ACTION}?blocking=true with your API key in the Authorization header. As Basic Auth require the credentials to be Base64 encoded, the easiest way to get the information in the right format is:

You also need your namespace, which has the format of {organisation name}_{space name} as you can see in the fully qualified action name in the output of wsk action list. In my case, this is: 19FT_dev.

We can then use this with our curl command:

As you can see, you get a lot of info back, but the key bit is in the response -> result property:

As you don't want to share your API key with anyone, there are other ways to call this action via HTTP: Web Action and API Gateway. We'll explore these in a separate post.

Something wrong? Viewing the logs

If something goes wrong, the place to look is the logs. To get an ongoing up to date view, open a new terminal window and run this in it:

The works a lot like tail -f. Invoke your action and you'll see the information for it.

Alternatively, to view the last log, read LornaJane's "One-Line Command For Newest OpenWhisk Logs" article.

The command you need is:

That's a bit of mouthful, so put it in a script or alias it.

Fin

That's it. Getting started with Swift actions on OpenWhisk is remarkably easy and lots of fun. If you want to poke around a more fully featured app, have a look at my DrinkChooser project.

Keyboard shortcut to resize Finder columns

I like to use Finder in Column mode (⌘+3). i.e. this view:

Column view

One feature of this view is that you can resize all the columns to fit by alt+double clicking on the move handle between each column. There doesn't appear to be a keyboard shortcut for this operation though, so I created one using Keyboard Maestro.

Keyboard Maestro can move the mouse around the screen and click with it which is exactly what I need. There's a "Click at Found Image" action which seemed like it was just what I needed. I took a screenshot of the handle and set up the action to look for it in the topmost window and then discovered that Click at Found Image fails if it finds more than one image. Most of the time there's at least two columns visible in my Finder windows and so this was never going to work. I needed a different solution.

The solution was to use the "For Each" action iterates over a collection and one of the choices for the collection is found images. I have set up this KM macro:

Resize finder columns

The image I am looking for is dragged into the image well and set to be found in the front window, searched from left to right. Once the image is found, a list of actions can be run. In this case, I double click with the alt modifier slightly to the right and down a bit from the top left hand corner of my screenshot of the handle, which automatically right-sizes all the columns in the window for me. As I only need this done one, I break the loop after double clicking the first handle.

Finally, I assigned it to the keyboard shortcut ⌥⌘R so that I can run it whenever I need to.

Right-sized column view

Stand-alone usage of Zend-InputFilter

Any data that you receive needs to be checked and validated. There are number of ways to do this including PHP's filter_var, but I prefer Zend-InputFilter. This is how to use it as a stand-alone component.

Installation

Firstly, we install it using Composer:

$ composer require zendframework/zend-inputfilter
$ composer require zendframework/zend-servicemanager

You don't have to have ServiceManager, but it makes working with InputFilter much easier, so it's worth installing.

Create the InputFilter

The easiest way to create an InputFilter is to use the provided Factory class. Let's consider an Author entity that has the properties: author_id, name, biography & date_of_birth. We can create an input filter like this:

use Zend\InputFilter\Factory as InputFilterFactory;

class Author
{
    protected $author_id;
    protected $name;
    protected $biography;
    protected $date_of_birth;

    // ...

    protected function createInputFilter()
    {
        $factory = new InputFilterFactory();
        $inputFilter = $factory->createInputFilter([
            'author_id' => [
                'required' => true,
                'validators' => [
                    ['name' => 'Uuid'],
                ],
            ],
            'name' => [
                'required' => true,
                'filters' => [
                    ['name' => 'StringTrim'],
                    ['name' => 'StripTags'],
                ],
            ],
            'biography' => [
                'required' => false,
                'filters' => [
                    ['name' => 'StringTrim'],
                    ['name' => 'StripTags'],
                ],
            ],
            'date_of_birth' => [
                'required' => false,
                'validators' => [
                    ['name' => 'Date'],
                    [
                        'name' => 'LessThan',
                        'options' => [
                            'max' => date('Y-m-d'),
                            'inclusive' => true,
                        ],
                    ],
                ],
            ],
        ]);

        return $inputFilter;
    }
}

The createInputFilter() method takes an associative array where the key is the name of the input and then the value is a specification. There are a number of elements in the specification, but we usually just specify required, filters and validators.

required This can be either true or false. If false, then the validators do not execute, but the filters do.
filters An optional array of Zend-Filters. A filter modifies the supplied data before it is passed to the validators (if any). The filtered data is used by the rest of the application. In this example, we have added two filters: StringTrim & StripTags.
validators An optional array of Zend-Validators. A validator will test the filtered value for the input and fail if the data is not valid. If any validator fails, then the entire InputFilter is invalid.

This particular input filter requires that author_id and name are present, but that biography and date_of_birth are optional. The author_id must be a UUID, the name & biography must not have leading or trailing whitespace or no HTML tags and the date_of_birth, if present, must be a valid date in the past.

Using the InputFilter

To use the InputFilter, we set the data and then call isValid(). This can be done in a validate() method that looks like this:

Use Crell\ApiProblem\ApiProblem;
use Error\Exception\ProblemException;

Class Author
{
    // ...

    /**
     * Create an author
     *
     * @param  array $data
     * @return Author
     * @throws ProblemException
     */
    public static function createAuthor($data)
    {
        $inputFilter = $this->createInputFilter();
        $inputFilter->setData($data);

        if ($inputFilter->isValid()) {
            return new Author($inputFilter->getValues());
        }

        $problem = new ApiProblem('Validation failed');
        $problem->setStatus(400);
        $problem['errors'] = $inputFilter->getMessages();

        throw new ProblemException($problem);
    }
}

In this case, it's an API, so the data has come from a PUT or POST request. We call setData() to pass the array of data into the InputFilter and then call isValid(). If the data is valid, we can return a newly instantiated Author object that is constructed with the filter data. If the validation fails, then we throw a ProblemException which needs an ApiProblem instance, so we create one for it.

To find out which validators failed, getMessages() provides a nested array which is very useful for passing back to the API client.

As an example, this is what failure looks like:

$ curl -i -X "POST" "http://localhost:8888/authors" \
     -H "Accept: application/json" \
     -H "Content-Type: application/json" \
     -d $'{ "name": "", "author_id": "1234" }'

HTTP/1.1 400 Bad Request
Host: localhost:8888
Connection: close
X-Powered-By: PHP/7.0.14
Content-type: application/problem+json

{
    "errors": {
        "author_id": {
            "valueNotUuid": "Invalid UUID format"
        },
        "name": {
            "isEmpty": "Value is required and can't be empty"
        }
    },
    "title": "Validation failed",
    "type": "about:blank",
    "status": 400
}

Fin

That's all there is to it. Zend-InputFilter is a very flexible data filter and validator and works really well for APIs, such as those written in Slim.

Rendering problem details in Slim

As I've already noted, in the project I'm currently building, I'm rendering errors in my Slim Framework API using RFC 7807: Problem Details for HTTP APIs via Larry Garfield's ApiProblem component and rka-content-type-renderer.

One place where we need to integrate this approach into Slim is in the error handlers. Let's look at NotFound. To ensure that we return a response in the right format, we need to implement our own NotFound handler:

src/App/Handler/NotFound.php:

<?php
namespace App\Handler;

use Crell\ApiProblem\ApiProblem;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class NotFound
{
    /**
     * Invoke not found handler
     *
     * @param  ServerRequestInterface $request  The most recent Request object
     * @param  ResponseInterface      $response The most recent Response object
     *
     * @return ResponseInterface
     */
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response)
    {
        $problem = new ApiProblem(
            'Not Found',
            'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html'
        );
        $problem->setStatus(404);

        $renderer = new RKA\ContentTypeRenderer\ApiProblemRenderer();
        return $renderer->render($request, $response, $problem);
    }
}

The NotFound handler must return a Response. To do this, we create a new ApiProblem object with the title "Not Found" and set the type to HTML page that defines the 404 status code. We then instantiate the ApiProblemRenderer and call its render method. The renderer will then return a Response object in either XML or JSON based on the Accept header, with the correct Content-Type header. We then set the status code and return it.

To register our new handler, we use the container. If you're using the skeleton application as your base, then this goes in dependencies.php:

src/dependencies.php:

// Error handlers
$container['notFoundHandler'] = function () {
    return new App\Handler\NotFound();
};

Slim will now use our handler whenever a NotFoundException is raised.

This is it in action:

$ curl -i -H "Accept: application/xml" http://localhost:8888/foo
HTTP/1.1 404 Not Found
Host: localhost:8888
Connection: close
X-Powered-By: PHP/7.0.14
Content-type: application/problem+xml

<?xml version="1.0"?>
<problem>
  <title>Not Found</title>
  <type>http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html</type>
  <status>404</status>
</problem>

We can of course apply this to the other error handers: NotAllowed, Error & PhpError which all follow the same pattern as we have done for NotFound.

Rendering ApiProblem with PSR-7

In the API I'm currently building, I'm rendering errors using RFC 7807: Problem Details for HTTP APIs. As this is a Slim Framework project, it uses PSR-7, so I updated rka-content-type-renderer to support problem.

RFC 7807 defines a standard for sending details of an error in an HTTP response message. It supports both XML and JSON formats. From the RFC, an example response is:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "accounts": [
        "/account/12345",
        "/account/67890"
    ]
}

Only title and type are required, though status should also be set. Full information is in the RFC, which is one of the easier ones to read.

In PHP, Larry Garfield has created the crell/ApiProblem component. The code to implement the above message is:

Use Crell\ApiProblem\ApiProblem;

$problem = new ApiProblem("You do not have enough credit.", "http://example.com/probs/out-of-credit");
$problem
  ->setDetail("Your current balance is 30, but that costs 50.")
  ->setInstance("http://example.net/account/12345/msgs/abc");

$problem['balance'] = 30;
$problem['accounts'] = array(
  "http://example.net/account/12345",
  "http://example.net/account/67890"
);

$jsonString = $problem->asJson();
$xmlString = problem->asXml();

ApiProblemRenderer

The only tricky bit is working out if we need to send back JSON or XML. This is called content negotiation and we read the Accept header to find out what the client wants. However, the Accept header has a complicated format with quality levels as you can see from RFC 7321, section 5.3.2. Fortunately, we can use Negotiation by Will Durand to deal with this, which is how rka-content-type-renderer works.

rka-content-type-render now has an ApiProblemRenderer which will read the Accept header and work out the if the client would prefer JSON or XML. If it can't determine, it will default to JSON.

In Expressive or Slim, it's used like this:

use Crell\ApiProblem\ApiProblem;
use RKA\ContentTypeRenderer\ApiProblemRenderer;
use Zend\Diactoros\Response;

$app->get('/', function ($request, $response, $next) {
    $problem = new ApiProblem(
        'Unauthorised',
        'http://www.example.com/api/docs/authentication'
    );
    $problem->setStatus(403);

    $renderer = new ApiProblemRenderer();
    return $renderer->render($request, new Response(), $problem);
});

This is it in action, with an XML accept header:

$ curl -i -H "Accept: application/vnd.akrabat.api+xml" http://localhost:8888/
HTTP/1.1 403 Forbidden
Host: localhost:8888
Connection: close
X-Powered-By: PHP/7.0.14
Content-type: application/problem+xml

<?xml version="1.0"?>
<problem>
  <title>Unauthorised</title>
  <type>http://www.example.com/api/docs/authentication</type>
  <status>403</status>
</problem>

And with a JSON one:

$ curl -i -H "Accept: application/vnd.akrabat.api+json" http://localhost:8888/
HTTP/1.1 403 Forbidden
Host: localhost:8888
Connection: close
X-Powered-By: PHP/7.0.14
Content-type: application/problem+json

{
    "title": "Unauthorised",
    "type": "http://www.example.com/api/docs/authentication",
    "status": 403
}

Ideally, the client should specify application/problem+json in their accept header, but in practice, I've never seen that happen, which is why rka-content-type-renderer works out the preferred format based on the media types specified.

Making Slack accessible on macOS

I've written before about how I tend to use my Mac via the keyboard as much as possible to minimise pain in my arm.

Possibly the best application on macOS to facilitate this is Shortcat which enables me to point and click at any UI element in any native Mac app. I rely on it a lot and it makes nearly every app I use accessible to me.

Recently, Slack updated their desktop app so that it is now built on top of Electron which broke Shortcat compatibility and made me sad as clicking links in Slack meant getting out the Wacom tablet. However, recently, I discovered a way to re-enable it that may work for other Electron apps too.

There's a switch to Chromium, --force-renderer-accessibility, which turns on accessibility access!

This means that I can enable accessibility integration with Shortcat from the command line using:

This enables Shortcat to work as you can see here:

Accessible Slack

In this image, I have enabled Shortcat (the small text box at the bottom of the window) and then typed "slimphp". Shortcat has highlighted where I can click and by pressing return, I'll select the link I want and it will open in a browser.

Note though, that I’ve found that if I change teams then Shortcat cannot "see" anything until I type something (and then backspace to remove it), after which Shortcat then works for that team. It seems that this only needs to be done once per launch per team though.

I also use Alfred, so I have made a workflow to open Slack with this command line argument which means that I can start Slack in my normal way using Alfred and it's accessibility enabled.

This is the workflow: Run-Slack-with-Accessibility.alfredworkflow. I hope that it helps.

A note on framework performance

A question came up recently wondering why Slim Framework was around 5 times slower than plain PHP.

All frameworks are by definition slower than no-code as there's more going on.

i.e. an index.php of:

    <?php
    header('Content-Type: application/json');
    echo json_encode(['result' => 1]);

is going to be much faster than a Slim application with this code:

    use \Slim\App;
    include 'vendor/autoload.php';
    
    $config = include 'Config/Container.php';
    $app = new App($config);
    $app->get('/do-it', function($request, $response){
        return $response->withJson(['result' => 1]);
    });
    
    $app->run();

This is not an apples-to-apples comparison of course as the Slim application is doing a lot more than the plain PHP one. It supports routing so that it can respond to different URLs (even ones with dynamic parameters in that URL) and HTTP methods, along with the ability to handle errors. It also a set of separate classes so that you can replace specific parts of our code with your own if you want it to do something different while still using the rest of our code.

To actually compare the performance of Slim to plain PHP, you need to make the plain PHP version have the same functionality as the Slim version and you'll discover that the performance difference is much smaller. Of course, custom written, application specific code will always be faster than generic code though. Slim is a micro framework, so already is pretty tight (more to do though and something I'm looking at for 4.0), but by definition, it supports more routing features than you may need.

However, you have to write it all that code yourself and, to be honest, parsing requests, sending responses, dealing with URL routing, formatting errors based on the Accept header, and so on are solved problems that don't add any value to your app if you write them yourself. You're much better off concentrating your efforts on the parts of your app that are unique to your app as that's where it brings value.

The definitive resource on this is Paul Jones' articles. Start with A Siege On Benchmarks. This was back in 2009 and somehow he managed to not test Slim! However, even if you take the fastest framework he measured (ZF 0.2!), you can see that at 180.56 requests per second, that's around 4.5 times slower than the baseline PHP of 829.82 requests/second.

Finally, the speed of this code will pale in comparison to the rest of your application's code, especially if you access a database or a web s service!

I'm interested in making Slim the fastest it can be, but the benefits of having a reliable framework that provides good routing middleware functionality out of the box cannot be overstated.

Homestead per-project crib sheet

I wanted a drop-dead simple way to try and replicate a problem someone was having on the Slim forums. I couldn't reproduce with php -S which is my go-to for this sort of thing, so I thought I'd try Homestead.

I had recently listend to a Voices of the Elephpant episode with Taylor Otwell & Joe Ferguson where Joe mentioned that Homestead worked on a per-project basis too. I didn't know this, so tried it out. The docs are fine, but there's a lot there that covers the global installation option when I just want to get up and running on a per-project basis.This is my crib sheet:

1. Create project

We just need a project that uses Composer. You probably have one already. If not, Slim Framework is a good choice!

2. Add Homestead to the project

The make command creates VagrantFile and a Homestead.yaml for configuration.

3. Deal with IP address and hostname

By default, the Homestead vagrant box is set up on 192.168.10.10 with the hostname homestead.app. You can change this in Homestead.yaml.

Add the IP address to /etc/hosts. This only needs to be done once if you don't change the defaults.

All done

We're all done, so we can use vagrant up to run our new website Go to homestead.app in a browser to see it. To shut down, use vagrant halt or vagrant destroy.

Slim home page

Automatic OCR with Hazel and PDFPen

I have a useful scanner as part of my networked HP printer that will scan directly to a shared directory on my computer. Once there, I want the file to be renamed to the current date and the document OCR'd so that I can search it.

To do this, I use Hazel and PDFPen and this is a note to ensure that I can remember to do it again if I ever need to!

Firstly, rename the file. My scanner names each file with the prefix scan, so the Hazel rule is quite simple:

If all the following conditions are met:
	Name starts with scan

Do the following to the matched file or folder:
	Rename with pattern: [date created][extension]

This is the screenshot:

Hazel1

Having renamed the file, we can use PDFPen's AppleScript support to perform an OCR of the document:

If all the following conditions are met:
	Extension is pdf
	Date Last Modified is after Date Last Matched

Do the following to the matched file or folder:
	Run AppleScript embedded script

The embedded AppleScript is:

tell application "PDFpen"
	open theFile as alias
	tell document 1
		ocr
		repeat while performing ocr
			delay 1
		end repeat
		delay 1
		close with saving
	end tell
	quit
end tell

This is the screenshot of it in Hazel:

Hazel2

That's it. Scanning a document now results in a dated, OCR'd PDF file in my Scans folder.