Copy as Markdown Link

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).

You are talking about a client application (perhaps an Electron app) supplied by Roam ?

That would need a sub-macro of its own.

Not something that I'm using myself, but If you want to point me to it, I might be able to take a look later in the week, or at the weekend.

( Electron apps can tend to be a bit opaque to macOS scripting )

Keyboard Maestro Forum has the exact same option to create a Chrome app.

Chrome App as in:

[Google is finally killing off Chrome apps](https://www.theverge.com/2020/1/15/21067907/google-chrome-apps-end-support-lune-windows-macos-linux)

?

Looks a long shot, I'm afraid. This approach needs macOS apps with predictable bundleIDs, and that technology seems to rely on bundleID scrambling ...

Never mind. It is not that important. I will default to using the browser itself more.

Good to know.

Could you maybe add one for Notion?

Just a Note, maybe it will be of some use:
Cmd + L gets the link for the page but gets the link in form of "https" if you change https with "notion" it becomes a link for notion, as mentioned in the blog post:

Not something that I use, personally, but there again – its another Electron app, so a bit beyond the fringes of macOS scriptability.

1 Like