Pasting DEVONthink 3 MD links from a dialog with MRU memory

I find myself pasting a fair number of Markdown format links to resources in DEVONthink3 at the moment.

Hook gives one useful way of copying DT3 items as MD links, but (at the time of writing) is limited to one link at a time.

Here is a menu dialog which lets us:

  • browse up and down the tree structure of all open DT3 databases
  • select multiple items to paste as MD links
  • return straight to the DT3 group most recently pasted from, whenever we relaunch it.

Paste one or more MD Links from DT3.kmmacros (56.5 KB)

  • The Back button moves back up the hierarchy,
  • and the OK button either pastes MD Links (if DT3 records are selected), or, when a group is selected, moves down into a group.

(I have placed a sibling macro in the .kmmacros file above for clearing MRU memory, just in case anyone ever finds that useful)

JavaScript Source
(() => {
    'use strict';

    // Rob Trew 2020
    // Ver 0.01

    // Browsing open DEVONthink 3 databases and pasting one
    // or more links from a dialog which remembers 
    // the most recently used location in the data-trees.

    // main :: IO ()
    const main = () => {
        const fpMRU = getTemporaryDirectory() + 'mruDevonLinks.json';
        return either(constant(''))(tpl => {
            const [menuPath, menuSelns, chosenRecords] = list(tpl);
            return (
                // Any MRU memory written to file.
                writeFile(fpMRU)(JSON.stringify(
                    Tuple(menuPath)(menuSelns),
                    null, 2
                )),
                // Any Markdown links returned.
                unlines(chosenRecords.map(
                    x => `[${x.name()}]` +
                    `(x-devonthink-item://${x.uuid()})`
                ))
            );
        })(devonLinksMenu(fpMRU));
    };

    // BROWSING DEVONTHINK 3 DATA, PASTING MARKDOWN LINKS

    // devonLinksMenu :: FilePath -> IO String
    const devonLinksMenu = fpMRU =>
        mruTreeMenu('MD links from DevonThink 3')(
            // Menu label from tree node value.
            x => x.name()
        )(
            // Predicate - true if tree node x is a leaf.
            x => 'record' === x.class()
        )(
            // Any MRU memory of path + selections retrieved.
            either(constant(Tuple([])([])))(identity)(
                bindLR(readFileLR(fpMRU))(
                    jsonParseLR
                )
            )
        )(devonTree())

    // LAZILY-EVALUATED SUBTREES OF DEVONTHINK DATABASES

    // devonTree :: IO () -> Tree DT3 Item
    const devonTree = () =>
        // A virtal tree with a subforest of open DT3
        // databases, with descendants at each level
        // evaluated only on demand (by the nest() accessor)
        Node({
            name: () => 'DevonThink 3',
            class: () => 'root'
        })(
            map(flip(Node)(dtLazyRecords))(
                Application('DEVONthink 3').databases()
            )
        );

    // dtLazyRecords :: DT Database -> [Tree DT Record]
    const dtLazyRecords = db => {
        const xs = db.records;
        return 0 < xs.length ? (
            xs().map(flip(Node)(dtLazyChildren))
        ) : [];
    };

    // dtLazyChildren :: DT Record -> [DT Record]
    const dtLazyChildren = rec => {
        const xs = rec.children;
        return 0 < xs.length ? (
            xs().map(flip(Node)(dtLazyChildren))
        ) : [];
    };

    // ---------GENERIC MRU MEMORY FROM LAZY TREE----------

    // mruTreeMenu ::
    // (a -> String) ->  // A menu label for the root value of a Node
    // ([String], [String]) ->  // Any MRU label path to a sub-menu,
    //                          // and any pre-selected labels.
    // Tree a ->                // Tree (not necessarily of Strings)
    // Either String ([String], [String], [a])
    const mruTreeMenu = legend =>
        // Either a message or a:
        // (menuPath, chosenLabels, chosenValues) tuple, in which
        // menuPath :: is a list of strings representing
        // the path of the chosen leaf menu in the whole menu-tree, and
        // chosenLabels :: is a list of the chosen leaf-menu strings
        // chosenValues :: is a list of of the values chosen in that menu.
        // (Of type a – not necessarily String)
        keyFromRoot => leafTest => mruTuple => tree => {
            let blnInit = true; // True only for first recursion (with MRU)
            const
                fKey = compose(keyFromRoot, root),
                pLeaf = compose(leafTest, root);
            const go = mruPair => nodePath => t => {
                const
                    children = nest(t),
                    strTitle = fKey(t),
                    mruPath = dropWhile(eq(strTitle))(
                        mruPair[0]
                    ),
                    // mruPath = mruPair[0],
                    selns = mruPair[1];

                // menuBrowse :: IO () -> (Bool, [String])
                const menuBrowse = () =>
                    until(exitOrLeafChoices)(
                        tpl => either(
                            constant(Tuple(false)([]))
                        )(Tuple(true))(
                            bindLR(menuSelectionOrExit())(
                                matchingValues
                            )
                        )
                    )(Tuple(true)([]))[1];

                // exitOrLeafChoices :: (Bool, [String]) -> Bool
                const exitOrLeafChoices = tpl =>
                    !fst(tpl) || !isNull(snd(tpl));

                // menuSelectionOrExit :: IO () ->
                // Either String [String]
                const menuSelectionOrExit = () => {
                    const
                        choices = map(x => {
                            const r = x.root;
                            return Tuple(leafTest(r))(
                                keyFromRoot(r)
                            );
                        })(children),
                        menu = sortBy(menuOrder)(
                            map(x => Tuple(
                                x.startsWith('▶ ') ? (
                                    Tuple(x.slice(0, 2))(
                                        x.slice(2)
                                    )
                                ) : Tuple('')(x)
                            )(x))(
                                choices.map(tpl => (
                                    tpl[0] ? (
                                        ''
                                    ) : '▶ '
                                ) + tpl[1])
                            )
                        ).map(snd),
                        blnMultiSeln = 1 < children.reduce(
                            (a, x) => pLeaf(x) ? 1 + a : a,
                            0
                        );
                    return blnInit && 0 < mruPath.length &&
                        elem(mruPath[0])(choices.map(snd)) ? (
                            Right([mruPath[0]]) // Any MRU path.
                        ) : showMenuSelectionLR(blnMultiSeln)(
                            1 < nodePath.length ? (
                                'Back'
                            ) : 'Cancel'
                        )(legend)(
                            nodePath.join(' > ') + '\n\n' +
                            menu.length.toString() + ' ' + (
                                blnMultiSeln ? (
                                    'choice(s):'
                                ) : 'sub-menus:'
                            )
                        )(menu)( // Any selection MRU.
                            blnInit ? selns : []
                        );
                };

                // matchingValues :: [String] ->
                // Either String [a]
                const matchingValues = ks => {
                    const k = ks[0];
                    return maybe(Left('Not found: ' + k))(
                        menuResult(ks)
                    )(find(
                        compose(eq(k), fKey)
                    )(children));
                };

                // menuOrder :: ((String), String) ->
                // ((String), String) -> Ord
                const menuOrder = a => b => {
                    // Parent status DESC, then A-Z ASC
                    const
                        x = a[0],
                        y = b[0];
                    return x[0] < y[0] ? (
                        1
                    ) : x[0] > y[0] ? (
                        -1
                    ) : x[1] < y[1] ? (
                        -1
                    ) : x[1] > y[1] ? 1 : 0
                };

                // menuResult :: String -> Tree a ->
                // Either String ([String], [String], [Tree a])
                const menuResult = chosenKeys => subTree => {
                    // (Menu path, labels, values)
                    const setChosen = new Set(chosenKeys);
                    return Right(
                        isNull(nest(subTree)) ? [Right(
                            TupleN(
                                nodePath, chosenKeys,
                                concatMap(
                                    v => {
                                        const r = v.root;
                                        return setChosen.has(
                                            keyFromRoot(r)
                                        ) ? [r] : []
                                    }
                                )(sortOn(fKey)(nest(t)))
                            )
                        )] : leafMenuResult(subTree)
                    );
                };

                // leafMenuResult :: Tree a ->
                // [Either String ([String], [String], [Tree a])]
                const leafMenuResult = subTree => {
                    const
                        chosenLeafKeys = go(
                            Tuple(mruPath.slice(1))(selns)
                        )(nodePath.concat(fKey(subTree)))(
                            subTree
                        );
                    return ( // MRU initialisation complete.
                        blnInit = false,
                        chosenLeafKeys
                    );
                };
                // Main menu process ...
                return menuBrowse();
            };

            const choices = go(mruTuple)([fKey(tree)])(tree);
            return (0 < choices.length && choices[0]) || (
                Left('User cancelled')
            );
        };

    // showMenuSelectionLR :: Bool -> String -> String ->
    // [String] -> [String] -> Either String [String]
    const showMenuSelectionLR = blnMult =>
        cancelName => title => prompt => xs => selns =>
        0 < xs.length ? (
            (() => {
                const
                    sa = Object.assign(
                        Application('System Events'), {
                            includeStandardAdditions: true
                        }),
                    v = (
                        sa.activate(),
                        sa.chooseFromList(xs, {
                            withTitle: title,
                            withPrompt: prompt,
                            defaultItems: blnMult ? (
                                selns
                            ) : 0 < selns.length ? (
                                selns[0]
                            ) : [],
                            okButtonName: 'OK',
                            cancelButtonName: cancelName,
                            multipleSelectionsAllowed: blnMult,
                            emptySelectionAllowed: false
                        })
                    );
                const pfx = /^▶ /g;
                return Array.isArray(v) ? (
                    Right(v.map(s => s.replace(pfx, '')))
                ) : Left(
                    'User cancelled ' + title + ' menu.'
                );
            })()
        ) : Left(title + ': No items to choose from.');

    // GENERIC FUNCTIONS ----------------------------
    // https://github.com/RobTrew/prelude-jxa

    // Just :: a -> Maybe 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 =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: 'Node',
            root: v,
            nest: xs || []
        });

    // Nothing :: Maybe a
    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
        });

    // TupleN :: a -> b ...  -> (a, b ... )
    function TupleN() {
        const
            args = Array.from(arguments),
            n = args.length;
        return 1 < n ? Object.assign(
            args.reduce((a, x, i) => Object.assign(a, {
                [i]: x
            }), {
                type: 'Tuple' + (2 < n ? n.toString() : ''),
                length: n
            })
        ) : args[0];
    };

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

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        x => fs.reduceRight((a, f) => f(a), x);

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

    // constant :: a -> b -> a
    const constant = k =>
        _ => k;

    // dropWhile :: (a -> Bool) -> [a] -> [a]
    // dropWhile :: (Char -> Bool) -> String -> String
    const dropWhile = p =>
        xs => {
            const lng = xs.length;
            return 0 < lng ? xs.slice(
                until(i => i === lng || !p(xs[i]))(
                    i => 1 + i
                )(0)
            ) : [];
        };

    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;

    // elem :: Eq a => a -> [a] -> Bool
    // elem :: Char -> String -> Bool
    const elem = x =>
        xs => {
            const t = xs.constructor.name;
            return 'Array' !== t ? (
                xs['Set' !== t ? 'includes' : 'has'](x)
            ) : xs.some(eq(x));
        };

    // eq (==) :: Eq a => a -> a -> Bool
    const eq = a =>
        // True when a and b are equivalent in the terms
        // defined below for their shared data type.
        b => {
            const t = typeof a;
            return t !== typeof b ? (
                false
            ) : 'object' !== t ? (
                'function' !== t ? (
                    a === b
                ) : a.toString() === b.toString()
            ) : (() => {
                const kvs = Object.entries(a);
                return kvs.length !== Object.keys(b).length ? (
                    false
                ) : kvs.every(([k, v]) => eq(v)(b[k]));
            })();
        };

    // find :: (a -> Bool) -> [a] -> Maybe a
    const find = p => xs => {
        const i = xs.findIndex(p);
        return -1 !== i ? (
            Just(xs[i])
        ) : Nothing();
    };

    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = f =>
        1 < f.length ? (
            (a, b) => f(b, a)
        ) : (x => y => f(y)(x));

    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];

    // getTemporaryDirectory :: IO FilePath
    const getTemporaryDirectory = () =>
        ObjC.unwrap($.NSTemporaryDirectory());

    // identity :: a -> a
    const identity = x =>
        // The identity function. (`id`, in Haskell)
        x;

    // isNull :: [a] -> Bool
    // isNull :: String -> Bool
    const isNull = xs =>
        1 > xs.length;

    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(`${e.message} (line:${e.line} col:${e.column})`);
        }
    };

    // list :: TupleN(a) -> [a]
    const list = tpl =>
        Array.from(tpl);

    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => (
            Array.isArray(xs) ? (
                xs
            ) : xs.split('')
        ).map(f);

    // maybe :: b -> (a -> b) -> Maybe a -> b
    const maybe = v =>
        // Default value (v) if m is Nothing, or f(m.Just)
        f => m => m.Nothing ? v : f(m.Just);

    // nest :: Tree a -> [a]
    const nest = tree => {
        // Allowing for lazy (on-demand) evaluation.
        // If the nest turns out to be a function –
        // rather than a list – that function is applied
        // here to the root, and returns a list.
        const children = tree.nest;
        return 'function' !== typeof children ? (
            children
        ) : children(tree.root);
    };

    // readFileLR :: FilePath -> Either String IO String
    const readFileLR = fp => {
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );
        return ns.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.unwrap(ns));
    };

    // root :: Tree a -> a
    const root = tree => tree.root;

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

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

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

    // sortOn :: Ord b => (a -> b) -> [a] -> [a]
    const sortOn = f =>
        // Equivalent to sortBy(comparing(f)), but with f(x)
        // evaluated only once for each x in xs.
        // ('Schwartzian' decorate-sort-undecorate).
        xs => xs.map(
            x => [f(x), x]
        ).sort(
            (a, b) => a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : 0)
        ).map(x => x[1]);

    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join('\n');

    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p => f => x => {
        let v = x;
        while (!p(v)) v = f(v);
        return v;
    };

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

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