Pasting an image into TaskPaper as a Markdown link

A Keyboard Maestro macro:

It uses Keyboard Maestro for the keyboard binding and the paste action, but there would be other ways of using it.

The macro saves any image in the clipboard to a file in the folder of the TaskPaper document that you are editing,

and then pastes a Markdown image link (to the saved file), in TaskPaper.

The idea is to be able to preview TaskPaper text files with inline images in things like Brett Terpstra’s Marked 2.

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

    // Paste any image in clipboard to a file in 
    // the same folder as active (text) document.

    // Where successful, return an MD image file link 
    // of the form ![name](path) for pasting.

    // Rob Trew @
    // Ver 0.03

    ObjC.import('AppKit');

    // --------------------- OPTIONS ---------------------

    // .tiff files can be rather large, so the default 
    // here is to save smaller PNG files.
    // If you are typically saving *vector* images you may  
    // prefer to set `saveTiffAsPNG = false`
    // and preserve full vector scalability, at the cost
    // of significantly larger files.

    // saveTiffAsPNG :: Bool
    const saveTiffAsPNG = true;

    // preferredTypes :: [String]
    const preferredTypes = ['.pdf', '.png', '.tiff'];

    // ---------------------- MAIN -----------------------
    // main :: ()
    const main = () => either(
        alert('Page image to MD link in text')
    )(
        ([mdLink, appID]) => (
            Application(appID).activate(),
            delay(0.5),
            mdLink
        )
    )(
        bindLR(
            filePathAndAppIDFromFrontWindowLR()
        )(([fpDocFile, bundleID]) => bindLR(
            chosenTypeInClipboardLR(preferredTypes)
        )(uti => bindLR(
            confirmSavePathLR(
                defaultPath(saveTiffAsPNG)(fpDocFile)(uti)
            )
        )(fpImage => bindLR(
            ((uti === 'public.tiff' && saveTiffAsPNG) ? (
                tiffClipboardWrittenToFileAsPNGLR
            ) : clipboardWrittenToFileLR(uti))(fpImage)
        )(
            fpChecked => Right([
                mdImageLinkForFilePath(fpChecked),
                bundleID
            ])
        ))))
    );

    // defaultPath :: Bool -> FilePath -> UTI -> IO FilePath
    const defaultPath = saveTiffAsPNG =>
        fpDocFile => uti => combine(
            takeDirectory(fpDocFile)
        )(
            'copied' + iso8601Now() + (
                'public.tiff' !== uti ? (
                    takeExtension(uti)
                ) : saveTiffAsPNG ? (
                    '.png'
                ) : '.tiff'
            )
        )
        .split(':')
        .join('-');


    // mdImageLinkForFilePath :: FilePath -> String
    const mdImageLinkForFilePath = fp =>
        `![${takeFileName(fp)}](file://${encodeURI(fp)})`;


    // -------------------- CLIPBOARD --------------------

    // chosenTypeInClipboardLR :: [String] -> Either String UTI
    const chosenTypeInClipboardLR = preferredExtensions => {
        const
            matches = typesInClipboard().filter(
                uti => preferredExtensions.includes(
                    takeExtension(uti)
                )
            );
        return 0 < matches.length ? (
            Right(matches[0])
        ) : Left(
            'No clipboard content with type drawn from:\n\t{' + (
                preferredExtensions.join(', ') + (
                    '}\n\nFound only:\n\t' + (
                        typesInClipboard().join('\n\t')
                    )
                )
            )
        );
    };

    // typesInClipboard :: () -> IO [UTI]
    const typesInClipboard = () =>
        ObjC.deepUnwrap(
            $.NSPasteboard.generalPasteboard
            .pasteboardItems.js[0].types
        );

    // clipboardWrittenToFileLR :: UTI -> FilePath -> 
    // Either IO String IO FilePath
    const clipboardWrittenToFileLR = uti =>
        fp => (
            $.NSPasteboard.generalPasteboard
            .pasteboardItems.js[0]
            .dataForType(uti)
            .writeToFileAtomically(fp, true),
            doesFileExist(fp) ? (
                Right(fp)
            ) : Left(
                "${uti} clipboard not be written to:" + (
                    `\t\t${fp}`
                )
            )
        );

    // tiffClipboardWrittenToFileAsPNGLR :: UTI -> FilePath -> 
    // Either IO String IO FilePath
    const tiffClipboardWrittenToFileAsPNGLR = fp =>
        typesInClipboard().includes('public.tiff') ? (

            // In the pasteboard and file system,
            $.NSBitmapImageRep.imageRepWithData(
                $.NSPasteboard.generalPasteboard
                .pasteboardItems.js[0]
                .dataForType('public.tiff')
            )
            .representationUsingTypeProperties(
                $.NSPNGFileType, $()
            )
            .writeToFileAtomically(fp, true),

            // and thence back to the JS interpreter.
            doesFileExist(fp) ? (
                Right(fp)
            ) : Left(
                "${uti} clipboard not be written to:" + (
                    `\t\t${fp}`
                )
            )
        ) : Left('No public.tiff content found in clipboard');

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


    // confirmSavePathLR :: FilePath -> Either Message FilePath
    const confirmSavePathLR = fp => (
        ([fldr, fname], sa) => {
            sa.activate();
            try {
                return Right(
                    sa.chooseFileName({
                        withPrompt: 'Save As:',
                        defaultName: fname,
                        defaultLocation: Path(ObjC.unwrap(
                            $(doesDirectoryExist(fldr) ? (
                                fldr
                            ) : '~')
                            .stringByExpandingTildeInPath
                        ))
                    })
                    .toString()
                );
            } catch (e) {
                return Left(e.message);
            }
        })(
        Array.from(splitFileName(fp)),
        Object.assign(Application('System Events'), {
            includeStandardAdditions: true
        })
    );


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

    // filePathAndAppIDFromFrontWindowLR  :: () -> 
    // Either String (FilePath, String)
    const filePathAndAppIDFromFrontWindowLR = () => {
        // ObjC.import ('AppKit')
        const
            appName = ObjC.unwrap(
                $.NSWorkspace.sharedWorkspace
                .frontmostApplication.localizedName
            ),
            appProcess = Application('System Events')
            .applicationProcesses.byName(appName),
            ws = appProcess.windows;
        return bindLR(
            0 < ws.length ? Right(
                ws.at(0).attributes.byName('AXDocument').value()
            ) : Left(`No document windows open in ${appName}.`)
        )(
            docURL => null !== docURL ? (
                Right([
                    decodeURIComponent(docURL.slice(7)),
                    appProcess.bundleIdentifier()
                ])
            ) : Left(`No saved document active in ${appName}.`)
        );
    };


    // iso8601Now :: () -> IO String
    const iso8601Now = () =>
        iso8601Local(new Date())
        .split('.')
        .join('')
        .slice(0, -1);


    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();




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


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


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        // A function defined by the right-to-left
        // composition of all the functions in fs.
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


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


    // splitFileName :: FilePath -> (String, String)
    const splitFileName = strPath =>
        // Tuple of directory and file name, derived from file path.
        // Inverse of combine.
        ('' !== strPath) ? (
            ('/' !== strPath[strPath.length - 1]) ? (() => {
                const
                    xs = strPath.split('/'),
                    stem = xs.slice(0, -1);
                return stem.length > 0 ? (
                    Tuple(stem.join('/') + '/')(xs.slice(-1)[0])
                ) : Tuple('./')(xs.slice(-1)[0]);
            })() : Tuple(strPath)('')
        ) : Tuple('./')('');


    // takeDirectory :: FilePath -> FilePath
    const takeDirectory = fp =>
        '' !== fp ? (
            (xs => xs.length > 0 ? xs.join('/') : '.')(
                fp.split('/').slice(0, -1)
            )
        ) : '.';


    // takeExtension :: FilePath -> String
    const takeExtension = fp => (
        fs => {
            const fn = last(fs);
            return fn.includes('.') ? (
                '.' + last(fn.split('.'))
            ) : '';
        }
    )(fp.split('/'));


    // takeFileName :: FilePath -> FilePath
    const takeFileName = fp =>
        '' !== fp ? (
            '/' !== fp[fp.length - 1] ? (
                fp.split('/').slice(-1)[0]
            ) : ''
        ) : '';

    return main();
})();

UPDATE 2021-02-05

Ver 0.3 (updated in the source code above, and in the macro on the Keyboard Maestro site) now saves TIFF clipboards as PNG files, which are often much smaller.

If you typically want to retain any vector scalability, and are happy with large file sizes, you can override this default at the top of the script by editing one line from:

const saveTiffAsPNG = true;

to

const saveTiffAsPNG = false;
1 Like

Added to wiki (Under " Uncategorized Tidbits")

1 Like