Copy as Markdown Link

Added a com.amazon.Kindle sub-macro to extend the Copy as MD Link macro to work with the macOS Kindle app.

(updated original macro group above)

  • Working here on Catalina 10.15.7, but will need some testing on other systems
  • Kindle.app doesn't yield its book titles, locations and ASIN codes easily, so this sub-macro cycles through the app views, and puts up a notification to ask for a second or two of patience : - )

Testing and glitch reports particularly welcome on this one – its approach is in the tradition of Rube Goldberg and Heath Robinson GUI scripting.

Does seem to be working here, but no guarantees ...

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

    ObjC.import('AppKit');

    // Copy as MD Link for macOS Kindle

    // Rob Trew @ 2020

    // Read available fields 
    //     (for md link :: Title + Author, Location, ASIN)
    // from active Kindle view <- {'page', 'icon', 'list'}
    // and continue to 'next' view,
    // until 'home' and 'dry'.

    // main :: IO ()
    const main = () => {
        const kme = Application('Keyboard Maestro Engine');
        return either(
            alert('Copy as MD Link')
        )(x => (
            kme.setvariable('mdLink', {
                to: x
            }),
            x
        ))(
            bindLR(
                kindleProcLR()
            )(proc => bindLR(
                until(
                    homeAndDryOrSunk
                )(
                    harvestNextViewLR(kme)(proc)
                )(
                    viewHarvestFromProcessLR(kme)(proc)({
                        viewPath: []
                    })
                )
            )(linkFromDictLR))
        );
    };


    // harvestNextViewLR :: Application -> a
    // Dict -> Either String Dict
    const harvestNextViewLR = kme =>
        proc => lrDict => {
            const
                dict = lrDict.Right,
                path = dict.viewPath,
                currentView = last(path);
            return (
                // In Kindle:
                !isComplete(dict) ? (
                    'list' !== currentView ? (
                        kme.doScript(
                            'page' !== currentView ? (
                                hitReturnPlist
                            ) : menuChainPlist([
                                'File',
                                'Close Book'
                            ])
                        )
                    ) : Boolean(dict.location) ? (
                        clickViewButton(proc)('list')
                    ) : kme.doScript(hitReturnPlist)
                ) : 'page' !== currentView ? (
                    'page' !== path[0] ? (
                        clickViewButton(proc)(currentView)
                    ) : kme.doScript(
                        menuChainPlist(['File', 'Open Book'])
                    )
                ) : kme.doScript(
                    menuChainPlist(['File', 'Close Book'])
                ),
                // In JavaScript interpreter:
                viewHarvestFromProcessLR(kme)(proc)(
                    dict
                )
            );
        };


    // linkFromDictLR :: Dict -> Either String String
    const linkFromDictLR = dict =>
        // Either a message or an MD Link string
        bindLR(
            Boolean(dict.name) ? (
                Right(dict.name)
            ) : Left('Name field not found')
        )(name => {
            const
                i = [...name].findIndex(
                    c => ',' === c
                );
            return -1 !== i ? (() => {
                const [title, rest] = splitAt(i)(name);
                const
                    authorList = Object.keys(
                        rest.slice(1).split(/; /g).reduce(
                            (a, k) => Object.assign({},
                                a, {
                                    [
                                        reverse(
                                            k.split(', ')
                                        ).join(' ')
                                    ]: 1
                                }), {}
                        )
                    );
                return Right(
                    `[${title} – ${authorList.join(', ')}]` + (
                        '(kindle://book?action=open&asin=' + (
                            `${dict.asin}&location=${dict.location})`
                        )
                    )
                );
            })() : Left('No comma found in name field.');
        });


    // isComplete :: Dict -> Bool
    const isComplete = dict => {
        // Complete if the dictionary 
        // holds all 3 key values <- {asin, location, name}
        const ks = Object.keys(dict);
        return ['name', 'asin', 'location'].every(
            k => ks.includes(k)
        );
    };


    // homeAndDryOrSunk :: Either String Dict -> Bool
    const homeAndDryOrSunk = lrDict =>
        // Either an explanatory message, or 
        // True if all the sought key values are 
        // present in the dictionary, and the path
        // shows a return to initial position.
        Boolean(lrDict.Left) || (() => {
            const
                dict = lrDict.Right,
                path = dict.viewPath;
            return 1 < path.length && (
                path[0] === last(path)
            ) && isComplete(dict);
        })();


    // viewHarvestFromProcessLR :: Application -> 
    // process -> Dict -> Either String Dict
    const viewHarvestFromProcessLR = kme =>
        // Either a message or a dictionary with
        // additional fields and an extended path
        // of visited views.
        process => history => bindLR(
            kindleProcWinLR(process)
        )(window => bindLR(
            kindleWindowToolbarLR(window)
        )(toolbar => bindLR(
            kindleToolbarControlsLR(toolbar)
        )(controls => bindLR(
            kindleWindowTypeLR(controls)
        )(winType => 'page' === winType ? (
            locationFromKindlePageLR(history)(window)
        ) : 'list' === winType ? (
            authorFromKindleListLR(history)(window)
        ) : 'icon' === winType ? (
            bindLR(
                titlePosnSizeFromKindleIconLR(window)
            )(
                asinEtcFromIconDetailsLR(kme)(history)
            )
        ) : Left('other')))));


    // asinEtcFromIconDetailsLR :: Application -> 
    // Dict -> (String, String, String) -> 
    // Either String Dict
    const asinEtcFromIconDetailsLR = kme =>
        // Either a message or a dictionary with an 
        // extended viewPath and updated or added 
        // `asin` and `name` fields.
        history => ([label, posn, size]) => {
            const
                x = posn[0] + size[0] / 2,
                y = posn[1] + size[1] / 2;
            return Right(
                (
                    copyText(''),
                    kme.doScript(
                        clickPointPlist(x)(y)
                    ),
                    kme.doScript(controlCplist),
                    Object.assign({}, history, {
                        viewPath: history.viewPath.concat('icon'),
                        name: label.split(', Reading')[0],
                        asin: either(
                            _ => 'Empty clipboard - asin not found.'
                        )(
                            x => x.split('-0-')[0]
                        )(clipTextLR())
                    })
                )
            );
        };


    // titlePosnSizeFromKindleIconLR :: Window -> 
    // Either String [String, (Int, Int), (Int, Int)]
    const titlePosnSizeFromKindleIconLR = window => {
        const lists = window.lists;
        return bindLR(
            0 < lists.length ? (
                Right(lists.at(0))
            ) : Left('No icon list found - perhaps not icon view ?')
        )(list => {
            const
                staticText = lists.at(0)
                .staticTexts.at(0);
            return Right([
                'title', 'position', 'size'
            ].map(k => staticText[k]()));
        });
    };


    // kindleProcLR :: () -> Either String Process
    const kindleProcLR = () => {
        // Either a message, or a reference
        // to a running Kindle process.
        const
            kindleProcs = Application('System Events')
            .applicationProcesses.where({
                bundleIdentifier: 'com.amazon.Kindle'
            });
        return 0 < kindleProcs.length ? (
            Right(kindleProcs.at(0))
        ) : Left('Kindle reader not found.');
    };


    // kindleProcWinLR :: Process -> Either String Window
    const kindleProcWinLR = process => {
        const wins = process.windows;
        return 0 < wins.length ? (
            Right(wins.at(0))
        ) : Left('Open window not found in Kindle.');
    };


    // kindleToolbarControlsLR :: Toolbar -> 
    // Either Message Buttons
    const kindleToolbarControlsLR = toolbar => {
        const controls = toolbar.uiElements;
        return 0 < controls.length ? (
            Right(controls)
        ) : Left('No UI elements found in toolbar.');
    };


    // kindleWindowToolbarLR :: Window -> Either String Toolbar
    const kindleWindowToolbarLR = window => {
        const toolbars = window.toolbars;
        return 0 < toolbars.length ? (
            Right(toolbars.at(0))
        ) : Left(
            'No toolbars found in Kindle window: ' + (
                window.name()
            )
        );
    };


    // kindleWindowTypeLR :: controls -> 
    // Either String String
    const kindleWindowTypeLR = controls => {
        const count = controls.length;
        return [3, 4, 8].includes(count) ? (
            Right(({
                3: 'list',
                4: 'icon',
                8: 'page'
            })[count])
        ) : Left(
            'Unrecognised pattern of controls on toolbar.'
        );
    };


    // authorFromKindleListLR :: Dict -> 
    // Window -> Either String Dict
    const authorFromKindleListLR = history =>
        window => {
            const tables = window.tables;
            return bindLR(
                0 < tables.length ? (
                    Right(tables.at(0))
                ) : Left('No table found in window - perhaps not list ?')
            )(
                table => {
                    const staticTexts = table.staticTexts;
                    return bindLR(
                        1 < staticTexts.length ? (
                            Right(staticTexts)
                        ) : Left('Less than 2 static texts found.')
                    )(
                        texts => Right(
                            Object.assign({}, history, {
                                viewPath: history.viewPath
                                    .concat('list'),
                                author: texts.at(1).title()
                            })
                        )
                    );
                }
            );
        };


    // --------------------- TOKENS ----------------------

    // locationFromKindlePageLR :: Dict -> 
    // Window -> Either String Dict
    const locationFromKindlePageLR = history =>
        window => {
            const
                staticTexts = window.staticTexts(),
                iLabel = staticTexts.findIndex(
                    x => 0 < x.uiElements.length
                );
            return -1 !== iLabel ? (() => {
                const
                    xs = staticTexts[iLabel]
                    .uiElements.at(0).value()
                    .split(/\s+/g),
                    lng = xs.length;
                return bindLR(
                    2 < lng ? (
                        Right(xs[lng - 3])
                    ) : Left(
                        'Location string not found.'
                    )
                )(label => isNaN(label) ? (
                    Left('Expected a location integer.')
                ) : Right(
                    Object.assign({}, history, {
                        viewPath: history.viewPath.concat('page'),
                        location: parseInt(label)
                    })
                ));
            })() : Left('Library page');
        };


    // --------------------- KINDLE ----------------------

    // kindlePageBookNameLR :: Either String String
    const kindlePageBookNameLR = () => {
        const
            se = Application('System Events'),
            kindleProcs = se.applicationProcesses.where({
                bundleIdentifier: 'com.amazon.Kindle'
            });
        return bindLR(
            0 < kindleProcs.length ? (
                Right(kindleProcs.at(0))
            ) : Left('Kindle reader not found.')
        )(kindleProc => {
            const ws = kindleProc.windows;
            return bindLR(
                0 < ws.length ? (
                    Right(ws.at(0))
                ) : Left('No windows open in Kindle')
            )(win => {
                const
                    toolbar = win.toolbars.at(0),
                    buttons = toolbar.buttons;
                return buttons.length !== 4 ? (
                    Left('This is not a reading page')
                ) : Right(win.title().split(' - ').slice(1)[0]);
            });
        });
    };


    // kindleButtonTypeLR :: () -> IO Dict
    const kindleButtonTypeLR = () => {
        // Either a message, or a dictionary with 
        // a single key drawn from {'icon', 'list', 'library'}
        // the value of the key is a button which can be 
        // clicked with the method:
        // (dct[k]).actions.at(0).perform()
        const
            se = Application('System Events'),
            kindleProcs = se.applicationProcesses.where({
                bundleIdentifier: 'com.amazon.Kindle'
            });
        return bindLR(
            0 < kindleProcs.length ? (
                Right(kindleProcs.at(0))
            ) : Left('Kindle reader not found.')
        )(kindleProc => {
            const ws = kindleProc.windows;
            return bindLR(
                0 < ws.length ? (
                    Right(ws.at(0))
                ) : Left('No windows open in Kindle')
            )(win => {
                const
                    toolbar = win.toolbars.at(0),
                    buttons = toolbar.buttons;
                return Right(
                    0 < buttons.length ? ({
                        library: buttons.at(0)
                    }) : (() => {
                        const
                            groups = toolbar.groups,
                            blnIconView = 3 < groups.length,
                            group = groups.at(
                                blnIconView ? (
                                    2
                                ) : 1
                            );
                        return {
                            [blnIconView ? 'list' : 'icons']: (
                                group.radioGroups.at(0)
                                .radioButtons.at(
                                    blnIconView ? (
                                        1
                                    ) : 0
                                )
                            )
                        };
                    })()
                );
            });
        });
    };


    // clickViewButton :: Process -> String -> Kindle IO
    const clickViewButton = proc =>
        // A button clicked
        // Either the List view or Icons view button,
        // to toggle from the current view to its sibling. 
        viewName => {
            const
                toolbar = proc.windows.at(0)
                .toolbars.at(0),
                blnIconView = 'list' !== viewName,
                group = toolbar.groups.at(
                    blnIconView ? 2 : 1
                ),
                button = group.radioGroups.at(0)
                .radioButtons.at(
                    blnIconView ? 1 : 0
                );
            return button.actions.at(0).perform();
        };


    // kindleLocationFromPageLR :: 
    const kindleLocationFromPageLR = () => {
        const
            se = Application('System Events'),
            kindleProcs = se.applicationProcesses.where({
                bundleIdentifier: 'com.amazon.Kindle'
            });
        return bindLR(
            0 < kindleProcs.length ? (
                Right(kindleProcs.at(0))
            ) : Left('Kindle reader not found.')
        )(kindleProc => {
            const ws = kindleProc.windows;
            return bindLR(
                0 < ws.length ? (
                    Right(ws.at(0))
                ) : Left('No windows open in Kindle')
            )(win => {
                const
                    staticTexts = win.staticTexts(),
                    iLabel = staticTexts.findIndex(
                        x => 0 < x.uiElements.length
                    );
                return -1 !== iLabel ? (() => {
                    const
                        xs = staticTexts[iLabel]
                        .uiElements.at(0).value()
                        .split(/\s+/g),
                        lng = xs.length;
                    return bindLR(
                        2 < lng ? (
                            Right(xs[lng - 3])
                        ) : Left(
                            'Location string not found.'
                        )
                    )(label => isNaN(label) ? (
                        Left('Expected a location integer.')
                    ) : Right(parseInt(label)));
                })() : Left('Library page');
            });
        });
    };


    // kindleTypeAndFieldLR :: () -> Either String (String, String)
    const kindleTypeAndFieldLR = () =>
        // Either a message or a key value pair in which
        // the key is drawn from {'name', 'asin', 'location'}
        either(
            _ => bindLR(
                kindleLocationFromPageLR()
            )(
                label => Right(['location', label])
            )
        )(
            txt => 0 < txt.length ? (
                txt.endsWith('EBOK') ? (
                    Right(['asin', txt.split('-')[0]])
                ) : Right(['name', txt])
            ) : Left('No text copied in Library view.')
        )(
            clipTextLR()
        );


    // ------------------- KM ACTIONS --------------------

    // activateKindlePlist :: XML String
    const activateKindlePlist = `<?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <array>
        <dict>
            <key>AllWindows</key>
            <true/>
            <key>AlreadyActivatedActionType</key>
            <string>Normal</string>
            <key>Application</key>
            <dict>
                <key>BundleIdentifier</key>
                <string>com.amazon.Kindle</string>
                <key>Name</key>
                <string>Kindle</string>
                <key>NewFile</key>
                <string>/Applications/Kindle.app</string>
            </dict>
            <key>MacroActionType</key>
            <string>ActivateApplication</string>
            <key>ReopenWindows</key>
            <false/>
            <key>TimeOutAbortsMacro</key>
            <true/>
        </dict>
    </array>
    </plist>`;


    // clickPointPlist :: Int -> Int -> XML String
    const clickPointPlist = x =>
        y => `<?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <array>
        <dict>
            <key>Action</key>
            <string>MoveAndClick</string>
            <key>Button</key>
            <integer>0</integer>
            <key>ClickCount</key>
            <integer>1</integer>
            <key>DisplayMatches</key>
            <false/>
            <key>DragHorizontalPosition</key>
            <string>0</string>
            <key>DragVerticalPosition</key>
            <string>0</string>
            <key>Fuzz</key>
            <integer>15</integer>
            <key>HorizontalPositionExpression</key>
            <string>${x}</string>
            <key>MacroActionType</key>
            <string>MouseMoveAndClick</string>
            <key>Modifiers</key>
            <integer>0</integer>
            <key>MouseDrag</key>
            <string>None</string>
            <key>Relative</key>
            <string>Window</string>
            <key>RelativeCorner</key>
            <string>TopLeft</string>
            <key>RestoreMouseLocation</key>
            <false/>
            <key>VerticalPositionExpression</key>
            <string>${y}</string>
        </dict>
    </array>
    </plist>`;


    // menuChainPlist :: [String] -> XML String
    const menuChainPlist = ks => `<?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <array>
        <dict>
            <key>MacroActionType</key>
            <string>SelectMenuItem</string>
            <key>Menu</key>
            <array>
            ${ks.map(k => ['<string>',k,'</string>'].join('')).join('\n')}
            </array>
            <key>TargetApplication</key>
            <dict>
                <key>BundleIdentifier</key>
                <string>com.amazon.Kindle</string>
                <key>Name</key>
                <string>Kindle</string>
                <key>NewFile</key>
                <string>/Applications/Kindle.app</string>
            </dict>
            <key>TargetingType</key>
            <string>Specific</string>
        </dict>
    </array>
    </plist>`;


    // controlCplist :: XML String
    const controlCplist = `<?xml version="1.0" encoding="UTF-8"?>
        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
        <plist version="1.0">
        <array>
            <dict>
                <key>KeyCode</key>
                <integer>8</integer>
                <key>MacroActionType</key>
                <string>SimulateKeystroke</string>
                <key>Modifiers</key>
                <integer>256</integer>
                <key>ReleaseAll</key>
                <false/>
                <key>TargetApplication</key>
                <dict/>
                <key>TargetingType</key>
                <string>Front</string>
            </dict>
        </array>
        </plist>`;


    // hitReturnPlist :: XML String
    const hitReturnPlist = `<?xml version="1.0" encoding="UTF-8"?>
        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
        <plist version="1.0">
        <array>
            <dict>
                <key>KeyCode</key>
                <integer>36</integer>
                <key>MacroActionType</key>
                <string>SimulateKeystroke</string>
                <key>Modifiers</key>
                <integer>0</integer>
                <key>ReleaseAll</key>
                <false/>
                <key>TargetApplication</key>
                <dict/>
                <key>TargetingType</key>
                <string>Front</string>
            </dict>
        </array>
        </plist>`;


    // pausePlist :: XML String
    const pausePlist = `
        <?xml version="1.0" encoding="UTF-8"?>
        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
        <plist version="1.0">
        <array>
            <dict>
                <key>MacroActionType</key>
                <string>Pause</string>
                <key>Time</key>
                <string>0.2</string>
                <key>TimeOutAbortsMacro</key>
                <true/>
            </dict>
        </array>
        </plist>`;


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


    // clipTextLR :: () -> Either String String
    const clipTextLR = () => (
        v => Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left('No utf8-plain-text found in clipboard.')
    )(
        ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString))
    );


    // copyText :: String -> IO String
    const copyText = s => {
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };


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

    // 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 =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : undefined;


    // reverse :: [a] -> [a]
    const reverse = xs =>
        'string' !== typeof xs ? (
            xs.slice(0).reverse()
        ) : xs.split('').reverse().join('');


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

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

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

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