Reading and writing plists from Execute Script actions

This has come up in various contexts recently – perhaps worth listing pairs of basic read/write .plist functions under an easily found title.

Here are JavaScript for Automation drafts of:

  • writePlist(jsObject, strPath) (where the jsObject can be a dictionary of key-value pairs, or an array of such dictionaries, with any degree of nesting in either)
  • readPlist(strPath) where the file on the path is a .plist with either <dict> or <array> nodes at the top level

With examples of writing and reading:

(function () {
    'use strict';

    // writePlist :: Object -> String -> IO ()
    function writePlist(jsObject, strPath) {
        $(jsObject)
            .writeToFileAtomically(
                $(strPath)
                .stringByStandardizingPath, true
            );
    }

    // readPlist :: String -> Object
    function readPlist(strPath) {
        var strFullPath = $(strPath)
            .stringByStandardizingPath;

        return ObjC.deepUnwrap(
            $.NSDictionary.dictionaryWithContentsOfFile(
                strFullPath
            )
        ) || ObjC.deepUnwrap(
            $.NSArray.arrayWithContentsOfFile(strFullPath)
        );
    }

    // TWO EXAMPLES: creating and reading plists with 
    // <dict> or <array> elements at the top level
    var lstObjects = [
        // dict
        {
            alpha: 1,
            beta: 2,
            gamma: 3
        },

        // array
        [{
            delta: 4,
            epsilon: 5,
            zeta: 6
        }, {
            eta: 'seven',
            theta: 'eight',
            iota: 'nine'
        }]
    ];

    var lstPaths = ['~/Desktop/test01.plist', '~/Desktop/test02.plist'];

    return lstObjects
        .map(function (obj, i) {
            var strPath = lstPaths[i];

            writePlist(obj, strPath);

            return readPlist(strPath);
        });
})();
1 Like

Can you break down what this code does?

Hey Dan,

It writes a JSON string with 1 top-level DICT and 1 top-level ARRAY to 2 plist files and then reads them back and returns a JSON string. (Further nesting is supported.)

Understanding that doesn't get me to the point where I can use it effectively, but give me time.

:sunglasses:

This is why I mentioned JSON as a good medium for data storage in a recent thread. But. The learning curb is still sitting squarely in my way, but I'll jump over it pretty soon.

-Chris

1 Like

It writes a JSON string
returns a JSON string
JSON as a good medium for data storage

Well, JSON strings are certainly a useful storage medium (a bit more compact and legible than XML, a bit less fragile under manual editing than YAML), but funnily enough JSON is not actually involved at any stage in the two functions above :slight_smile:

Perhaps the confusion arises in part from the 'String' in the comments on function type (which just refer to the type of the strPath argument), but more likely the impression of JSON arises because at first glance JavaScript object literals look quite similar to their JSON serialisations.

In AppleScript, the object code in the example might be written something like:

which still has some visual overlap with a JSON string, though if we were actually to serialize the objects to JSON, we would notice the compulsory double-quoting of keys.

In JS for Automation, you can generate JSON from JavaScript object code with JSON.stringify():

var strClip = (function() {
    'use strict';

    var lstObjects = [
        // dict
        {
            alpha: 1,
            beta: 2,
            gamma: 3
        },

        // array
        [{
            delta: 4,
            epsilon: 5,
            zeta: 6
        }, {
            eta: 'seven',
            theta: 'eight',
            iota: 'nine'
        }]
    ];
	
	
	return JSON.stringify(lstObjects, null, 2);

})();

var a = Application.currentApplication(),
	sa = (a.includeStandardAdditions = true, a); 

sa.setTheClipboardTo(strClip);

strClip

Rather than using JSON, the writePlist() and readPlist() above are taking array and dictionary objects (just like those in the Applescript example above) and writing them straight out to (or reading them straight in from) plist XML like:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>alpha</key>
    <real>1</real>
    <key>beta</key>
    <real>2</real>
    <key>gamma</key>
    <real>3</real>
</dict>
</plist>

or

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
    <dict>
        <key>delta</key>
        <real>4</real>
        <key>epsilon</key>
        <real>5</real>
        <key>zeta</key>
        <real>6</real>
    </dict>
    <dict>
        <key>eta</key>
        <string>seven</string>
        <key>iota</key>
        <string>nine</string>
        <key>theta</key>
        <string>eight</string>
    </dict>
</array>
</plist>

To answer Dan's question, the breakdown of stages is:

(see the general documentation on .writeToFileAtomically and .stringByStandardizingPath and the specifics of JS and ObjC interactions in the JavaScript for Automation release notes)

// writePlist :: Object -> String -> IO ()
function writePlist(jsObject, strPath) {
    $(jsObject)   // The $() operator converts the JavaScript array or dictionary
                  // to an ObjC type, in preparation for using ObjC functions like:
        .writeToFileAtomically( 
            $(strPath)  // which requires an ObjC path string
            .stringByStandardizingPath, true // which we have normalised from ~/ etc
        );
}

and

(see .dictionaryWithContentsOfFile and .arrayWithContentsOfFile)

// readPlist :: String -> Object
function readPlist(strPath) {
    // Get the output path as an ObjC datatype, with ~ etc resolved
    var strFullPath = $(strPath)
        .stringByStandardizingPath;

    // ObjC.unwrap and .deepUnwrap convert from ObjC objects back to JS objects

    // Here we first try to read the .plist as a dictionary, and if that returns
    // an undefined, the boolean expression instead returns the result of trying to
    // read the .plist as an array
    return ObjC.deepUnwrap(
        $.NSDictionary.dictionaryWithContentsOfFile(
            strFullPath
        )
    ) || ObjC.deepUnwrap(
        $.NSArray.arrayWithContentsOfFile(strFullPath)
    );
}

In the example run, we map over the two objects, applying to each a function which uses the second argument of the map callback function (an index into the array) to pull in a different filePath for each object.

  • First we write the javascript dictionary or array straight out as a .plist file, and then
  • we read it back in from the .plist to a js dictionary or array

(no JSON used, just plist XML)

2 Likes

Hey Rob,

Okay, I stand corrected.  :smile:

I thought it was a JSON string, because I put the result in a JSON analyzer and all the nodes came up correctly.

-Chris

That’s the beauty of JSON, and its close correspondence to the object literal code :slight_smile:

(its only real gotcha is that even tolerant JSON parsers choke if you try to include comments)

Please provide links when you mention intriguing things like β€œa JSON analyzer”.

1 Like

Here, in case it’s more illuminating, is an AppleScript translation

(AppleScript versions of writePlist() and readPlist() – note that in AS you will need to open with the line

use framework "Foundation"

which is not needed in JavaScript for Automation

(the generic map function, used in the example, is an illustrative imitation of something built into JS)

use framework "Foundation"

-- writePlist :: Object -> String -> IO ()
on writePlist(asObject, strPath)
    set a to current application
    set cClass to class of asObject
    
    if (cClass is record) then
        set objcObject to a's NSDictionary's dictionaryWithDictionary:asObject
    else if (cClass is list) then
        set objcObject to a's NSArray's arrayWithArray:asObject
    else
        return missing value
    end if
    
    (objcObject)'s Β¬
        writeToFile:((a's NSString's stringWithString:strPath)'s Β¬
            stringByStandardizingPath()) atomically:true
end writePlist

-- readPlist :: String -> Object
on readPlist(strPath)
    set a to current application
    set oPath to (a's NSString's stringWithString:strPath)'s Β¬
        stringByStandardizingPath()
    
    set maybeDict to (a's NSDictionary's dictionaryWithContentsOfFile:oPath)
    if (maybeDict is not missing value) then return maybeDict as record
    
    set maybeArray to (a's NSArray's arrayWithContentsOfFile:oPath)
    if (maybeArray is not missing value) then return maybeArray as list
end readPlist

-- GENERIC MAP FUNCTION
-- (given a list, return a transformed copy, 
--  to each member of which some function f 
--  has been applied )

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    script mf
        property lambda : f
    end script
    
    set lng to length of xs
    set lst to {}
    repeat with i from 1 to lng
        set end of lst to mf's lambda(item i of xs, i, xs)
    end repeat
    return lst
end map


-- TWO EXAMPLES: plists with <dict> or <array> at the top level

set lstObjects to [Β¬
    {alpha:1, beta:2, gamma:3}, Β¬
    [{delta:4, epsilon:5, zeta:6}, Β¬
        {eta:"seven", theta:"eight", iota:"nine"}]]


on writeAndRead(obj, i)
    set strPath to item i of ["~/Desktop/test05.plist", "~/Desktop/test06.plist"]
    
    writePlist(obj, strPath)
    
    return readPlist(strPath)
end writeAndRead

map(writeAndRead, lstObjects)
2 Likes

Got some questions:

  1. I don’t understand why you pass 3 parameters to mf’s lambda function. If I’m reading this really cool example correctly, you pass writeAndRead as the lambda expression, yet it only takes 2 parameters.

  2. EDIT: Never mind - I found the answer on this one.

  3. EDIT: Never mind - I found the answer on this one.

  4. EDIT: Never mind - I found the answer on this one.

It’s amazing how quickly this information is coming at me. I’ve got a huge learning curve ahead of me, but I’m starting to understand some things. Yesterday morning I wouldn’t have understood anything in this script. Then yesterday I learned about script objects and how to make multiple instances of them, and today I learned that you can pass lambda expressions in parameters - are these true lambda expressions, i.e. instead of function, you could pass what amounts to inline code, or can you only pass functions (handlers)?

Thanks!

1 Like

I don't understand why you pass 3 parameters to mf's lambda function

The map function is generic and allows for functions with 1-3 arguments. On the model of the JavaScript map, these are:

  • x an item in the array
  • i the index of that item in the array
  • xs the array itself

Most functions may well just use the first argument, probably not many will use all three, but map makes them available in case they are needed.

can you only pass functions (handlers) ?

Only scripts are first class objects in the AppleScript type system - even handlers are just properties of scripts, and only passable as passengers in script wrappings. It's not a language that is really designed for (or really supports) genuine first-class lambdas.

(JavaScript, of course, does provide fully first-class functions)

Thanks. That helps.

God I miss C#. :smirk:

Perhaps the quality of any language is largely a function of how much time one has invested in it …

and sooner than we think, we may well be missing all languages:

Oh, no question about it. If I implied that C# is superior, I didn't mean that. I meant what I said. "I miss C#". Comfortable old friend I know how to converse with. Feel that way about some other languages, but I've been away from them longer, so I don't long for them as much, so to speak.

It's just so frustrating starting out again. Invigorating, but frustrating. "All I want to do is..."

I know the feeling well. :confounded:

I've programmed in many languages, and I find AppleScript to be the most confusing and inconsistent and, sometimes, illogical, of any I've used. There is a big discussion going on right now in the AppleScript Users List on essentially this central issue, by developers who have been using the language for decades.

That's why I started switching to JavaScript for Automation (JXA) several months ago, because I much prefer the JavaScript language. But I'm still using AppleScript because I've become so familiar with it.

Dan, my suggestion, since you don't have much background in AppleScript, is to use JXA. Since you like C#, it should be much more "learnable" than AppleScript. Just my opinion. Do as you see fit.

Yeah, dang, I know you’re right. It’s just that the examples of the things I’ve wanted to do are either in AppleScript, or JXA written in a way as to dangle the solution out in front of me, but still bet out of my grasp (I’m sure you know what I’m talking about).

But I’ve learned a tone in the last few days, and it just might help in understand that JXA code more, so perhaps I will step back and look at it again.

Thanks for the nudge. Assuming I can figure out what I want in JXA, you’re 100% correct that I would prefer to work in it.

I just discovered a major issue with the method writeToFileAtomically:
It does NOT give any kind of error message if the path is invalid.

How can we check for valid path of a new file in this function?

I figured it out.

The link @ComplexPoint provided above to "writeToFile:atomically" was just a general NS Dictionary topic, and not really helpful.

The answer is here:
writeToFile:atomically

Also the compound code used does not allow for error checking:

// writePlist :: Object -> String -> IO ()
    function writePlist(jsObject, strPath) {
        $(jsObject)
            .writeToFileAtomically(
                $(strPath)
                .stringByStandardizingPath, true
            );
    }

But I was rewriting it anyway to something I could understand, use, modify, etc:

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
function writePlist(pJSObject, pFilePathStr) {
/*      Ver 1.0    2017-02-23
---------------------------------------------------------------------------------
  PURPOSE:  Output JavaScript Object to plist File
  PARAMETERS:
    β€’ pJSObject      | object  |  JavaScript Object
    β€’ pFilePath      | text    |  File Path string
  RETURNS:  true if successful; else false
  REF:  
    1.  Based on script by @ComplexPoint
        https://forum.keyboardmaestro.com/t/reading-and-writing-plists-from-execute-script-actions/3976
β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”
*/

  // Convert Path string to an ObjC path string
  var nsPath    = $(pFilePathStr);
  
  // Expand tilde (~)
  var fullPath  = nsPath.stringByStandardizingPath;
  
  // Convert JavaScript Object to NS Object (ObjC)
  var nsObject  = $(pJSObject);
  
  //--- WRITE JS OBJECT TO PLIST ---
  //  Returns logical true if successful; else false
  
  var successBol = nsObject.writeToFileAtomically(fullPath, true);
  
  return successBol
  
} //~~~~~~~~~~~~~~~ END OF function writePlist ~~~~~~~~~~~~~~~~~~~~~~~~~

So now, with a much simpler and easy to use test, I can check for error:

  console.log("Write to: " + lstPaths[0].toString())
  var success = writePlist(lstObjects[0], lstPaths[0]);
  
  if (!success) {throw new Error("[ERROR]\nFile Write Failed to: " + lstPaths[0])}

When I get done, I'll post my complete rewritten functions and test for everyone's benefit, to learn, understand, test, and modify.

Yes please! When you have the time, that would be great.