Copy macros from front document, for pasting to another document

The dragging of Tinderbox macros from one document to another has been temporarily suspended in current builds after it was broken by Catalina.

Copy macros from front Tinderbox document.kmmacros.zip (12.9 KB)

Here is a Keyboard Maestro macro which, (at least on Mojave, I haven’t tested on Catalina), aims to let you:

  • list the macros in the front Tinderbox 8 document,
  • and select all or a subset for copying.

(in a clipboard format which then lets you paste them into another Tinderbox document)

Note that only saved macro edits are available for copying in this way – the script reads them from the most recent save of the front document’s .tbx file.

Once copied, you can paste them into another document by selecting it and using the usual ⌘V or Edit > Paste

(Not much will appear to happen, but you should then find the pasted macros in the Macros pane of the HTML inspector)

The JavaScript for Automation source is below, and can be used outside Keyboard Maestro, in any context from which JXA script can be run, for example in macOS Script Editor, with the language selector pull-down at top left set to JavaScript rather than AppleScript

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

    // Copy Tinderbox 8 macros from latest save of front document.
    // (For pasting to another document).

    // Rob Trew 2020
    // Ver 0.01

    ObjC.import('AppKit');

    // main :: IO ()
    const main = () =>
        either(alert('No macros copied'))(
            xmlMacros => {
                // XML for 'com.eastgate.tinderbox.scrap' clipboard:
                // XML composed,
                const
                    strMacrosXML = unlines([
                        '<?xml version="1.0" encoding="UTF-8" ?>',
                        '<tinderbox version="2" revision="12" >',
                        '<macros >',
                        xmlMacros[0].join(''),
                        '</macros>',
                        '</tinderbox>'
                    ]);
                return (
                    // XML copied as 'com.eastgate.tinderbox.scrap' pboard.
                    setClipOfTextType(
                        'com.eastgate.tinderbox.scrap'
                    )(strMacrosXML),

                    // Notification of copy,
                    Object.assign(Application.currentApplication(), {
                        includeStandardAdditions: true
                    }).displayNotification(
                        unwords(xmlMacros[1]), {
                            withTitle: str(xmlMacros[0].length) +
                                ' Tinderbox macros copied',
                            subtitle: '(from ' + xmlMacros[2] + ')'
                        }
                    ),

                    // and return of macro name list.
                    unlines(xmlMacros[1])
                );
            }
        )(bindLR(tbxFrontDocLR())(
            doc => bindLR(
                null !== doc.file() ? (
                    // XML read from latest save of document file.
                    macroDictFromTBXxml(
                        readFile(doc.file().toString())
                    )
                ) : Left('Changes not saved: ' + doc.name())
            )(dctMacros => {
                const ks = Object.keys(dctMacros);

                // User choice of macros to copy:
                return 0 < ks.length ? (
                    bindLR(
                        showMenuLR(true)('Copy Tinderbox Macros')(ks)
                    )(names => Right([
                        names.map(k => dctMacros[k]),
                        names,
                        doc.name()
                    ]))
                ) : Left('No macros found in ' + doc.name())
            })
        ));


    // ---------------------TINDERBOX----------------------

    // macroDictFromTBXxml ::
    // String -> Either String {key::String, macro::XML}
    const macroDictFromTBXxml = strMacrosXML => {
        // Either a message String, or
        // a dictionary of the macros found
        // in a Tinderbox (.tbx) XML document.
        const
            uw = ObjC.unwrap,
            eXML = $(),
            docXML = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                strMacrosXML, $.NSXMLNodePreserveAll, eXML
            );
        return bindLR(
            docXML.isNil() ? (
                Left(uw(eXML.localizedDescription))
            ) : Right(docXML)
        )(docXML => {
            const
                eXP = $(),
                xs = docXML
                .nodesForXPathError('//macro', eXP);
            return xs.isNil() ? (
                Left(uw(eXP.localizedDescription))
            ) : Right(
                uw(xs).reduce(
                    (a, x) => Object.assign(a, {
                        [uw(
                            x.attributeForName('name')
                            .valueForKey('stringValue')
                        )]: uw(x.XMLString)
                    }), {}
                )
            );
        })
    };

    // tbxFrontDocLR :: Tbx IO () -> Either String Tbx Doc
    const tbxFrontDocLR = () => {
        // Either the front document in Tinderbox 8, or an
        // explanatory message if no documents are open,
        // or Tinderbox 8 is not running.
        const tbx = Application('Tinderbox 8');
        return tbx.running() ? (() => {
            const ds = tbx.documents;
            return 0 < ds.length ? (
                Right(ds.at(0))
            ) : Left('No documents open in Tinderbox');
        })() : Left('Tinderbox 8 is not running.');
    };


    // ------------------------JXA-------------------------

    // alert :: String -> String -> IO String
    const alert = title => s => {
        const
            sa = Object.assign(Application('System Events'), {
                includeStandardAdditions: true
            });
        return (
            sa.activate(),
            sa.displayDialog(s, {
                withTitle: title,
                buttons: ['OK'],
                defaultButton: 'OK'
            }),
            s
        );
    };

    // setClipOfTextType :: String -> String -> IO Bool
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;
            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                )
            );
        };

    // showMenuLR :: Bool -> String -> [String] ->
    // Either String [String]
    const showMenuLR = blnMult => title => xs =>
        0 < xs.length ? (() => {
            const sa = Object.assign(
                Application('System Events'), {
                    includeStandardAdditions: true
                });
            sa.activate();
            const v = sa.chooseFromList(xs, {
                withTitle: title,
                withPrompt: 'Select' + (
                    blnMult ? ' one or more of ' +
                    xs.length.toString() +
                    ' to copy:' : ':'
                ),
                defaultItems: xs,
                okButtonName: 'Copy',
                cancelButtonName: 'Cancel',
                multipleSelectionsAllowed: blnMult,
                emptySelectionAllowed: false
            });
            return Array.isArray(v) ? (
                Right(v)
            ) : Left('User cancelled ' + title + ' menu.');
        })() : Left(title + ': No items to choose from.');


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

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

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

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

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

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

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

    // str :: a -> String
    const str = x =>
        // Stringification of a JS value.
        x.toString();

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

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

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

(zipped copy of the Keyboard Maestro macro now added above)

Awesome stuff!

What about storing the macros in the KM Macro itself or in repository outside TBX?
Like your Prelude JXA stuff?
Then you have all your macros in one place and don’t have to look around, in which TBX they are.
Or perhaps build some starter TBX file with everything in it and discard, what you don’t need…
Just some ideas.

Ok, the last one is boring, because no JXA is incorporated :wink:

1 Like

Indeed – good thoughts.

‘Incremental formalisation’ I guess … : - )

1 Like