omniJS experiment – returning a result to JXA

JavaScript executed in the omniJS context is very fast, already cross-platform (macOS and iOS) for omniGraffle, and with luck and large amounts of hard work, may well become cross-platform for iOS too.

For the moment, however, it doesn’t yet have access to all of the UI, full clipboard Read/Write and other forms of access to the rest of the world that JavaScript for Automation (JXA), or iOS applications like 1Writer do.

Here is an experiment in getting around this by:

  1. Launching an omniJS OmniOutliner script from JXA (e.g. from the Script Editor or Script menu, or from the Atom editor Script plugin, or something like Keyboard Maestro, Fastscripts etc)
  2. Getting a result back from to OmniOutliner omniJS to JXA and the various forms of IO

This is just an interim workaround – my understanding is that Omni is working on providing something built-in.

Two main functions in the draft script below:

  1. A function (selnJSON) to be evaluated in the omniJS JavaScript context
  2. A function (evaluateOOomniJS) to be run from JXA, which submits the omniJS function, and harvests a result from it in the form of a JSON string, which can be converted to a JS Object with the standard JSON.parse() function.

All that this experimental script does is to read the selected OO row and all of its descendants, and convert them into a nested JSON format, optionally with a simple Markdown version of the topic text of each (bold and italic emphases, and markdown bracketing for any links.

Note: this draft is in ES6 JavaScript, making it compatible only with Sierra and onwards.

(() => {
    'use strict';

    // Author: Rob Trew (Twitter @complexpoint)
        // Ver 0.01
        // Date: 2017-08-21 18:52

        // OMNIJS CONTEXT --------------------------------------------------------

        const selnJSON = options => {
            // foldl :: (b -> a -> b) -> b -> [a] -> b
            const foldl = (f, a, xs) => xs.reduce(f, a);

            // id :: a -> a
            const id = x => x;

            // isNull :: [a] | String -> Bool
            const isNull = xs =>
                Array.isArray(xs) || typeof xs === 'string' ? (
                    xs.length < 1
                ) : undefined;

            // log :: a -> IO ()
            const log = (...args) =>
                console.log(
                    args
                    .map(x => JSON.stringify(x, null, 2))
                    .join(' -> ')
                );

            // map :: (a -> b) -> [a] -> [b]
            const map = (f, xs) => xs.map(f);

            // Value simply returned
            // (with adjunct storage in a temporary OO node/row, for JXA to read)
            // pure :: a -> M a
            const pure = v => (
                document.outline.rootItem.addChild(
                    null, x => x.topic = '|λ|omniJSON|λ|'
                )
                .addChild(
                    null, x => x.topic = show(2, v)
                ),
                v
            );

            // readDecimal :: Decimal -> Float
            const readDecimal = d =>
                parseFloat(d.toString());

            // show :: Int -> a -> Indented String
            // show :: a -> String
            const show = (...x) =>
                JSON.stringify.apply(
                    null, x.length > 1 ? [x[1], null, x[0]] : x
                );

            // toLower :: Text -> Text
            const toLower = s => s.toLowerCase();

            const
                outline = document.outline,
                cols = outline.columns.filter(col => col.title.length > 0);

            const valueJS = (k, v) =>
                v !== null ? (
                    k > 'enumeration' ? (
                        k < 'rich-text' ? (
                            readDecimal(v) // 'number'
                        ) : k !== 'state' ? (
                            v.string // 'rich-text'
                        ) : undefined // 'state'  (user defined box – not yet working ??)
                    ) : k < 'duration' ? (
                        v // 'date'
                    ) : k !== 'duration' ? (
                        undefined // 'enumeration' (popup - not yet working ??)
                    ) : readDecimal(v) // 'duration'
                ) : undefined;

            // Array of attribute runs -> inline Markdown string
            // inLineMD :: OO Text -> String
            const inlineMD = ooText =>
                foldl(
                    (a, x) => {
                        const
                            style = x.style,
                            [vBold, blnItal, vLink] = map(
                                k => style.get(Style.Attribute[k]), //
                                ['FontWeight', 'FontItalic', 'Link']
                            ),
                            blnBold = vBold > 8,
                            strBold = ((!a.inBold && blnBold) ? '**' : ''),
                            strItal = ((!a.inItal && blnItal) ? '*' : ''),
                            strURL = vLink.string,
                            blnLink = (a.link === '') && (strURL !== '');
                        return {
                            md: a.md + [
                                strBold, strItal,
                                blnLink ? (
                                    '[' + x.string + '](' + strURL + ')'
                                ) : x.string,
                                strItal, strBold
                            ].join(''),
                            inBold: blnBold,
                            inital: blnItal,
                            link: strURL
                        };
                    }, {
                        md: '',
                        inBold: false,
                        inItal: false,
                        link: ''
                    },
                    ooText.attributeRuns
                )
                .md;

            // typeString :: Column.Type -> String
            const typeString = type => type.identifier.split('.')
                .slice(-1)[0];

            // jsoOutline :: OO.TreeNode -> TextNest {text:String, nest:[TextNest]}
            const jsoOutline = node => {
                const
                    outline = document.outline,
                    [textMay, noteMay, eState] = map(
                        k => node.valueForColumn(outline[k + 'Column']), //
                        ['outline', 'note', 'status']
                    ),
                    xs = node.children;

                return cols.reduce(
                    (a, col) => {
                        const k = toLower(col.title);
                        return !isNull(k) && (k !== 'topic') ? (
                            a[k] = valueJS(
                                typeString(col.type), node.valueForColumn(col)
                            ), a
                        ) : a;
                    }, {
                        text: textMay ? textMay.string : '',
                        md: (options.mdText && textMay) ? (
                            inlineMD(textMay)
                        ) : undefined,
                        nest: xs.length > 0 ? map(jsoOutline, xs) : [],
                        note: noteMay ? noteMay.string : undefined,
                        status: eState === State.Unchecked ? (
                            false
                        ) : eState === State.Checked ? (
                            true
                        ) : undefined
                    }
                );
            };

            // strJSON :: String
            const strJSON = pure(document.editors[0].selectedNodes
                .map(
                    n => jsoOutline(n)
                )
            );
        };

        // JXA CONTEXT -----------------------------------------------------------

        // A list of functions applied to a list of arguments
        // <*> :: [(a -> b)] -> [a] -> [b]
        const ap = (fs, xs) => //
            [].concat.apply([], fs.map(f => //
                [].concat.apply([], xs.map(x => [f(x)]))));


        // log :: a -> IO ()
        const log = (...args) =>
            console.log(
                args
                .map(JSON.stringify)
                .join(' -> ')
            );

        // show :: Int -> a -> Indented String
        // show :: a -> String
        const show = (...x) =>
            JSON.stringify.apply(
                null, x.length > 1 ? [x[1], null, x[0]] : x
            );

        // evaluateOOomniJS :: (Options -> OO Maybe JSON String) -> {KeyValues}
        //      -> Maybe JSON String
        const evaluateOOomniJS = (f, dctOptions) => {
            const
                oo = Application('OmniOutliner'),
                ws = (oo.activate(), oo.windows),
                mw = ws.length > 0 ? {
                    just: ws[0],
                    nothing: false
                } : {
                    nothing: true
                };

            return mw.nothing ? mw : (() => {
                const
                    a = Application.currentApplication(),
                    sa = (a.includeStandardAdditions = true, a),
                    w = mw.just;

                // OMNIJS URL (FOR FUNCTION AND OPTIONS) ASSEMBLED, OPENED, COPIED
                return ap([sa.openLocation], // sa.setTheClipboardTo], //
                        ['omnioutliner:///omnijs-run?script=' +
                            encodeURIComponent(
                                '(' + f.toString() + ')(' +
                                (dctOptions && Object.keys(dctOptions)
                                    .length > 0 ? show(dctOptions) : '') + ')'
                            )
                        ]) && w.name()
                    .indexOf('Automation Console') === 0 ? ({
                        nothing: true,
                        msg: 'Front window was Automation Console'
                    }) : (() => {
                        //sa.doShellScript('sleep 0.25');
                        const rs = w ? w.document.rows.where({
                                _match: [ObjectSpecifier()
                                    .topic, '|λ|omniJSON|λ|'
                                ]
                            }) : [],
                            cs = rs.length > 0 ? rs[0].children : [],
                            c = cs.length > 0 ? cs.at(0) : undefined,
                            v = c ? c.topic() : '';

                        return (
                            // WITHOUT ANY TEMPORARY RESULT NODES --------------------
                            (() => {
                                var i = rs.length;
                                while (i--) rs[i].delete();
                            })(),
                            // MAYBE JSON RETURN VALUE -------------------------------
                            (v === undefined || v === null) ? ({
                                nothing: true,
                                msg: "No JSON found in " +
                                    "temporary row '|λ|omniJSON|λ|'"
                            }) : {
                                just: v,
                                nothing: false
                            }
                        );
                    })();
            })();
        };

        const mResult = evaluateOOomniJS(selnJSON, {
            mdText: true // or false - to skip making an md field
        });

        console.log(mResult.just);

        return mResult.nothing ? mResult : (
            JSON.parse(mResult.just)
        );
    })();