Copy as Markdown Link

A single keystroke (I happen to use ⌘⌥M) to copy selected items as Markdown links from a variety of browsers and other applications.

This is a working draft of a group containing a main macro and a number of supporting sub-macros:

  1. A main macro defining a single keystroke for use in a variety of different applications: Copy as Markdown link
  2. various sub-macros (not stand-alone - they are called by the main one) with the bundle identifier names of specific apps.

The main macro:

  • can get MD links, unassisted, from some browsers and from applications in which the front document is simply a file
  • can also, for use with other applications, look up the Bundle identifier (e.g. com.apple.finder) and use any supporting sub-macro in the group which has that name.

If, for example, you need to Copy as MD Link in Panic's Transmit.app, the main macro will:

  • call the com.panic.transmit.mas sub-macro if you are running the App Store build of Transmit
  • call the com.panic.Transmit sub-macro is you are running a copy bought directly from Panic.

On the range of applications supported:

  • If the main macro doesn't immediately work with an application that you need, you may be able to ask me to see if I can write an additional app-specific sub-macro, or write one yourself.

Two requirements:

  • The name of the sub-macro should exactly match that of the application's bundle id
  • The sub-macro should just place one or more markdown links for the selected item(s) in a KM variable named mdLink

And one installation step:

  • Once you have added a new sub-macro to the MD Link tools group, you need to register it by running, just once, a special macro which is included in that group, called: Update map from bundleIDs to KM UUIDs (after new sub-macro added)

If there are any sub-macros here named after the bundle-ids of applications which you never use, you can simply disable or delete them.

The current version of this macro can be downloaded from a Github repository:
RobTrew/copy-as-md-link

Screenshot 2020-12-31 at 07.48.41

Full current list of sub-macros (extending the main macro to cover apps other than browsers, and app windows other than simple document windows):

  • app.soulver.mac
  • com.agiletortoise.Drafts-OSX
  • com.amazon.Kindle
  • com.apple.AddressBook
  • com.apple.finder
  • com.apple.iCal
  • com.apple.mail
  • com.culturedcode.ThingsMac
  • com.devon-technologies.think3
  • com.flexibits.fantastical2.mac
  • com.happenapps.Quiver
  • com.houdah.HoudahSpot-setapp
  • com.houdah.HoudahSpot4
  • com.literatureandlatte.scrivener3
  • com.lukilabs.lukiapp
  • com.multimarkdown.nvUltra
  • com.OakTree.Accordance
  • com.omnigroup.OmniFocus3
  • com.omnigroup.OmniFocus3.MacAppStore
  • com.omnigroup.OmniPlan3
  • com.omnigroup.OmniPlan4
  • com.panic.Nova
  • com.panic.Transmit
  • com.panic.transmit.mas
  • com.reederapp.5.macOS
  • com.reederapp.macOS
  • com.sonnysoftware.bookends
  • com.sonnysoftware.bookends2
  • com.soulmen.ulysses-setapp
  • com.spotify.client
  • com.stairways.keyboardmaestro.editor
  • com.toketaware.ithoughtsx
  • com.ulyssesapp.mac
  • de.zettelkasten.TheArchive
  • md.obsidian
  • net.shinyfrog.bear
  • org.mozilla.firefox
  • QReader.MarginStudyMac
23 Likes

Added [iThoughtsX (Mac) — toketaWare](https://www.toketaware.com/ithoughts-osx) to the generic Copy as MD Link above.

3 Likes

Also added OmniFocus3 (above).

3 Likes

and the Keyboard Maestro Editor itself (above).

3 Likes

Added Copy as MD Link for OmniPlan tasks.

(Defaults back to Copy as MD Link to OmniPlan document in the absence of a selection).

Tested with OmniPlan 4, and should, I think, also work with OmniPlan 3.

(updated in original post above)

2 Likes

Added a com.houdah.HoudahSpot4 sub-macro to allow Copy as MD Link to work with HoudahSpot – Search Tool for Mac.

(updated original macro group above)

3 Likes

Added a com.agiletortoise.Drafts-OSX sub-macro to extend Copy as MD Link to work with [Drafts](https://getdrafts.com/)

(updated original macro group above)

3 Likes

Great work. Maybe you could build one for Obsidian MD Editor?

1 Like

Not an app that I run myself, or have here, but if its feasible to:

  • write a macro which returns an md link in a variable called mdLink
  • and give that macro the bundle id name of the Obsidian app ( md.obsidian, I think ? )

then it should work if you just add it to the MD Link tools group.

1 Like

Added an md.obsidian sub-macro to extend the Copy as MD Link macro to work with:

[ The Obsidian 0.10.1 Beta ]( https://obsidian.md/ )

(updated original macro group above)

The Obsidian client seems to be a beta of a cross-platform Electron app, and slightly parsimonious in the data it exposes to macOS scripting :slight_smile:

This first draft ( which would need testing – I'm not making active use of Obsidian myself ):

  1. Reads the vault name from the front window,
  2. and gets the file name and vault id from a couple of the JSON files which Obsidian maintains.

A limitation which is, as far as I can see, built into the Obsidian scheme, is that if two different vaults (at different paths on the system) share a name, and are both open at the same time, it is not possible for a script to know which is the foregrounded vault.

(The script here just throws up a message if this arises, so that one of the two vaults can be closed, allowing the user to try Copy as MD Link again, in an unambiguous context).

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

    // MD link to file selected in Obsidian v. 0.10.1
    
    // Rob Trew @2020
    // Ver 0.1

    // main :: IO ()
    const main = () => {
        const
            fpFolder = '~/Library/Application Support/Obsidian',
            fileName = 'obsidian.json',
            fpFullPath = combine(fpFolder)(fileName);

        const
            kme = Application('Keyboard Maestro Engine'),
            windowTitle = (
                activateNamedApp(kme)('Obsidian'),
                frontWindowName(kme)('windowName')
            );

        return either(
            alert('Copy as MD Link')
        )(
            mdLink => kme.setvariable('mdLink', {to: mdLink})
        )(
            bindLR(
                windowTitle.includes(' - ') ? (
                    Right(windowTitle.split(' - ')[0])
                ) : Left('Obsidian window title not found')
            )(vaultName => bindLR(
                doesDirectoryExist(fpFolder) ? (
                    Right(fpFullPath)
                ) : Left(`Folder not found :: ${fpFolder}`)
            )(fp => bindLR(
                doesFileExist(fp) ? (
                    readFileLR(fp)
                ) : Left(`File not found :: ${fp}`)
            )(txt => bindLR(
                jsonParseLR(txt)
            )(dct => bindLR(
                Boolean(dct.vaults) ? (() => {
                    const
                        vaults = dct.vaults,
                        ks = Object.keys(vaults),
                        matchingVaults = ks.filter(
                            k => {
                                const vault = vaults[k];
                                return Boolean(vault.open) && (
                                    vault.path.endsWith(vaultName)
                                );
                            }
                        ),
                        intMatches = matchingVaults.length;
                    return 0 < intMatches ? (
                        1 < intMatches ? (
                            Left(
                                'Ambiguous: two open vaults ' & (
                                    `named ${vaultName}.`
                                )
                            )
                        ) : Right(
                            Tuple(ks[0])(
                                vaults[ks[0]].path
                            )
                        )
                    ) : Left(
                        `No vault matches window name ${vaultName}`
                    );
                })() : Left(`No vaults listed in ${fp}`)
            )(tpl => {
                const
                    dataFolder = combine(tpl[1])(
                        '.obsidian'
                    );
                return bindLR(
                    doesDirectoryExist(dataFolder) ? (
                        Right([tpl[0], dataFolder])
                    ) : Left(
                        `Folder not found :: ${dataFolder}`
                    )
                )(([vaultID, fpObsidian]) => bindLR(
                    readFileLR(
                        combine(fpObsidian)('workspace')
                    )
                )(txt => bindLR(
                    jsonParseLR(txt)
                )(dct => {
                    const mru = dct.lastOpenFiles;
                    return 0 < mru.length ? (
                        Right(
                            `[${mru[0]}](` + (
                                `obsidian://open?vault=${vaultID}&` + (
                                    `file=${encodeURIComponent(mru[0])})`
                                )
                            )
                        )
                    ) : Left('Last opened file not found.');
                })));
            })))))
        );
    };

    // ---------------- KEYBOARD MAESTRO -----------------

    // frontWindowName :: Application -> String -> String
    const frontWindowName = kme =>
        kmVarName => (
            kme.doScript(
                `<?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>SetVariableToText</string>
                <key>Text</key>
                <string>%FrontWindowName%</string>
                <key>Variable</key>
                <string>${kmVarName}</string>
            </dict>
        </array>
        </plist>`
            ),
            kme.getvariable(kmVarName)
        );

    // activateNamedApp :: Application -> String -> IO ()
    const activateNamedApp = kme =>
        appName => kme.doScript(
            `<?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>md.obsidian</string>
                    <key>Name</key>
                    <string>${appName}</string>
                    <key>NewFile</key>
                    <string>/Applications/Obsidian.app</string>
                </dict>
                <key>MacroActionType</key>
                <string>ActivateApplication</string>
                <key>ReopenWindows</key>
                <false/>
                <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',
                    withIcon: sa.pathToResource('Obsidian.icns', {
                        inBundle: 'Applications/Obsidian.app'
                    })
                }),
                s
            );
        };

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

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

    // readFileLR :: FilePath -> Either String IO String
    const readFileLR = fp => {
        // Either a message or the contents of any
        // text file at the given filepath.
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );
        return ns.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.unwrap(ns));
    };

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


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


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


    // combine (</>) :: FilePath -> FilePath -> FilePath
    const combine = fp =>
        // Two paths combined with a path separator. 
        // Just the second path if that starts 
        // with a path separator.
        fp1 => Boolean(fp) && Boolean(fp1) ? (
            '/' === fp1.slice(0, 1) ? (
                fp1
            ) : '/' === fp.slice(-1) ? (
                fp + fp1
            ) : fp + '/' + fp1
        ) : fp + fp1;


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


    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        // Either a message, or a JS value obtained
        // from a successful parse of s.
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(
                `${e.message} (line:${e.line} col:${e.column})`
            );
        }
    };

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

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

Added:

  • com.ulyssesapp.mac
  • com.soulmen.ulysses-setapp

sub-macros to extend the Copy as MD Link macro to work with Mac AppStore and Setapp versions of:

[ Ulysses ]( https://ulysses.app/ )

(updated original macro group above)

1 Like

Added a

com.literatureandlatte.scrivener3

sub-macro to extend the Copy as MD Link macro to work with:

[Scrivener | Literature & Latte](https://www.literatureandlatte.com/scrivener/overview)

(updated original macro group above)

1 Like

Thanks for sharing and updating a great tool.
Is there a way we can just download the new added macro for a specific app rather than re-downloading everything?

1 Like

The supporting sub-macros are not stand-alone – the group forms a system, and apart from additions, individual components may also be updated at times.

(The size of the group is only about the same as that of the image)

I'll aim to maintain a parallel Github repository, in case anyone prefers to watch it or download it from there:
[ copy-as-md-link ]( https://github.com/RobTrew/copy-as-md-link )

but I don't plan to manually disaggregate the files. Possibly one could do that with a Makefile, but the incentives are not enormously compelling : -)

(and I don't immediately see an export method in the KM scripting API, though I may well have missed something)

1 Like

Added two more sub-macros:

  • app.soulver.mac
  • com.multimarkdown.nvUltra

to extend the Copy as MD Link macro to work with:

(updated original macro group above, and a copy is also kept on Github)

[RobTrew/copy-as-md-link](https://github.com/RobTrew/copy-as-md-link)

2 Likes

Added a sub-macro for:

  • com.panic.Nova

to extend the Copy as MD Link macro to work with:

(updated original macro group above, and a copy is also kept on Github)

[RobTrew/copy-as-md-link](https://github.com/RobTrew/copy-as-md-link )

1 Like

This is unbelievable Rob. Thank you very very much! I was thinking about this for the last couple of days and was only able to find a chrome extension which ONLY worked for copying chrome links in markdown.
This is Mega!

Is it maybe possible that the option for the page attribute in Devonthink 3 would also add the page number to the end of the title

" // ( including any ?page=nn attribute in a content record)"

So for: x-devonthink-item://7EC0718F-F4CE-415D-9BD7-A43EAE8A806A?page=9

instead of:
DEVONthink 3 - Take Control Of
DEVONthink 3 - Take Control Of/9

2 Likes

Ah, understood ... adding the page number to the [ label ] as well as the ( URL ) for DEVONthink ?

Should be no problem – I'll take a look at that tomorrow.

UPDATE

I've added page number and total page count to DEVONthink links, where the selection was in an DEVONThink 'content record' window, e.g. as

p173 of 592

in

[AppleScript_The_Definitive_Guide_Second_Edition.pdf p173 of 592](x-devonthink-item://C53E561C-9A79-4A36-97EF-896920982429?page=173)

If you are happy to make an edit in the JS source code of the:

com.devon-technologies.think3

sub-macro, you can customise the display format to something which suits you better.

The link title is defined on lines 48-59 of the JS:

const
    pageCount = record.pageCount(),
    page = window.currentPage(),
    hasPage = -1 !== page,
    pageString = hasPage ? (
        `?page=${page}`
    ) : '',
    title = record.name() + (
        hasPage ? (
            ` p${page} of ${pageCount}`
        ) : ''
    );

and if you want, you can experiment with adjusting the details of the string between backticks there:

` p${page} of ${pageCount}`
1 Like

Thank you very very much! Worked like a charm!

I am using Roam Research
When I use it on chrome itself the macro works successfully.
However, when I use it as a Chrome app then I get error that states consider adding a macro named.... (as in the screenshot)
I wanted to add this by copying the macro for chrome, as I assumed there would be one (also hoping that no changes would be necessary in terms of code :smiley: )
But there is none for chrome

Text of error:
Window 'null' of:

com.google.Chrome.app.gmhpgfbjacdocjngbdbmhkgcpochhnge

may not be a document window.

Consider adding a macro named 'com.google.Chrome.app.gmhpgfbjacdocjngbdbmhkgcpochhnge'
to the KM Group 'MD link tools'.

(Or request such a macro, which should
save a label string) in the
KM variable "mdLink")
on the Keyboard Maestro forum).