Clipboard History Switcher - Insert separator between multiple items

When pasting from the clipboard with multiple items selected, how can I insert a separator (tab or paragraph mark, or space) between the items? When I paste now, all the items are run together.
Thanks.

1 Like

Good question – I can't immediately see the proper route to that either ...

In the meanwhile, for text clipboards at least, an interim script, which throws up a menu of the named clipboards, and allows you to specify a delimiter in a variable at the top of the macro.

Paste chosen (KM named) clipboards with delimiter.kmmacros (31.4 KB)

JS Source:

(() => {
    'use strict';

    // Rob Trew 2017-12-23

    // GENERIC FUNCTIONS -----------------------------------------------------

    // append (++) :: [a] -> [a] -> [a]
    const append = (xs, ys) => xs.concat(ys);

    // catMaybes :: [Maybe a] -> [a]
    const catMaybes = ls =>
        concatMap(m => m.nothing ? [] : m.just, ls);

    // concatMap :: (a -> [b]) -> [a] -> [b]
    const concatMap = (f, xs) =>
        xs.length > 0 ? [].concat.apply([], xs.map(f)) : [];

    // Handles two or more arguments
    // curry :: ((a, b) -> c) -> a -> b -> c
    const curry = (f, ...args) => {
        const go = xs => xs.length >= f.length ? (f.apply(null, xs)) :
            function () {
                return go(xs.concat(Array.from(arguments)));
            };
        return go([].slice.call(args));
    };

    // elemIndex :: Eq a => a -> [a] -> Maybe Int
    const elemIndex = (x, xs) => {
        const i = xs.indexOf(x);
        return {
            nothing: i === -1,
            just: i
        };
    };

    // enumFromTo :: Int -> Int -> [Int]
    const enumFromTo = (m, n) =>
        Array.from({
            length: Math.floor(n - m) + 1
        }, (_, i) => m + i);

    // fst :: (a, b) -> a
    const fst = pair => pair.length === 2 ? pair[0] : undefined;

    // headMay :: [a] -> Maybe a
    const headMay = xs =>
        xs.length > 0 ? just(xs[0]) : nothing();

    // justifyRight :: Int -> Char -> String -> String
    const justifyRight = (n, cFiller, strText) =>
        n > strText.length ? (
            (cFiller.repeat(n) + strText)
            .slice(-n)
        ) : strText;

    // just :: a -> Just a
    const just = x => ({
        nothing: false,
        just: x
    });

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

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) => xs.map(f);

    // show :: Int -> a -> Indented String
    // show :: a -> String
    const show = (...x) =>
        JSON.stringify.apply(
            null, x.length > 1 ? [x[1], null, x[0]] : x
        );

    // nothing :: () -> Nothing
    const nothing = (optionalMsg) => ({
        nothing: true,
        msg: optionalMsg
    });

    // readFile :: FilePath -> IO String
    const readFile = strPath => {
        var error = $(),
            str = ObjC.unwrap(
                $.NSString.stringWithContentsOfFileEncodingError(
                    $(strPath)
                    .stringByStandardizingPath,
                    $.NSUTF8StringEncoding,
                    error
                )
            );
        return typeof error.code !== 'string' ? (
            str
        ) : 'Could not read ' + strPath;
    };

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

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

    // takeExtension :: FilePath -> String
    const takeExtension = strPath => {
        const
            xs = strPath.split('.'),
            lng = xs.length;
        return lng > 1 ? (
            '.' + xs[lng - 1]
        ) : '';
    };

    // File name template -> temporary path
    // (Random digit sequence inserted between template base and extension)
    // tempFilePath :: String -> IO FilePath
    const tempFilePath = template =>
        ObjC.unwrap($.NSTemporaryDirectory()) +
        takeBaseName(template) + Math.random()
        .toString()
        .substring(3) + takeExtension(template);

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // unzip :: [(a,b)] -> ([a],[b])
    const unzip = xys =>
        xys.reduceRight(([xs, ys], [x, y]) => [
            [x].concat(xs), [y].concat(ys)
        ], [
            [],
            []
        ]);

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = (f, xs, ys) =>
        Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => f(xs[i], ys[i], i));


    // JXA --------------------------------------------------------------------

    // menuChoices :: (String a | Num a) => [a] -> [b] -> [a] -> String ->
    //                 String -> String -> String -> Bool -> Bool -> [b]
    const menuChoices = (
        lstNames, lstValues, lstDefault, strTitle, strPrompt,
        strOK, strEsc, blnManyOK, blnEmptyOK
    ) => {
        const intMenu = lstNames.length;
        return intMenu > 0 ? (() => {
            const
                se = Application('System Events'),
                sa = (se.includeStandardAdditions = true, se),
                procsFront = se.applicationProcesses.where({
                    frontmost: true
                }),
                mbFront = procsFront.length > 0 ? (
                    just(procsFront.at(0)
                        .bundleIdentifier())
                ) : nothing('No front application'),
                intChars = intMenu.toString()
                .length,
                paddedIndex = (n, s) =>
                justifyRight(intChars, '0',
                    n.toString()) + '\t' + s,
                lstMenu = zipWith(
                    paddedIndex, enumFromTo(1, intMenu), lstNames
                ),
                result = (
                    se.activate(),
                    se.chooseFromList(
                        lstMenu, {
                            withTitle: strTitle || '',
                            withPrompt: strPrompt || 'Choose:',
                            defaultItems: Array.isArray(lstDefault) ?
                                concatMap(
                                    k => {
                                        const mb = elemIndex(k, lstNames);
                                        return mb.nothing ? [] : (
                                            [lstMenu[mb.just]]
                                        );
                                    },
                                    lstDefault
                                )
                                .slice(
                                    0, blnManyOK ? undefined : 1
                                ) : blnEmptyOK ? [] : lstMenu[0],
                            okButtonName: strOK || 'OK',
                            cancelButtonName: strEsc || 'Cancel',
                            multipleSelectionsAllowed: blnManyOK === false ? (
                                false
                            ) : true,
                            emptySelectionAllowed: blnEmptyOK || false
                        }));

            if (!mbFront.nothing) {
                Application(mbFront.just)
                    .activate();
                delay(0.2);
            }
            return (
                typeof result !== 'boolean' ? (
                    result.map(
                        s => (lstValues || lstNames)[
                            parseInt(s.split('\t')[0], 10) - 1
                        ]
                    )
                ) : []
            );
        })() : [];
    };

    // KM VARIABLES ----------------------------------------------------------

    // Testing whether this code is executing inside a KM macro,
    // and if so, supporting instance-sensitive variable resolutions,
    // for example for Local or Instance variables.

    // kmInstanceMay :: -> Maybe KM Instance
    const kmInstanceMay = () => {
        const oInstance = ObjC.unwrap(
            $.NSProcessInfo.processInfo.environment
            .objectForKey('KMINSTANCE')
        );
        return Boolean(oInstance) ? (
            just(oInstance)
        ) : nothing('This code is not running in a KM Macro');
    };

    // kmValueMay :: Maybe KM Instance -> String -> Maybe String
    const kmValueMay = (mbInstance, k) => {
        const
            kme = Application("Keyboard Maestro Engine"),
            v = mbInstance.nothing ? (
                kme.getvariable(k)
            ) : kme.getvariable(k, {
                instance: mbInstance.just
            });
        return v === '' ? (
            nothing('Empty string returned for ' + k)
        ) : just(v);
    };

    // At least one KMVAR key -> Maybe value of first key to return one
    // kmValueOrAltMay :: [String] -> Maybe String
    const kmValueOrAltMay = ks =>
        headMay(catMaybes(
            map(curry(kmValueMay)(kmInstanceMay()), ks)
        ));

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

    // jsoDoScript :: Object (Dict | Array) -> IO ()
    const jsoDoScript = jso => {
        const strPath = tempFilePath('tmp.plist');
        return (
            Application('Keyboard Maestro Engine')
            .doScript((
                $(Array.isArray(jso) ? jso : [jso])
                .writeToFileAtomically(
                    $(strPath)
                    .stringByStandardizingPath,
                    true
                ),
                readFile(strPath)
            )),
            true
        );
    };

    // MAIN -------------------------------------------------------------------
    const
        mbDelim = kmValueOrAltMay(['InstanceDelim', 'testDelim']),
        strDelim = mbDelim.nothing ? '\n\n' : mbDelim.just,
        pairs = map(x => [x.Name, x.UID],
            ObjC.deepUnwrap($.NSArray.arrayWithContentsOfFile(
                $('~/Library/Application Support/' +
                    'Keyboard Maestro/Keyboard Maestro Clipboards.plist')
                .stringByStandardizingPath
            ))),
        choices = menuChoices(
            map(fst, pairs), pairs, undefined,
            'KM Named clipboards',
            '⌘-Click to select clipboards:'
        ),
        actions = concatMap(
            ([strName, uid]) => [{
                    "Delete": false,
                    //"Second": "100",
                    "First": "0",
                    "Destination": "Variable",
                    "MacroActionType": "Substring",
                    "NamedClipboardName": uid,
                    "RedundandDisplayName": strName,
                    "Source": "NamedClipboard",
                    "StringRangeType": "From",
                    "DestinationVariable": "InstanceClip"
                },
                {
                    "Variable": "InstanceAccum",
                    "MacroActionType": "SetVariableToText",
                    "Text": "%Variable%InstanceAccum%%Variable%InstanceClip%" +
                        strDelim
                }
            ],
            choices
        );
    return actions.length > 0 ? (
        jsoDoScript(append(actions, [{
                "MacroActionType": "SetClipboardToText",
                "Text": "%Variable%InstanceAccum%",
                "JustDisplay": false
            },
            {
                "Action": "Paste",
                "IsDisclosed": false,
                "MacroActionType": "CutCopyPaste",
                "TimeOutAbortsMacro": true
            }
        ])),
        'Pasted with delimiter: ' + strDelim + '\n' + unlines(
            map(fst, choices)
        )
    ) : 'No clipboards chosen';
})();

This is a clever solution. I had hoped for something simpler, but this should work for my purposes.
Thanks.

There is no solution to this because there is no way to specify what the separator might be. I can’t imagine any useful UI for that I’m afraid.

Thanks for the reply

Can't you add a popup (dropdown) menu to choose a separator?

Peter, perhaps as a minimum you could offer to separate each clipboard item with a LF (or maybe a CR). IMO, it is a rare use case to concatenate all items without any separator.

So, perhaps using an OPT-RETURN would paste the selected items with some separator.

While a simple LF would be better than nothing, you could offer the following choices for separator:

  • LF
  • CR
  • Rich text of HTML <HR> (with a LF before and after)
  • --- (with LF before/after. This would translate as a <HR> in Markdown)

Thanks for considering this suggestion.

1 Like