omniJS iOS – Generating an OG3 tree diagram from a 1Writer outline

1Writer turns out to be an excellent companion for omniJS scripting of iOS OmniGraffle 3.

It’s a good powerful text editor, and it also has a well-developed JavaScript interface, from which iOS OmniGraffle scripts can be constructed and launched.

Here is a rough draft of a 1Writer script which converts the active 1Writer (tab-indented) outline into a tree diagram in current OG3 test builds.

(NB OmniGraffle 3 is still in a test build stage - so a couple of glitches:

  1. After the document is generated, you have to manually click layout now in OmniGraffle 3 which doesn’t yet seem to respond to the Canvas.layout() method in scripts.
    (Until you do this all this shapes are initially piled on top of each other in the top-left corner of the canvas).
  2. For some reason, at the moment even the layout now button doesn’t work until you have manually selected another canvas, and then reselected the newly-created tree diagram canvas

I’m sure these glitches will be ironed out at some point …

Images, and 1Writer script code (which creates and runs an omniJS script) below:

    (() => {
        'use strict';

        // Ver .030

        // DRAFT SCRIPT FOR TESTING PURPOSES ONLY - NOT FOR USE WITH REAL DATA

        // GENERATE A NESTED DIAGRAM FROM A 1WRITER OUTLINE -----------------------

        // outlineDiagram :: Node {text:String, [Node]}
        function outlineDiagram(options) {
            'use strict';

            // CUSTOM STYLES ------------------------------------------------------
            const
                dctShapeStyle = {
                    //strokeColor: Color.RGB(0.0, 0.0, 0.0)
                },
                dctLineStyle = {
                    //strokeColor: Color.RGB(0.0, 0.0, 1.0)
                };

            // DEFAULT STYLES -----------------------------------------------------
            // shapeDefaults :: Dictionary
            const shapeDefaults = {
                textSize: 12,
                textHorizontalAlignment: HorizontalTextAlignment.Center,
                strokeType: null,
                strokeColor: null,
                shadowFuzziness: 7,
                cornerRadius: 9,
                magnets: [new Point(0, -1), new Point(0, 1)]
            };

            // lineDefaults :: Dictionary
            const lineDefaults = {
                lineType: LineType.Orthogonal,
                shadowColor: null,
                strokeThickness: 2,
                strokeColor: Color.RGB(1.0, 0.0, 0.0)
            };

            // TREE DRAWING -------------------------------------------------------
            // newGraphic ::
            // Canvas -> String -> [Dictionary] -> Maybe [Float] -> Graphic
            const newGraphic = (cnv, strType, lstPropDicts, lstPosnSize) => {
                const g = cnv['new' + strType]();
                if (strType !== 'Line') {
                    const xywh = lstPosnSize.concat(
                        [0, 0, 100, 100].slice(lstPosnSize.length)
                    );
                    g.geometry = new Rect(xywh[0], xywh[1], xywh[2], xywh[3]);
                }
                return stylesApplied(lstPropDicts, g);
            };

            // stylesApplied :: [Dictionary] -> Graphic -> Graphic
            const stylesApplied = (lstDicts, g) =>
                concatMap(
                    d => map(k => [k, d[k]], Object.keys(d)),
                    lstDicts
                )
                .forEach(kv => g[kv[0]] = kv[1]) || g;


            // treeDiagram ::
            // Canvas -> Dictionary -> Dictionary -> Node -> Maybe Shape -> Node
            const treeDiagram = (cnv, dctShpStyle, dctLnStyle, dctNode, parent) => {
                const
                    nest = dctNode.nest,
                    shp = newGraphic(
                        cnv,
                        'Shape', [{
                            text: dctNode.text || ''
                        }, dctShpStyle], []
                    );

                //dctNode.id = shp.id;
                shp.name = dctNode.id;

                //shp.magnets = [new Point(0, -1), new Point(0 ,1)];

                // and any link connected and styled
                if (parent !== undefined) {
                    const lnk = stylesApplied(
                        [dctLnStyle],
                        cnv.connect(parent, shp)
                    );
                    // link positioned behind parent Shape
                    typeof lnk.orderBelow(parent);
                    //dctNode.linkID = lnk.id;
                }

                // Recurse with any children
                if ((nest !== undefined) && (!isNull(nest))) {
                    dctNode.nest = map(x => treeDiagram(
                        cnv, dctShpStyle, dctLnStyle, x, shp
                    ), nest);
                }
                return dctNode;
            };

            // GENERIC FUNCTIONS --------------------------------------------------
            // concatMap :: (a -> [b]) -> [a] -> [b]
            const concatMap = (f, xs) => [].concat.apply([], xs.map(f));

            // isNull :: [a] -> Bool
            const isNull = xs => (xs instanceof Array) ? xs.length < 1 : undefined;

            // log :: a -> IO ()
            const log = x => console.log(show(x));

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

            // Any value -> optional number of indents -> String

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

            // OUTLINE DIAGRAM RETURN ---------------------------------------------

            const
                jsoTree = options.tree,
                cnv = addCanvas(),
                layer = cnv.layers[0],
                blnForest = Array.isArray(jsoTree),
                lngForest = blnForest ? jsoTree.length : 0,
                combined = Object.assign;

            // Canvas settings
            cnv.orderBefore(document.portfolio.canvases[0]);
            cnv.layoutInfo.automaticLayout = false;
            ['Right', 'Down'].forEach(x => cnv['autosizes' + x] = true);

            // Hide layer temporarily  -- doesn't work in this build
            // layer.visible = false;
            // log(layer.locked);

            // Set active view to this canvas - perhaps defer this ?
            document.windows[0].selection.view.canvas = cnv; // ????

            console.log('before dctDiagram') // Not showing up in console ...

            const dctDiagram = treeDiagram(
                    cnv,
                    combined({}, shapeDefaults, dctShapeStyle),
                    combined({}, lineDefaults, dctLineStyle),
                    blnForest ? (
                        (lngForest === 1) ? jsoTree[0] : {
                            id: 'Root',
                            text: '',
                            nest: jsoTree
                        }
                    ) : jsoTree
                ),
                shpRoot = cnv.graphicWithName(dctDiagram.id);

            console.log('after dctDiagram'); // Not showing up in console ...

            //shpRoot.name = "Root";
            //dctDiagram.id = "Root";

            //shpRoot.notes = show(dctDiagram);
            //console.log(shpRoot.notes);

            // This doesn't yet trigger an automatic layout on iOS (works on macOS)
            //cnv.layoutInfo.automaticLayout = true;

            // So let's try this ...
            cnv.layout(); // also not currently triggering a layout event

            // This doesn't yet move the selection to the new canvas ... (works on macOS)
            //document.windows[0].selection.view.canvas = cnv;

            // So let's try this ...
            //cnv.orderBefore(document.portfolio.canvases[0])

            // Restore visibility of layer
            // layer.visible = true;
            log(layer.locked);
        };


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

        // 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)]))));

        // comparing :: (a -> b) -> (a -> a -> Ordering)
        const comparing = f =>
            (x, y) => {
                const
                    a = f(x),
                    b = f(y);
                return a < b ? -1 : (a > b ? 1 : 0);
            };

        // concat :: [[a]] -> [a] | [String] -> String
        const concat = xs =>
            xs.length > 0 ? (() => {
                const unit = typeof xs[0] === 'string' ? '' : [];
                return unit.concat.apply(unit, xs);
            })() : [];

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

        // curry :: Function -> Function
        const curry = (f, ...args) => {
            const go = xs => xs.length >= f.length ? (f.apply(null, xs)) :
                function () {
                    return go(xs.concat(Array.from(arguments)));
                };
            return go([].slice.call(args, 1));
        };

        // dropWhile :: (a -> Bool) -> [a] -> [a]
        const dropWhile = (p, xs) => {
            let i = 0;
            for (let lng = xs.length;
                (i < lng) && p(xs[i]); i++) {}
            return xs.slice(i);
        };

        // elem :: Eq a => a -> [a] -> Bool
        const elem = (x, xs) => xs.indexOf(x) !== -1;

        // flip :: (a -> b -> c) -> b -> a -> c
        const flip = f => (a, b) => f.apply(null, [b, a]);

        // intercalate :: String -> [a] -> String
        const intercalate = (s, xs) => xs.join(s);

        // length :: [a] -> Int
        const length = xs => xs.length;

        // lines :: String -> [String]
        const lines = s => s.split(/[\r\n]/);

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

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

        // minimumByMay :: (a -> a -> Ordering) -> [a] -> Maybe a
        const minimumByMay = (f, xs) =>
            xs.reduce((a, x) => a.nothing ? {
                just: x,
                nothing: false
            } : f(x, a.just) < 0 ? {
                just: x,
                nothing: false
            } : a, {
                nothing: true
            });

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

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

        // splitBefore :: (a -> Bool) -> [a] -> [[a]]
        const splitBefore = (p, xs) => {
            const dct = xs.reduce((a, x) =>
                p(x) ? {
                    splits: a.splits.concat([a.active]),
                    active: [x]
                } : {
                    splits: a.splits,
                    active: a.active.concat(x)
                }, {
                    splits: [],
                    active: []
                });
            return dct.splits.concat([dct.active]);
        };

        // splitOn :: String -> String -> [String]
        const splitOn = (cs, xs) => xs.split(cs);

        // stringChars :: String -> [Char]
        const stringChars = s => s.split('');

        // strip :: Text -> Text
        const strip = s => s.trim();

        // takeWhile :: (a -> Bool) -> [a] -> [a]
        const takeWhile = (f, xs) => {
            for (var i = 0, lng = xs.length;
                (i < lng) && f(xs[i]); i++) {}
            return xs.slice(0, i);
        };

        // unconsMay :: [a] -> Maybe (a, [a])
        const unconsMay = xs => xs.length > 0 ? {
            just: [xs[0], xs.slice(1)],
            nothing: false
        } : {
            nothing: true
        };

        // NESTED TEXT -----------------------------------------------------------

        // nestedLines :: Int -> [(Int, String)] -> Node {text:String, nest:[Node]}
        const nestedLines = (startLevel, xs) =>
            concatMap(
                lines => {
                    const mbht = unconsMay(lines);
                    return mbht.nothing ? [] : (() => {
                        const [h, t] = mbht.just;
                        return ([h.level === startLevel ? { // Normal node
                            text: h.text,
                            nest: isNull(t) ? [] : nestedLines(startLevel + 1, t)
                        } : { // Virtual root
                            text: '',
                            nest: nestedLines(startLevel, lines)
                        }]);
                    })();
                },
                splitBefore(x => x.level === startLevel, xs)
            );

        // lineIndents :: String -> [(Int, String)]
        const lineIndents = s =>
            length(strip(s)) > 0 ? (() => {
                const
                    lstLines = lines(s),
                    lineIndent = strLine => {
                        const xs = takeWhile(
                            curry(flip(elem))([' ', '\t']),
                            stringChars(strLine)
                        );
                        return length(xs) > 0 ? [xs] : [];
                    },
                    minDents = minimumByMay(
                        comparing(length),
                        concatMap(lineIndent, lstLines)
                    ),
                    strIndent = minDents.nothing ? '\t' : concat(minDents.just);
                return map(x => {
                        const
                            xs = curry(splitOn)(strIndent, x),
                            rs = dropWhile(x => x === '', xs);
                        return {
                            level: length(xs) - length(rs),
                            text: intercalate(strIndent, rs)
                        };
                    },
                    lstLines
                );
            })() : [{
                level: 0,
                text: ''
            }];

        const oneWriterTextNest = () =>
            nestedLines(0, lineIndents(editor.getText()));


        // 1WRITER -> OMNIJS ----------------------------------------------------------

        // evaluateOmniJS :: (Options -> ()) -> { KeyValues ..}
        const evaluateOmniJS = (f, dctOptions) => {

            //ap([app.setClipboard, app.openURL], //
            //[
            return 'omnigraffle:///omnijs-run?script=' +
                encodeURIComponent(
                    '(' + f.toString() + ')(' +
                    (dctOptions && Object.keys(dctOptions)
                        .length > 0 ? show(dctOptions) : '') + ')'
                )
            //]
            //);
        };

        const strURL = evaluateOmniJS(outlineDiagram, {
            //  [Node {text:String, nest:[Node]}]
            tree: oneWriterTextNest()
        });

        app.setClipboard(strURL);
        app.openURL(strURL);
        //ui.alert(strURL);

        //return ui.alert(
        //    show(
        //        oneWriterTextNest()
        //    )
        //);
    })();