Transposing rows ⇄ columns in the clipboard (JS ES5 vs ES6 vs AS)

If you copy some tab-delimited lines, for example from Excel, and want to use a script action in KM to transpose rows ⇄ columns in the clipboard

alpha	beta	gamma
1	4	7
2	5	8
3	6	9

alpha	1	2	3
beta	4	5	6
gamma	7	8	9

Then you could write the script in ES5 JS (from Yosemite to Sierra and onwards)
or in ES6 JS (from Sierra onwards)
on in AS (from some time ago).

Here are drafts of what each version might look like:

JS ES5

(function () {
    'use strict';

    // transpose :: [[a]] -> [[a]]
    function transpose(rows) {
        return rows[0].map(function (_, iCol) {
            return rows.map(function (row) {
                return row[iCol];
            });
        });
    }

    // stringMatrix :: String -> Maybe Regex -> Maybe Regex -> [[String]]
    function stringMatrix(s, rgxRow, rgxCol) {
        return s
            .split(rgxRow || /[\n\r]+/)
            .map(function (row) {
                return row.split(rgxCol || /\t/);
            });
    }

    // matrixString :: [[String]] -> String
    function matrixString(lstRows) {
        return lstRows
            .map(function (row) {
                return row.join('\t');
            }).join('\n');
    }

    var a = Application.currentApplication(),
        sa = (a.includeStandardAdditions = true, a),
        strClip = sa.theClipboard(),

        strTransPosed = typeof strClip === "string" ? (
            matrixString(transpose(stringMatrix(strClip)))
        ) : '';

        return (
            strTransPosed.length && sa.setTheClipboardTo(
                    strTransPosed
            ),
            strTransPosed
        );
})();

ES6 JS

(() => {
    'use strict';

    // transpose :: [[a]] -> [[a]]
    let transpose = rows =>
            rows[0].map((_, iCol) =>
                rows.map(row => row[iCol])
            ),

        // stringMatrix :: String ->
        //    Maybe Regex -> Maybe Regex -> [[String]]
        stringMatrix = (s, rgxRow, rgxCol) =>
            s.split(rgxRow || /[\n\r]+/)
            .map(row => row.split(rgxCol || /\t/)),

        // matrixString :: [[String]] -> String
        matrixString = lstRows =>
            lstRows.map(row => row.join('\t'))
            .join('\n');

    let a = Application.currentApplication(),
        sa = (a.includeStandardAdditions = true, a),
        strClip = sa.theClipboard(),

        strTransPosed = typeof strClip === "string" ? (
            matrixString(transpose(stringMatrix(strClip)))
        ) : '';

    return (
        strTransPosed.length && sa.setTheClipboardTo(
            strTransPosed
        ),
        strTransPosed
    );
})();

AS

-- TOGGLE ROWS <-> COLUMNS
on run
    set strClip to (the clipboard as string)
    if length of strClip > 0 then
        set strTransposed to matrixString(transpose(stringMatrix(strClip)))
        set the clipboard to strTransposed
        strTransposed
    else
        missing value
    end if
end run


-- Tab-delimited lines to list of lists of strings
-- stringMatrix :: String -> [[String]]
on stringMatrix(s)
    script cells
        on lambda(strLine)
            splitOn(tab, strLine)
        end lambda
    end script
    
    if s contains linefeed then
        set strDelim to linefeed
    else
        set strDelim to return
    end if
    
    map(cells, splitOn(strDelim, s))
end stringMatrix

-- Lists of lists of strings to lines of text
-- matrixString :: [[String]] -> String
on matrixString(cellRows)
    script rowString
        on lambda(cells)
            intercalate(tab, cells)
        end lambda
    end script
    
    intercalate(linefeed, map(rowString, cellRows))
end matrixString



-- GENERIC LIBRARY FUNCTIONS

-- Rows <-> columns
-- transpose :: [[a]] -> [[a]]
on transpose(rows)
    
    -- column :: a -> [a]
    script column
        -- Just the index of each top row item
        on lambda(_, n)
            
            -- nthCell :: [a] -> a
            script nthCell
                on lambda(row)
                    item n of row
                end lambda
            end script
            
            -- Column n consists of
            -- cell n of each row
            map(nthCell, rows)
        end lambda
    end script
    
    -- A column from each item of the top row
    map(column, item 1 of rows)
end transpose

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    set mf to mReturn(f)
    set lng to length of xs
    set lst to {}
    repeat with i from 1 to lng
        set end of lst to mf's lambda(item i of xs, i, xs)
    end repeat
    return lst
end map

-- splitOn :: Text -> Text -> [Text]
on splitOn(strDelim, strMain)
    set {dlm, my text item delimiters} to {my text item delimiters, strDelim}
    set lstParts to text items of strMain
    set my text item delimiters to dlm
    return lstParts
end splitOn

-- intercalate :: Text -> [Text] -> Text
on intercalate(strText, lstText)
    set {dlm, my text item delimiters} to {my text item delimiters, strText}
    set strJoined to lstText as text
    set my text item delimiters to dlm
    return strJoined
end intercalate

-- Lift 2nd class handler function into 1st class script wrapper 
-- mReturn :: Handler -> Script
on mReturn(f)
    if class of f is script then
        f
    else
        script
            property lambda : f
        end script
    end if
end mReturn

3 Likes

For a more ‘doobie do’ (iterative/imperative first ‘I’ ‘do’ this then ‘I’ ‘do’ this) transposition in AppleScript, see:

http://rosettacode.org/wiki/Matrix_transposition#AppleScript

@ComplexPoint, this is so handy. Thank you :slight_smile:

1 Like

It would be nice to have a macro that prompts the user for the delimiter before transposing.
A humble request. :wink:

I'll leave the prompt design to you, but here, FWIW, is a version which lets you specify the column delimiter (e.g. either four spaces or a tab) in a local_ColumnDelimiter variable.

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    ObjC.import("AppKit");

    // Rotate any lines of column delimited text in clipboard.

    // Column delimiter is either a default (see below)
    // or specified in KM `local_ColumnDelimiter` variable.
    // Rob Trew @2023

    // main :: IO ()
    const main = () => {
        const
            defaultDelimiter = "\t",
            delimString = kmValue(
                kmInstance()
            )(
                "local_ColumnDelimiter"
            ) || defaultDelimiter;

        return either(
            alert("Rotation of matrix text in clipboard")
        )(
            copyText
        )(
            fmapLR(
                compose(
                    unlines,
                    map(s => s.join(delimString)),
                    transpose,
                    map(splitRegex(delimString)),
                    lines
                )
            )(
                clipTextLR()
            )
        );
    };

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

    // kmInstance :: () -> IO String
    const kmInstance = () =>
        ObjC.unwrap(
            $.NSProcessInfo.processInfo.environment
            .objectForKey("KMINSTANCE")
        ) || "";


    // kmValue :: KM Instance -> String -> IO String
    const kmValue = instance =>
        k => Application("Keyboard Maestro Engine")
        .getvariable(k, {instance});

    // ----------------------- 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 = () => {
        // Either a message, or the plain text
        // content of the clipboard.
        const
            mb = $.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString);

        return mb.isNil() ? (
            Left("No utf8-plain-text found in clipboard.")
        ) : Right(ObjC.unwrap(mb));
    };


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


    // 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 => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);

    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e ? (
            e
        ) : Right(f(e.Right));


    // lines :: String -> [String]
    const lines = s =>
    // A list of strings derived from a single string
    // which is delimited by \n or by \r\n or \r.
        0 < s.length
            ? s.split(/\r\n|\n|\r/u)
            : [];

    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
    // The list obtained by applying f
    // to each element of xs.
    // (The image of xs under f).
        xs => [...xs].map(f);

    // splitRegex :: Regex -> String -> [String]
    const splitRegex = needle =>
        haystack => haystack.split(needle);


    // transpose :: [[a]] -> [[a]]
    const transpose = rows => {
    // If any rows are shorter than those that follow,
    // their elements are skipped:
    // > transpose [[10,11],[20],[],[30,31,32]]
    //             == [[10,20,30],[11,31],[32]]
        const go = xss =>
            0 < xss.length ? (() => {
                const
                    h = xss[0],
                    t = xss.slice(1);

                return 0 < h.length
                    ? [[h[0],
                        ...t.reduce(
                            (a, xs) => a.concat(
                                0 < xs.length
                                    ? [xs[0]]
                                    : []
                            ),
                            []
                        )],
                    ...go([
                        h.slice(1),
                        ...t.map(xs => xs.slice(1))
                    ])]
                    : go(t);
            })() : [];

        return go(rows);
    };

    // unlines :: [String] -> String
    const unlines = xs =>
    // A single string formed by the intercalation
    // of a list of strings with the newline character.
        xs.join("\n");

    return main();
})();