Paste Image Into Text File as a Markdown Link

Assuming the context of working with a text file and a Markdown previewer:

If there is an image in the clipboard, this macro:

  • Opens a Save As dialog:
    • pointing at the folder in which the currently edited text file is saved,
    • and defaulting to a date-stamped file name for the graphic.
  • saves the graphic content of the clipboard to the chosen file path,
  • and pastes a markdown link with a ![label](url) bang prefix at the current position in the text file.

The idea is that you can then view the text file, with inline images, in the Markdown previewer.

I personally work, most of the time, with the TaskPaper plain text outliner and with Brett Tersptra's Marked 2 previewer.

Paste image as MD link.kmmacros (30.4 KB)

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();
})();
2 Likes

Updated above to save public.tiff clipboards as .png files and MD links by default.

To save public.tiff clipboards to .tiff files and MD links, edit the option near the top of the script to:

const saveTiffAsPNG = false;

Ver 0.3

Fixed a regression (updated above) which had lost focus from the active text document,
preventing a paste of the image link.

1 Like

@ComplexPoint Would it be possible to get a version to this that just always puts the image in the same location in the file system and generates an MD link?

For quick screenshot -> markdown notes, I generally just put images in an _attachments folder at the root of my notes.

Everything else about this macro is perfection, though.