Save or Copy Scapple diagram as OPML/tabbed outline (treating arrows as parent → child)

Literature and Latte's excellent Scapple doesn't inherently use a nested architecture:

  1. All nodes are peers, by default,
  2. The built-in Save As OPML yields a flat, un-nested (unindented) list of peer nodes.

If, however, we choose to interpret Scapple arrow links as parent → child links then we can:

  1. Write some code to Save As OPML with nested structure, or
  2. Copy selections (or the whole file) as a Tab-indented plain text outline.

Here are a couple macros, for Save As OPML outline, and Copy As Tab-indented outline.

Scapple Macros.kmmacros (111.9 KB)

Scapple doesn't have a scripting API, so these macros work by:

  1. Reading any com.scapple.internal.pboardType content in the clipboard to get IDs of selected+copied nodes
  2. Parsing the XML .scap file for the active Scapple window, to obtain the detail of shapes and links.

(JS source below)

Untitled

JavaScript source

// Ver 0.03 Handled a minor bug which can arise in Scapple XML files
(() => {
    'use strict';

    ObjC.import('AppKit');

    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            blnOPML = Boolean(eval(kme.getvariable(
                'scappleAsOPML'))),
            strDefaultPath = kme.getvariable(
                'defaultScappleOutPath'
            );
        return bindLR(
            bindLR(
                allOrCopiedScapplesLR(),
                dot(Right, (
                    blnOPML ? (
                        opmlFromTrees('Scapple')
                    ) : tabIndentFromTrees
                )(fScappleText))
            ),
            blnOPML ? (
                savedAsOPMLR(strDefaultPath)
            ) : copiedAsTabIndentedLR
        );
    };

    // GENERIC FUNCTIONS ----------------------------------

    // Just :: a -> Just a
    const Just = x => ({
        type: 'Maybe',
        Nothing: false,
        Just: x
    });

    // Left :: a -> Either a b
    const Left = x => ({
        type: 'Either',
        Left: ' ' + x
    });

    // Node :: a -> [Tree a] -> Tree a
    const Node = (v, xs) => ({
        type: 'Node',
        root: v,
        nest: xs || []
    });

    // Nothing :: () -> Nothing
    const Nothing = () => ({
        type: 'Maybe',
        Nothing: true,
    });

    // Right :: b -> Either a b
    const Right = x => ({
        type: 'Either',
        Right: x
    });

    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = (a, b) => ({
        type: 'Tuple',
        '0': a,
        '1': b,
        length: 2
    });

    // any :: (a -> Bool) -> [a] -> Bool
    const any = (p, xs) => xs.some(p);

    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const append = (xs, ys) => xs.concat(ys);

    // bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
    const bindLR = (m, mf) =>
        m.Right !== undefined ? (
            mf(m.Right)
        ) : m;

    // compare :: a -> a -> Ordering
    const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);

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

    // cons :: a -> [a] -> [a]
    const cons = (x, xs) => [x].concat(xs);

    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = strPath => {
        const ref = Ref();
        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(strPath)
                .stringByStandardizingPath, ref
            ) && ref[0] !== 1;
    };

    // difference (\\) :: Eq a => [a] -> [a] -> [a]
    const difference = (xs, ys) =>
        xs.filter(x => ys.indexOf(x) === -1);

    // doesDirectoryExist :: FilePath -> IO Bool
    const doesDirectoryExist = strPath => {
        const ref = Ref();
        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(strPath)
                .stringByStandardizingPath, ref
            ) && ref[0] === 1;
    };

    // dot (.) :: (b -> c) -> (a -> b) -> a -> c
    const dot = (f, g) => x => f(g(x));

    // drop :: Int -> [a] -> [a]
    // drop :: Int -> String -> String
    const drop = (n, xs) => xs.slice(n);

    // 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.includes(x);

    // enumFromToInt :: Int -> Int -> [Int]
    const enumFromToInt = (m, n) =>
        n >= m ? (
            iterateUntil(x => x >= n, x => 1 + x, m)
        ) : [];

    // eq (==) :: Eq a => a -> a -> Bool
    const eq = (a, b) => {
        const t = typeof a;
        return t !== typeof b ? (
            false
        ) : t !== 'object' ? (
            a === b
        ) : (() => {
            const aks = Object.keys(a);
            return aks.length !== Object.keys(b).length ? (
                false
            ) : aks.every(k => eq(a[k], b[k]));
        })();
    };

    // even :: Int -> Bool
    const even = n => n % 2 === 0;

    // filePath :: String -> FilePath
    const filePath = s =>
        ObjC.unwrap(ObjC.wrap(s)
            .stringByStandardizingPath);

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

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

    // foldl1 :: (a -> a -> a) -> [a] -> a
    const foldl1 = (f, xs) =>
        xs.length > 1 ? xs.slice(1)
        .reduce(f, xs[0]) : xs[0];

    // foldlTree :: (b -> a -> b) -> b -> Tree a -> b
    const foldlTree = (f, acc, node) => {
        const go = (a, x) =>
            x.nest.reduce(go, f(a, x));
        return go(acc, node);
    };

    // index (!!) :: [a] -> Int -> a
    const index = (xs, i) => xs[i];

    // iterateUntil :: (a -> Bool) -> (a -> a) -> a -> [a]
    const iterateUntil = (p, f, x) => {
        let vs = [x],
            h = x;
        while (!p(h))(h = f(h), vs.push(h));
        return vs;
    };

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

    const mapAccumL = (f, acc, xs) =>
        xs.reduce((a, x, i) => {
            const pair = f(a[0], x, i);
            return Tuple(pair[0], a[1].concat(pair[1]));
        }, Tuple(acc, []));

    // Expects functions in the argument list to be
    // paired with Bools:
    //     true  -> ascending sort on that key
    //     false -> descending sort on that key
    // mappendComparing_ :: [((a -> b), Bool)] ->
    //      (a -> a -> Ordering)
    const mappendComparing_ = fboolPairs =>
        (x, y) => fboolPairs.reduce(
            (ordr, fb) => {
                const f = fb[0];
                return ordr !== 0 ? (
                    ordr
                ) : fb[1] ? (
                    compare(f(x), f(y))
                ) : compare(f(y), f(x));
            }, 0
        );

    // mean :: [Num] -> Num
    const mean = xs =>
        xs.reduce((a, x) => a + x, 0) / xs.length;

    // partition :: Predicate -> List -> (Matches, nonMatches)
    // partition :: (a -> Bool) -> [a] -> ([a], [a])
    const partition = (p, xs) =>
        xs.reduce(
            (a, x) =>
            p(x) ? (
                Tuple(a[0].concat(x), a[1])
            ) : Tuple(a[0], a[1].concat(x)),
            Tuple([], [])
        );

    // readFile :: FilePath -> IO String
    const readFile = strPath => {
        let error = $(),
            str = ObjC.unwrap(
                $.NSString
                .stringWithContentsOfFileEncodingError(
                    $(strPath)
                    .stringByStandardizingPath,
                    $.NSUTF8StringEncoding,
                    error
                )
            );
        return Boolean(error.code) ? (
            ObjC.unwrap(error.localizedDescription)
        ) : str;
    };

    // showJSON :: a -> String
    const showJSON = x => JSON.stringify(x, null, 2);

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

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = (f, xs) =>
        xs.slice()
        .sort(f);

    // Split a filename into directory and file.
    // combine is the inverse.
    // splitFileName :: FilePath -> (String, String)
    const splitFileName = strPath =>
        strPath !== '' ? (
            strPath[strPath.length - 1] !== '/' ? (() => {
                const
                    xs = strPath.split('/'),
                    stem = xs.slice(0, -1);
                return stem.length > 0 ? (
                    Tuple(stem.join('/') + '/', xs.slice(-1)[0])
                ) : Tuple('./', xs.slice(-1)[0]);
            })() : Tuple(strPath, '')
        ) : Tuple('./', '');

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // unwords :: [String] -> String
    const unwords = xs => xs.join(' ');

    // unwrap :: NSObject -> a
    const unwrap = o => ObjC.unwrap(o);

    // words :: String -> [String]
    const words = s => s.split(/\s+/);

    // writeFile :: FilePath -> String -> IO ()
    const writeFile = (strPath, strText) =>
        $.NSString.alloc.initWithUTF8String(strText)
        .writeToFileAtomicallyEncodingError(
            $(strPath)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );

    // zip :: [a] -> [b] -> [(a, b)]
    const zip = (xs, ys) =>
        xs.slice(0, Math.min(xs.length, ys.length))
        .map((x, i) => Tuple(x, ys[i]));

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = (f, xs, ys) =>
        Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => f(xs[i], ys[i], i));

    // TREES ----------------------------------------

    // First argument is a text accessor function
    // eg x => x.root.String
    // or x => x.root

    // fScappleText :: Node -> String
    const fScappleText = x => x.root.String || '';

    // tabIndentFromTrees :: (Tree -> String) ->
    //      [Tree] -> String
    const tabIndentFromTrees = fnText => trees => {
        const go = indent => tree =>
            indent + fnText(tree) +
            (tree.nest.length > 0 ? (
                '\n' + tree.nest
                .map(go('\t' + indent))
                .join('\n')
            ) : '');
        return trees.map(go('')).join('\n');
    };

    // First argument is a text accessor function,
    // e.g. x => x.root.String || ''
    // or   x => x.root

    // opmlFromTrees :: String -> (Tree -> String) -> [Tree] ->
    //  Optional String -> OPML String
    const opmlFromTrees = strTitle => fnText => xs => {
        const
            // ents :: [(Regex, String)]
            ents = zipWith.apply(null,
                cons(
                    (x, y) => [new RegExp(x, 'g'), '&' + y + ';'],
                    map(words, ['& \' " < >', 'amp apos quot lt gt'])
                )
            ),

            // entCoded :: a -> String
            entCoded = v => ents.reduce(
                (a, [x, y]) => a.replace(x, y),
                v.toString()
            ),

            // Nest -> Comma-delimited row indices of all parents in tree
            // expands :: [textNest] -> String
            expands = xs => {
                const indexAndMax = (n, xs) =>
                    mapAccumL((m, node) =>
                        node.nest.length > 0 ? (() => {
                            const sub = indexAndMax(m + 1, node.nest);
                            return [sub[0], cons(m, concat(sub[1]))];
                        })() : [m + 1, []], n, xs);
                return concat(indexAndMax(0, xs)[1]).join(',');
            };

        // go :: String -> Dict -> String
        const go = indent => node =>
            indent + '<outline ' + unwords(map(
                ([k, v]) => k + '="' + entCoded(v) + '"',
                cons(['text', fnText(node) || ''], node.kvs || [])
            )) + (node.nest.length > 0 ? (
                '>\n' +
                unlines(map(go(indent + '    '), node.nest)) +
                '\n' +
                indent + '</outline>'
            ) : '/>');

        // OPML serialization -----------------------------
        return unlines(concat([
            [
                '<?xml version=\"1.0\" encoding=\"utf-8\"?>',
                '<opml version=\"2.0\">',
                '  <head>',
                '    <title>' + (strTitle || '') + '</title>',
                '    <expansionState>' + expands(xs) +
                '</expansionState>',
                '  </head>',
                '  <body>'
            ],
            map(go('    '), xs), [
                '  </body>',
                '</opml>'
            ]
        ]));
    };
    // JXA ---------------------------------------------

    const uw = ObjC.unwrap;

    // filePathFromURL :: String -> String
    const filePathFromURL = fileURL =>
        drop(7, decodeURIComponent(fileURL));

    // confirmSavePathLR :: FilePath -> Either Message FilePath
    const confirmSavePathLR = fp => {
        const
            sa = standardSEAdditions(),
            tpl = splitFileName(fp),
            fldr = tpl[0],
            fname = tpl[1];
        return (() => {
            sa.activate();
            try {
                return Right(
                    sa.chooseFileName({
                        withPrompt: "Save As:",
                        defaultName: fname,
                        defaultLocation: Path(ObjC.unwrap(
                            $(doesDirectoryExist(fldr) ? (
                                fldr
                            ) : '~')
                            .stringByExpandingTildeInPath
                        ))
                    })
                    .toString()
                );
            } catch (e) {
                return Left(e.message)
            };
        })();
    };

    // savedAsOPMLR :: FilePath -> String ->
    //                  Either String IOString
    const savedAsOPMLR = fp => strOutline =>
        bindLR(
            confirmSavePathLR(fp),
            fp => (
                writeFile(fp, strOutline),
                Right('OPML written to ' + fp)
            )
        );

    // copiedAsTabIndentedLR :: String -> Either String IOString
    const copiedAsTabIndentedLR = s =>
        (
            standardSEAdditions()
            .setTheClipboardTo(s),
            Right(s)
        );

    // SYSTEM EVENTS ------------------------------------

    // standardSEAdditions :: () -> Application
    const standardSEAdditions = () =>
        Object.assign(Application('System Events'), {
            includeStandardAdditions: true
        });

    // frontScappleWindowFileUrlLR :: () -> URL
    const frontScappleWindowFileUrlLR = () => {
        const
            procs = standardSEAdditions()
            .applicationProcesses.where({
                name: 'Scapple'
            });
        return bindLR(
            bindLR(
                procs.length > 0 ? (
                    Right(procs.at(0).windows)
                ) : Left('No frontmost GUI app found'),
                ws => ws.length > 0 ? (
                    Right(ws.at(0))
                ) : Left('No windows found for Scapple')
            ),
            w => Right(w.attributes.byName('AXDocument').value())
        );
    };

    // SCAPPLE XML ----------------------------------------------



    // dictFromXMLPathStringLR :: String -> Either String Dict
    const dictFromXMLPathStringLR = strPath => {

        // attribsDictLR :: NSArray -> Either String Dict
        const attribsDictLR = attribs => {
            const v = uw(attribs);
            return Array.isArray(v) ? (
                uw(v[0].name) === 'ID' ? (
                    Right(
                        v.reduce(
                            (a, attr) =>
                            Object.assign(a, {
                                [uw(
                                    attr.name
                                )]: uw(
                                    attr.stringValue
                                )
                            }), {}
                        )
                    )
                ) : Left('No id')
            ) : Left('Not Array');
        };

        // xmlNodeDict :: NSXMLNode -> Dictionary
        const xmlNodeDict = xmlNode => {
            const
                go = x => parseInt(x.childCount, 10) > 0 ? {
                    root: bindLR(
                        x.attributes !== undefined ? (
                            Right(x.attributes)
                        ) : Left('None'),
                        attribsDictLR
                    ).Right || uw(x.name),
                    nest: uw(x.children)
                        .map(go)
                } : {
                    root: uw(x.stringValue),
                    nest: []
                };
            return go(xmlNode);
        };

        const fp = filePath(strPath);
        return bindLR(
            doesFileExist(fp) ? (
                Right(readFile(fp))
            ) : Left('File not found: ' + fp),
            strXML => {
                const
                    error = $(),
                    node = $.NSXMLDocument.alloc
                    .initWithXMLStringOptionsError(
                        strXML, 0, error
                    );
                return Boolean(error.code) ? (
                    Left(`File could not be parsed as XML: '${fp}'`)
                ) : Right(xmlNodeDict(node));
            }
        );
    };

    // listFromRangesString :: String -> [Int]
    const listFromRangesString = s =>
        concatMap(x => {
            const parts = map(
                s => parseInt(s, 10),
                x.split('-')
            );
            return parts.length > 1 ? (
                enumFromToInt(...parts)
            ) : isNaN(parts[0]) ? [] : parts;
        }, s.split(/,\s*/g));

    // nodeListFromFileLR :: FilePath -> Either String [Dict]
    const nodeListFromFileLR = fp =>
        bindLR(
            dictFromXMLPathStringLR(fp),
            dctXML => Right(map(
                x => x.nest.reduce(
                    (a, child) => {
                        const k = child.root;
                        return k !== 'Appearance' ? (
                            Object.assign(a, {
                                [k]: k.endsWith('IDs') ? (
                                    listFromRangesString(
                                        child.nest[0].root
                                    )
                                ) : child.nest[0].root
                            })
                        ) : a;
                    }, {
                        'ID': parseInt(x.root.ID, 10),
                        'xy': map(
                            s => parseFloat(s, 10),
                            x.root.Position.split(',')
                        )
                    }
                ),
                dctXML.nest[0].nest[0].nest
            ))
        );

    // Root IDs at head of list, with remainder sorted by
    // descending childCount and
    // and ascending distance from origin at top left.

    // rootIDsFromNodes :: [Dict] -> [Int]
    const rootEtcIDsFromNodes = nodes => {
        const
            // Shapes with only *outgoing* arrows, vs. the rest.
            tplRootsRest = partition(
                x => eq(
                    x.ConnectedNoteIDs,
                    x.PointsToNoteIDs
                ),
                // nodes sorted first by number of outgoing
                // connections, ??
                // and then by distance from
                // origin at top left
                sortBy(
                    mappendComparing_([
                        // [x => x.childCount, false],
                        [x => x.delta, true]
                    ]),
                    map(x => Object.assign(x, {
                            delta: Math.sqrt(
                                Math.pow(x.xy[0], 2) +
                                Math.pow(x.xy[1], 2)
                            ),
                            childCount: (x.PointsToNoteIDs || [])
                                .length
                        }),
                        nodes
                    )
                )
            );
        return map(
            x => x.ID,
            tplRootsRest[0].concat(
                tplRootsRest[1]
            )
        );
    };

    // geoSortedChildIDs :: Dict -> [Int] -> [Int]
    const geoSortedChildIDs = (dctShapes, dctParent) => {
        // Anti-clockwise angles to parent due North
        const
            ids = filter(
                k => Boolean(dctShapes[k]),
                dctParent.PointsToNoteIDs || []
            ),
            [px, py] = dctParent.xy,
            pi = Math.PI,
            [east, south, west, north] = [2, 1.5, 1, 1.5]
            .map(x => pi * x),
            radDegrees = 180 / Math.PI,

            // clockDegreesFromDxDy :: Bool -> Float -> Float -> Float
            clockDegreesFromDxDy = (blnClock, radStart, dx, dy) =>
            (radStart + (blnClock ? 1 : -1) * Math.atan2(dy, dx)) *
            radDegrees % 360,

            antiClockDegreesFromNorth = ([dx, dy]) =>
            clockDegreesFromDxDy(
                false, north, dx, dy
            ),

            quadrantFunction = ma => {
                const blnClock = ma <= 45 || ma > 225;
                return ma <= 135 || ma > 315 ? (
                    blnClock ? (
                        ([dx, dy]) => clockDegreesFromDxDy(
                            true, south, dx, dy
                        )
                    ) : ([dx, dy]) => clockDegreesFromDxDy(
                        false, east, dx, dy
                    )
                ) : blnClock ? (
                    ([dx, dy]) => clockDegreesFromDxDy(
                        true, west, dx, dy
                    )
                ) : antiClockDegreesFromNorth;
            };

        return Boolean(ids) && ids.length > 0 ? (() => {
            const
                // Anti-clockwise angles from North
                // (of vectors from parent to children).
                alphas = map(
                    k => {
                        const xy = dctShapes[k].xy;
                        return antiClockDegreesFromNorth(
                            [xy[0] - px, xy[1] - py]
                        );
                    },
                    ids
                ),
                sortFn = quadrantFunction(
                    mean(
                        any(x => x < 45, alphas) &&
                        any(x => x > 315, alphas) ? (
                            map(x => x < 45 ? 360 + x : x, alphas)
                        ) : alphas
                    )
                );

            return map(
                x => x.ID,
                sortBy(
                    comparing(x => x.theta),
                    map(k => {
                            const
                                shp = dctShapes[k],
                                xy = shp.xy;
                            return Object.assign(
                                shp, {
                                    theta: sortFn(
                                        [xy[0] - px, xy[1] - py]
                                    )
                                }
                            );
                        },
                        ids || []
                    )
                )
            );
        })() : [];
    };

    // scappleTreesFromIdList :: Dict -> [Int] -> [Tree]
    const scappleTreesFromIdList = (dctShapes, shapeIDs) => {
        const go = (rootIDs, visited) =>
            foldl(
                (a, intID) =>
                a[1].includes(intID) ? (
                    a
                ) : (() => {
                    const
                        shpParent = dctShapes[intID],
                        tplSub = go(
                            geoSortedChildIDs(
                                dctShapes,
                                shpParent
                            ),
                            a[1].concat(intID)
                        );
                    return Tuple(
                        a[0].concat(
                            Node(
                                shpParent,
                                tplSub[0]
                            )
                        ),
                        tplSub[1]
                    );
                })(), Tuple([], visited),
                rootIDs
            );
        return go(shapeIDs, [])[0];
    };

    // CLIPBOARD XML ---------------------------------------------

    // plistStringLR :: NSObject -> {error : String |
    //      Undefined, plist: String}
    const plistStringLR = nsObject => {
        let error = $();
        const
            v = $.NSString.alloc.initWithDataEncoding(
                $.NSPropertyListSerialization
                .dataWithPropertyListFormatOptionsError(
                    nsObject,
                    $.NSPropertyListXMLFormat_v1_0,
                    0,
                    error
                ),
                $.NSUTF8StringEncoding
            );
        return Boolean(error.code) ? (
            Left(error.localizedDescription)
        ) : Right(uw(v));
    };

    // xmlDocNodeLR :: XML String -> Either String XMLNode
    const xmlDocNodeLR = strXML => {
        const
            error = $(),
            node = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                strXML, 0, error
            );
        return Boolean(error.code) ? (
            Left('Could not be parsed as XML')
        ) : Right(node);
    };

    // plistArrayNodeFromXMLDoc :: XML Node -> XML Node
    const plistArrayNodeFromXMLDoc = nodeDoc =>
        nodeDoc.childAtIndex(0)
        .childAtIndex(0)
        .childAtIndex(3);

    // nsIndicesFromNode :: xmlNode -> [Index]
    const nsIndicesFromNode = node =>
        map(x => uw(
                x.childAtIndex(1)
                .stringValue
            ),
            uw(node.children)
        );

    // noteAttribDictFromIndex :: Plist Array -> Index -> Dict
    const noteAttribDictFromIndex = (plistArray, index) =>
        foldl(
            (a, x, i, xs) => even(i) ? (() => {
                const
                    nodeAttrib = plistArray[
                        uw(xs[1 + i].childAtIndex(1).stringValue)
                    ],
                    k = uw(x.stringValue);
                // return 'dict' !== uw(nodeAttrib.name) ? (() => {
                return ('string' === k || 'uniqueID' === k) ? (() => {
                    const v = uw(nodeAttrib.stringValue);
                    return '$null' !== v ? (
                        a[k] = v,
                        a
                    ) : a;
                })() : a;
            })() : a, {},
            uw(plistArray[index].children)
        );

    // plistKeyDictFromArray :: Plist Array -> Dict
    const plistKeyDictFromArray = plistArray => {
        const dctIndices = foldl(
            (a, x, i, xs) => 'key' === uw(
                x.name
            ) ? (() => {
                const k = uw(x.stringValue);
                return k !== '$class' ? (
                    a[k] = nsIndicesFromNode(
                        xs[i + 1]
                    ),
                    a
                ) : a
            })() : a, {},
            uw(dropWhile(
                x => uw(x.name) !== 'dict',
                plistArray
            )[0].children)
        );
        return foldl(
            (a, tpl) => (
                a[tpl[0]] = tpl[1],
                a
            ), {},
            zip(map(
                x => uw(
                    plistArray[x].stringValue
                ),
                dctIndices['NS.keys']
            ), map(
                x => plistArray[x],
                dctIndices['NS.objects']
            ))
        );
    };

    // attributesOfNotesLR :: XMLNode -> Either String [Dict]
    const attributesOfNotesLR = nodeDoc => {
        const
            plistArray = uw(
                plistArrayNodeFromXMLDoc(
                    nodeDoc
                ).children
            ),
            ixArrays = dropWhile(
                x => 'array' !== uw(x.name),
                uw(plistKeyDictFromArray(
                        plistArray
                    )['Notes']
                    .children
                )
            );
        return bindLR(
            ixArrays.length > 0 ? (
                Right(ixArrays[0])
            ) : Left('array not found in Notes dict'),
            niArray => Right(map(
                x => noteAttribDictFromIndex(
                    plistArray,
                    uw(
                        x.childAtIndex(1)
                        .stringValue
                    )
                ),
                uw(niArray.children)
            )));
    };

    // scappleNotesInClipboardLR :: Scapple () ->
    //                          Either String [Dict]
    const scappleNotesInClipboardLR = () => {
        const clipScapple = ObjC.deepUnwrap(
            $.NSPasteboard.generalPasteboard
            .propertyListForType(
                'com.scapple.internal.pboardType'
            )
        );
        return bindLR(
            Boolean(clipScapple) ? (
                Right(clipScapple)
            ) : Left('No Scapple selection in clipboard'),
            nsPlist => bindLR(
                plistStringLR(nsPlist),
                strXML => {
                    //console.log('strXML', strXML);
                    return bindLR(
                        xmlDocNodeLR(strXML),
                        attributesOfNotesLR
                    );
                }
            )
        );
    };

    // allOrCopiedScapplesLR :: () -> Either String [Tree scappleShape]
    const allOrCopiedScapplesLR = () =>
        bindLR(
            bindLR(
                bindLR(
                    frontScappleWindowFileUrlLR(),
                    dot(Right, filePathFromURL)
                ),
                nodeListFromFileLR
            ),
            nodes => Right(
                scappleTreesFromIdList(

                    // All nodes hashed by ID
                    foldl(
                        (a, x) => (
                            a[x.ID] = x,
                            a
                        ), {},
                        nodes
                    ),

                    // Copied nodes or all nodes
                    bindLR(
                        scappleNotesInClipboardLR(),
                        xs => Right(map(
                            x => parseInt(x.uniqueID, 10),
                            xs
                        ))
                    ).Right || rootEtcIDsFromNodes(nodes)
                )
            )
        );

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