OmniFocus :: Copy as JSON

A first draft of a Copy As JSON action for OmniFocus:

Copy as JSON.omnijs.zip (2.0 KB)

JS Source
/* eslint-disable no-undef */
/* eslint-disable no-return-assign */
/* eslint-disable spaced-comment */
/*{
    "author": "Rob Trew",
    "targets": ["omnifocus"],
    "type": "action",
    "identifier": "com.robtrew.copyOFasJSON",
    "version": "0.1",
    "description": "Copies selected OF items as JSON",
    "label": "Copy as JSON",
    "mediumLabel": "Copy as JSON",
    "paletteLabel": "Copy as JSON",
}*/
(() => {
    "use strict";

    // main :: () -> Plugin
    const main = () => Object.assign(
        new PlugIn.Action(selection => {

            // nodePropertyDict :: OF DBObject -> Dict
            const nodePropertyDict = x => {
                const
                    nodeType = x.constructor.name,
                    isProjectTask = ["Project", "Task"]
                    .includes(nodeType);

                return {
                    "type": nodeType,
                    "name": x.name,
                    "tags": "Task" === nodeType ? (
                        x.tags.map(tagPath)
                    ) : undefined,
                    "due": isProjectTask ? (
                        null !== x.dueDate ? (
                            x.dueDate.toISOString()
                        ) : undefined
                    ) : undefined,
                    "note": isProjectTask ? (
                        x.note
                    ) : undefined
                };
            };

            // json :: JSON String
            const json = JSON.stringify(
                commonAncestors(
                    selection.databaseObjects
                ).map(
                    jsObjectFromOmniFocusObject(
                        nodePropertyDict
                    )
                )
                // null, 2
            );

            return (
                Pasteboard.general.string = json,
                new Alert("Copied as JSON", json).show()
            );

        }), {
            validate: selection =>
                0 < selection.databaseObjects.length
        }
    );

    // -------------------- OMNIFOCUS --------------------

    // commonAncestors :: [OFItem] -> [OFItem]
    const commonAncestors = items => {
        // Only items which do not descend from
        // other items in the list.
        const itemSet = new Set(items);

        return items.filter(
            x => !until(
                parent => !parent || itemSet.has(parent)
            )(
                v => v.parent
            )(
                x.parent
            )
        );
    };

    // jsObjectFromOmniFocusObject :: (dbObject -> a) ->
    // dbObject -> Tree [a, [Tree]]
    const jsObjectFromOmniFocusObject = f =>
        // A mapping from an OF database object and
        // its descendants to a Tree of (Value, [Tree])
        // pairs, in which the Value is some representation
        // of one or more of the OF object's properties,
        // (perhaps a name string, or a key:value dictionary)
        // and the [Tree] is a list of similar
        // representations of its descendants, or
        // an empty list – [] – if it has no descendants.
        //
        // The f function (dbObject -> a) takes an
        // individual OF object and returns a corresponding
        // Value.
        dbObject => {
            const go = x => [
                f(x),
                x.children.map(go)
            ];

            return go(dbObject);
        };


    // tagPath :: Tag -> String
    const tagPath = tag => {
        // A " : " delimited path for a tag or
        // the tag's name if it has no parent tag.
        const go = x =>
            null !== x.parent ? (
                go(x.parent).concat(x.name)
            ) : [x.name];

        return go(tag).join(" : ");
    };

    // --------------------- GENERIC ---------------------

    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p =>
        // The value resulting from repeated applications
        // of f to the seed value x, terminating when
        // that result returns true for the predicate p.
        f => x => {
            let v = x;

            while (!p(v)) {
                v = f(v);
            }

            return v;
        };

    // MAIN ---
    return main();
})();

If you are writing a script which needs a nested tree of OmniFocus objects to be in place, for example:

  • a particular tag with a given set of descendants,
  • or a template project or folder with a particular set of descendants

then it may be useful for the script to contain a light, structured (and human-legible) representation of that nested hierarchy of objects, from which to:

  • build,
  • check,
  • or repair

the nested structure (or tree, hierarchy if you prefer) which you need.


If you install the omniJS action above (dragging it, for example, into the Automation > Configure window), you should find that you can copy one or more:

  • top level tags,
  • projects,
  • folders,
  • or just tasks

and choose Automation > Copy As JSON (or click a button dragged to the toolbar from a Customize Toolbar dialog) to copy the OmniFocus object structure to a JSON representation.

For example, if we have a Kanban tag with four descendants, and you selected the Kanban parent tag, it might be copied as JSON, with its descendants, to:

The JSON structure is a list of pairs.

The left-hand item of each pair gives the details of an item (as a dictionary of property values).

The right hand item of each pair is a list of descendants.

Even β€œleaf” items (bottom-level tags or task with no descendants, for example, are represented as a [details, descendants] pair.

In the case of leaves without descendants, the descendant list is empty.

The pairs themselves (languages like TypeScript, Python and Haskell would call them tuples), are, in JSON, just plain lists with two items:

[Properties dictionary, List]

e.g.

[{"type":"Tag","name":"To Do"},[]]

This draft just copies a basic set of properties for each selected OmniFocus database item.

To refine it, and add any additional properties that you might need, you can extend this function in the source code:

// nodePropertyDict :: OF DBObject -> Dict
const nodePropertyDict = x => {
    const
        nodeType = x.constructor.name,
        isProjectTask = ["Project", "Task"]
        .includes(nodeType);

    return {
        "type": nodeType,
        "name": x.name,
        "tags": "Task" === nodeType ? (
            x.tags.map(tagPath)
        ) : undefined,
        "due": isProjectTask ? (
            null !== x.dueDate ? (
                x.dueDate.toISOString()
            ) : undefined
        ) : undefined,
        "note": isProjectTask ? (
            x.note
        ) : undefined
    };
};
3 Likes

PS the action allows for multiple selections, (several top-level tags, projects, or folders), and the JSON copied is always of a list of selected trees.

( sometimes called a forest )

So if you select two ancestral objects, for example, the action will copy JSON of the following type:

[[details, descendants],  [details, descendants]]

and if you select just one ancestral object, you will still be copying a forest (containing just one tree)

i.e.

[ [details, descendants] ]