Sorting anything with Execute JavaScript actions

After useful discussions in other threads about sorting filenames by 3 substrings, here are a few notes, with:

  1. line data and record data examples,
  2. some source code, and
  3. a couple of working macros with various custom sorts,

about the basics of sorting anything (any lines or records of data) by using JavaScript actions (JXA or browser) in Keyboard Maestro:

  1. Using primary, secondary and N-ary sort keys
  2. Using any mix of Ascending and Descending orders for each key
  3. Specifying custom sorts which don't depend on the order of 'fields' or 'columns' in the data.

Basic functions for sorting in JS

The built-in Array.sort() method lets us provide a custom function on two arguments (a, b) which returns an 'Ordering' value: -1 or 0 or 1, depending on whether a is less than, equal to, or more than b.

For a simple one-key sort, all we need to write or paste is a compare(a, b) function, which takes two arguments of the same type (e.g. two numbers, strings, or booleans which we want to compare), and returns an Ordering value.

// compare :: a -> a -> Ordering
const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);

If we want secondary, tertiary ... n-ary sort keys, we can append a list of these Ordering values together in a special way: as soon as one of them has a non-zero value (i.e. not equal: either -1 for 'less than' or 1 for 'more than), we just ignore the rest – primary keys override secondary keys, secondaries override tertiaries and so on.

The pattern when we are combining two Ordering values (a, b) (e.g. for the primary and secondary sort keys) is just: if a is not zero then we use a. Otherwise, we use b.

// Ordering :: ( LT | EQ | GT ) | ( -1 | 0 | 1 )
// appendOrdering :: Ordering -> Ordering -> Ordering
const appendOrdering = (a, b) => a !== 0 ? a : b;

With these basics, we can now define our custom sorts.

A custom sort is an array of simple property-getting functions, in the order of our primary, secondary and n-Ary sort keys. Each function is applied to a line or record of data, and returns a value (for example a field of the data, the key of a record, or a particular part of a string).

For example, if we want to sort a KM variable containing some record lines like these in various ways:

{city: 'Shanghai', pop: 24.3, country: 'China', capital: false}
{city: 'Beijing', pop: 21.5, country: 'China', capital: true}
{city: 'Delhi', pop: 11.0, country: 'India', capital: true}
{city: 'Lagos', pop: 16.0, country: 'Nigeria', capital: true}
{city: 'Karachi', pop: 14.9, country: 'Pakistan', capital: false}
{city: 'Dhaka', pop: 14.5, country: 'Bangladesh', capital: true}
{city: 'Guangzhou', pop: 14.0, country: 'China', capital: false}
{city: 'Istanbul', pop: 14.0, country: 'Turkey', capital: false}
{city: 'Tokyo', pop: 13.5, country: 'Japan', capital: true}

We could sort first by name of Country, and then by descending populations size, with a sequence of the following two functions:

    x => x.country
    x => x.pop // false

Note that this is using ES6 (Sierra onwards JS) anonymous functions or lambda functions.

If you were trying to do something like this in the older ES5 JavaScript you would need to use the following, slightly wordier format:

    function (x) { return x.country }
    function (x) { return x.pop } // false

Ascending sort is the default. In the attached macros, if you add false after a pair of comment slashes, this is read to mean that you want a descending sort.

Another example – if we want to separate out the large cities that are national capitals from those that are not, we can use the capital key here as the primary sort key, and perhaps the Country name as the secondary key:

x => x.capital // false // DESC :: true (1) then false (0)
x => x.country

There are two comment markers here. Only the first is used, the second is ignored, so you can use it for adding notes.

There are other examples, both for record sorting and for sorting tab-delimited lines, in the attached macros. The code is the same for both.

What are these macros doing ? They are combining the Ordering values returned by an array of functions (of any length) into a comparison function (like our first compare function) which can be passed to Array.sort(); They are also taking account of any false or true in the first comment string after a function, to choose between Ascending and Descending sorts.

The function which combines the Orderings and the direction Booleans is (in an ES6, Sierra onwards JS idiom):

    // mappendFoldComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    const mappendFoldComparing = fbs =>
        (x, y) => fbs.reduce(
            (ord, [f, bool]) => ord !== 0 ? ord : (
                (bool ? id : flip)(compare)(f(x), f(y))
            ), 0
        );

Note that:

  1. It uses our simple compare function
  2. When one of the sort keys is being used in a descending order, it creates a 'flipped' version of compare, which reverses the sort order by reversing the order of the (a, b) arguments of compare to (b, a).

Flip can be written in JS as:

// flip :: (a -> b -> c) -> b -> a -> c
const flip = f => (a, b) => f.apply(null, [b, a]);

The main mappendFoldComparing function uses a 'fold' (in JS terms the Array.reduce() method) rather than a loop.

Using a fold/reduce simplifies code, and makes it less accident-prone, but it may not yet be as familiar to some scripters as an iterating loop with a mutating counter variable. To unpack what an mppendComparing function is doing, here is a looping (ES5) rewrite with some comments. More verbose, and more detail that needs to be checked and can go wrong, but perhaps a bit clearer at first acquaintance ?

    // ILLUSTRATIVE ONLY - NOT USED IN THE MACROS BELOW
    // AN ITERATIVE AND COMMENTED ES5 VERSION, FOR COMPARISON WITH FOLD
    // VERSION: mappendFoldComparing()   (below)
    // mappendIterComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    function mappendIterComparing(fbs) {
        // Given an ordered list of functions (tupled with direction Bools),
        // each of which returns some field or value
        // derived from a line or record of the data,
        // as well as a Bool :: True for Ascending | False for Descending,

        return function (x, y) {
            // this returns a single comparator function
            // which can be passed to Array.sort()
            var lng = fbs.length,
                ord = 0; // An Ordering value: ( -1 |  0  |  1 )
            for (i = 0; i < lng; i++) {

                // The nth function to sort by,
                // tupled with a Bool (Ascending::True, Descending::False)
                var fb = fbs[i],
                    f = fb[0],
                    bool = fb[1];

                // An ASCending or DESC (flipped) comparison of the values
                // that are returned by f when applied to x and y respectively.
                var ord = (bool ? id : flip)(compare)(f(x), f(y));
                if (ord !== 0) return ord; // Only first non-zero value needed
            }
            return ord;
        };
    };

For more examples, and for use of the same code with delimited or other text lines, see the two attached macros. Each defines 3/4 different custom sorts in Set KM Variable to Text actions. Make sure that you use the gear wheel on these actions to enable only one custom sort at a time.

Sorting Macros.kmmacros (60.9 KB)

3 Likes

PS Some sorting can be done in Keyboard Maestro’s Execute AppleScript actions, but custom sorts are a little harder and less flexible – the obvious way to do it is by calling ObjC methods in the Foundation class.

Here are a couple of basic AppleScript functions:

use framework "Foundation" -- for basic NSArray sort

-- sort :: [a] -> [a]
on sort(xs)
	((current application's NSArray's arrayWithArray:xs)'s ¬
		sortedArrayUsingSelector:"compare:") as list
end sort
use framework "Foundation"

-- In sortBy, f is a function from () to a tuple of two parts:
-- 1. a function from any value to a record derived from (and containing) that value
--    The base value should be present in the record under the key 'value'
--    additional derivative keys (and optionally the 'value' key) should be included in 2:
-- 2. a list of (record key, boolean) tuples, in the order of sort comparison,
--    where the value *true* selects ascending order for the paired key
--    and the value *false* selects descending order for that key
 
-- sortBy :: (() -> ((a -> Record), [(String, Bool)])) -> [a] -> [a]
on sortBy(f, xs)
    set {fn, keyBools} to mReturn(f)'s |λ|()
    script unWrap
        on |λ|(x)
            value of x
        end |λ|
    end script
    map(unWrap, sortByComparing(keyBools, map(fn, xs)))
end sortBy

-- List of {strKey, blnAscending} pairs -> list of records -> sorted list of records

-- sortByComparing :: [(String, Bool)] -> [Records] -> [Records]
on sortByComparing(keyDirections, xs)
    set ca to current application
    
    script recDict
        on |λ|(x)
            ca's NSDictionary's dictionaryWithDictionary:x
        end |λ|
    end script
    set dcts to map(recDict, xs)
    
    script asDescriptor
        on |λ|(kd)
            set {k, d} to kd
            ca's NSSortDescriptor's sortDescriptorWithKey:k ascending:d selector:dcts
        end |λ|
    end script
    
    ((ca's NSArray's arrayWithArray:dcts)'s ¬
        sortedArrayUsingDescriptors:map(asDescriptor, keyDirections)) as list
end sortByComparing

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

-- 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 |λ| : f
		end script
	end if
end mReturn
2 Likes

Running your macro "Sort lists of anything by N keys (ASC | DESC) Example 2 delimited text",
I get this error:

/var/folders/hb/6xgg0y8j4g530m81rd1f9mpc0000gn/T/Keyboard-Maestro-Script-508D9E69-964E-4BAE-917A-AA1A0C1B3DBA:4415:4488: execution error: Error on line 114: SyntaxError: Unexpected token '>' (-2700)

I don't know if it is the same line# or not, but in Script Editor, line 114 is:
return [eval('[' + lr[0] + ']')[0], lr.length > 1 ? function () {

Of course, I had to convert your ES6 script to ES5 using Babel (as you often recommend). Babel produced this script:

'use strict';

var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }();

(function () {
    //'use strict';

    // for n-ary sorts:
    // derives a comparator function from a list of property-getting functions

    // compare :: a -> a -> Ordering
    var compare = function compare(a, b) {
        return a < b ? -1 : a > b ? 1 : 0;
    };

    // A derivative function, identical except in that
    // its two arguments are reversed:
    // flip :: (a -> b -> c) -> b -> a -> c
    var flip = function flip(f) {
        return function (a, b) {
            return f.apply(null, [b, a]);
        };
    };

    // id :: a -> a
    var id = function id(x) {
        return x;
    };

    // lines :: String -> [String]
    var lines = function lines(s) {
        return s.split(/[\r\n]/);
    };

    // log :: a -> IO ()
    var log = function log() {
        for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
            args[_key] = arguments[_key];
        }

        return console.log(args.map(JSON.stringify).join(' -> '));
    };

    // map :: (a -> b) -> [a] -> [b]
    var map = function map(f, xs) {
        return xs.map(f);
    };

    // ILLUSTRATIVE ONLY - NOT USED:
    // AN ITERATIVE AND COMMENTED ES5 VERSION, FOR COMPARISON WITH FOLD
    // VERSION: mappendFoldComparing()   (below)
    // mappendIterComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    function mappendIterComparing(fbs) {
        // Given an ordered list of functions (tupled with direction Bools),
        // each of which returns some field or value
        // derived from a line or record of the data,
        // as well as a Bool :: True for Ascending | False for Descending,

        return function (x, y) {
            // this returns a single comparator function
            // which can be passed to Array.sort()
            var lng = fbs.length,
                ord = 0; // An Ordering value: ( -1 |  0  |  1 )
            for (i = 0; i < lng; i++) {

                // The nth function to sort by,
                // tupled with a Bool (Ascending::True, Descending::False)
                var fb = fbs[i],
                    f = fb[0],
                    bool = fb[1];

                // An ASCending or DESC (flipped) comparison of the values
                // that are returned by f when applied to x and y respectively.
                var ord = (bool ? id : flip)(compare)(f(x), f(y));
                if (ord !== 0) return ord; // Only first non-zero value needed
            }
            return ord;
        };
    };

    // mappendFoldComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    var mappendFoldComparing = function mappendFoldComparing(fbs) {
        return function (x, y) {
            return fbs.reduce(function (ord, _ref) {
                var _ref2 = _slicedToArray(_ref, 2),
                    f = _ref2[0],
                    bool = _ref2[1];

                return ord !== 0 ? ord : (bool ? id : flip)(compare)(f(x), f(y));
            }, 0);
        };
    };

    // show :: Int -> a -> Indented String
    // show :: a -> String
    var show = function show() {
        for (var _len2 = arguments.length, x = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
            x[_key2] = arguments[_key2];
        }

        return JSON.stringify.apply(null, x.length > 1 ? [x[1], null, x[0]] : x);
    };

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    var sortBy = function sortBy(f, xs) {
        return xs.slice().sort(f);
    };

    // -----------------------------------------------------------------------
    var kme = Application('Keyboard Maestro Engine'),
        strFs = lines(kme.getvariable('functionSequence')),
        fnBoolPairs = strFs.map(function (x) {
        var lr = x.split(/\/\//g);
        return [eval('[' + lr[0] + ']')[0], lr.length > 1 ? function () {
            try {
                return Boolean(eval(lr[1]));
            } catch (e) {
                return true;
            }
        }() : true];
    });

    return show(2, sortBy(mappendIterComparing(fnBoolPairs), JSON.parse(kme.getvariable('jsonArray'))));
})();

I made no changes other than replacing your ES6 script with the Babel ES5 script.
I'm running Keyboard Maestro 8.0 (8.0) on macOS 10.11.6.

What needs to be changed in the Babel ES5 Script to fix this bug?

ES5 lambdas (anonymous functions) have the format:

function (x) { return x.country }

in lieu of the ES6 ‘fat arrow’ lambdas:

x => x.country

Thanks, but I don't have any "fat arrow" lambdas in the ES5 script.
I don't see how your post fixes the bug I reported:

JavaScript code is present in three places in those macros:

  1. The final sorting action
  2. The preliminary data tidying action
  3. The sequences of functions (and optional boolean comments) which define the custom sort orders in the functionSequence KM variable.

I don’t have an ES5 (pre-Sierra) system to test with here, (and as new builds of applications like OmniOutliner are already becoming Sierra-only it maybe that the sun is beginning to set on ES5) but it looks to me as if you may not have converted the code in all three of the locations listed above.

In particular, I wonder whether you have converted the sort descriptor variables from ES6 lambdas to ES5 lambdas:

For example the ES6 custom sort:

// IS CAPITAL, THEN DESCENDING SIZE

x => x.split('\t')[3] // false //  Capital DESC (1: True, 0: False)
x => parseFloat(x.split('\t')[1], 10) // false // Pop DESC

Would need to be rewritten in ES5 to something like:

function (x) { return x.split('\t')[3] } // false
function (x) { return parseFloat(x.split('\t')[1], 10) } // false

in the value of the functionSequence Keyboard Maestro variable.

OK, I made that change. Now I get this error

/var/folders/hb/6xgg0y8j4g530m81rd1f9mpc0000gn/T/Keyboard-Maestro-Script-7230E57C-0F05-43A3-9DBF-8BCEA25C03D4:2562:2601: execution error: Error on line 64: ReferenceError: Can't find variable: i (-2700)

on this line:
` for (i = 0; i < lng; i++) {'

Looks like to me that i is NEVER declared in that function, so the use strict causes an error.

That line is in this function, which I understood from your comments is "NOT USED:"

    // ILLUSTRATIVE ONLY - NOT USED:
    // AN ITERATIVE AND COMMENTED ES5 VERSION, FOR COMPARISON WITH FOLD
    // VERSION: mappendFoldComparing()   (below)
    // mappendIterComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    function mappendIterComparing(fbs) {
        // Given an ordered list of functions (tupled with direction Bools),
        // each of which returns some field or value
        // derived from a line or record of the data,
        // as well as a Bool :: True for Ascending | False for Descending,

        return function (x, y) {
            // this returns a single comparator function
            // which can be passed to Array.sort()
            var lng = fbs.length,
                ord = 0; // An Ordering value: ( -1 |  0  |  1 )
            for (i = 0; i < lng; i++) {

                // The nth function to sort by,
                // tupled with a Bool (Ascending::True, Descending::False)
                var fb = fbs[i],
                    f = fb[0],
                    bool = fb[1];

                // An ASCending or DESC (flipped) comparison of the values
                // that are returned by f when applied to x and y respectively.
                var ord = (bool ? id : flip)(compare)(f(x), f(y));
                if (ord !== 0) return ord; // Only first non-zero value needed
            }
            return ord;
        };
    };

BUT, then you have this line which calls that function:
return show(2, sortBy(mappendIterComparing(fnBoolPairs), JSON.parse(kme.getvariable('jsonArray'))));

Please clarify the use of function mappendIterComparing(fbs)

@ComplexPoint,

So, when I correct this line:
for (i = 0; i < lng; i++) {

to add "var":
for (var i = 0; i < lng; i++) {

your script/macro now runs with this result:

Strange that the output does NOT expand the TAB codes "\t".

After I put it into BBEdit, it looks like this:

  "Delhi	11.0	India	true",
  "Tokyo	13.5	Japan	true",
  "Dhaka	14.5	Bangladesh	true",
  "Lagos	16.0	Nigeria	true",
  "Beijing	21.5	China	true",
  "Guangzhou	14.0	China	false",
  "Istanbul	14.0	Turkey	false",
  "Karachi	14.9	Pakistan	false",
  "Shanghai	24.3	China	false"

That looks like ascending order of size to me, but your macro said "descending":

But the translation to ES5 that you gave me did NOT include all of the data:

So, when I correct the ES5 version to:

I finally get the correct answer from the macro, with the data in descending order.

Actually, now that I look at the code and the results, the results look wrong:

Isn't it supposed to do a two-level sort:

  1. Capital Descending
  2. Size Descending

Since none of the capitals are repeated, it should be ordered by descending capitals.

Something's wrong.

Later version – the two functions are interchangeable in the ES6 original, but the current version uses the fold (rather than iterative version)

(In the very first upload, I had inadvertently posted a version in which I was testing the alternative iterative draft – looks like you got that copy before I corrected it. I can see that that must have been confusing - my apologies :slight_smile: )

Better to use the fold (either by editing your copy or downloading the current one) – as you have found in ES5 conversion, there’s always a bit more that can go wrong in an iterative mutation than in a fold. The reduce/fold mechanism handles the messier details for us.

( and has a surprisingly wide range of application - a ‘fold’ or ‘reduce’ function is really the most powerful and reliable ‘swiss army knife’ in any language. Part of the beauty of it is that we don’t need to know how it is implemented – in some languages it would make most sense to do it recursively, in others iteratively – all we need to know is that it is taking care of the details and mechanics for us. )

One way of understanding it might be to look at an equivalent (called foldl, as in ‘fold left’ – there is also a ‘foldr’, which in JS is provided in the form of Array.reduceRight)

AppleScript version of foldl / reduce

on run
    script sum
        on |λ|(accumulator, x)
            accumulator + x
        end |λ|
    end script
    
    foldl(sum, 0, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
    
    --> 55
end run


-- foldl :: (a -> b -> a) -> a -> [b] -> a
on foldl(f, startValue, xs)
    tell mReturn(f)
        set v to startValue
        set lng to length of xs
        repeat with i from 1 to lng
            set v to |λ|(v, item i of xs, i, xs)
        end repeat
        return v
    end tell
end foldl

-- For comparison: foldr, essentially equivalent to the JS Array.reduceRight
-- though it traditionally expects a function on (x, accumulator)
-- whereas a foldl expects a function on (accumulator, x)
-- (JS uses the (acc, x) pattern for both reduce and reduceRight)

-- foldr :: (b -> a -> a) -> a -> [b] -> a
on foldr(f, startValue, xs)
	tell mReturn(f)
		set v to startValue
		set lng to length of xs
		repeat with i from lng to 1 by -1
			set v to |λ|(item i of xs, v, i, xs)
		end repeat
		return v
	end tell
end foldr

-- 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 |λ| : f
        end script
    end if
end mReturn

Fold version of mappendComparing:

    // mappendFoldComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    const mappendFoldComparing = fbs =>
        (x, y) => fbs.reduce(
            (ord, [f, bool]) => ord !== 0 ? ord : (
                (bool ? id : flip)(compare)(f(x), f(y))
            ), 0
        );

Since none of the capitals are repeated, it should be ordered by descending capitals.

If you look at the search descriptor, you will notice that Is Capital is the boolean field at the end ( zero-based col [3] )

Beijing is a capital, Shanghai and Guangzhou are not, for example.

The descending sort brings all the isCapital=true lines to the top, pushing the isCapital=false lines to the bottom.

Within the two sets of cities (capital and not), it then orders by descending population size.

Strange that the output does NOT expand the TAB codes "\t".

For this experimental and illustrative macro, the final output is wrapped in the show function (JSON.stringify, with an indentation argument, in this case), to give the scripter a fuller picture of what an output KM variable would now contain.

    return show(2, sortBy(
        mappendFoldComparing(fnBoolPairs),
        JSON.parse(kme.getvariable('jsonArray'))
    ))

For the purposes of further use, rather than experimental inspection, the show(2, ...) wrapper can be removed.

return sortBy(
    mappendFoldComparing(fnBoolPairs),
    JSON.parse(kme.getvariable('jsonArray'))
);

FootNote:

ES5 versions of the generic JS custom sort functions, with an example.

( Hard for me to test ES5 – I have to borrow a machine – not something I can offer to do frequently :- )

// Rob Trew (c) 2017
// Ver 0.02  - added a comparing() function

var strClip = (function () {
    'use strict';

    // GENERIC FUNCTIONS FOR CUSTOM SORTS ------------------------------------
        // The central function for AZ sorts,
        // which we can 'flip' for ZA sorts.

        // compare :: a -> a -> Ordering
        function compare(a, b) {
            return a < b ? -1 : a > b ? 1 : 0;
        };

        // flip() returns an argument-flipped version of the function passed to it.
        // Where compare(a, b) gives an AZ sort,
        // flip(compare)(a, b) gives a ZA sort.

        // flip :: (a -> b -> c) -> b -> a -> c
        function flip(f) {
            return function (a, b) {
                return f.apply(null, [b, a]);
            };
        };

        // If we don't want to directly compare the whole of a and b,
        // but just some particular part, property, or function of them
        // (e.g. their lengths, or the values each returns for some key)
        // we can get a specialised version of compare, by passing the
        // the relevant data-extraction function to comparing() which
        // returns our specialised version of the compare function.

        // comparing :: (a -> b) -> (a -> a -> Ordering)
        function comparing(f) {
            return function (x, y) {
                return compare(f(x), f(y));
            };
        };

        // Stet - an unmodified version of the value passed to it.
        // i.e. a Vanilla function. More useful than it looks :-)

        // id :: a -> a
        function id(x) {
            return x;
        };

        // Builds a single comparator function from a sequence of
        // [Function, Bool] pairs, in which the Bool means DESC if false

        // mappendComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
        function mappendComparing(fbs) {
            return function (x, y) {
                return fbs.reduce(function (ord, fAndBool) {
                    var f = fAndBool[0],
                        bool = fAndBool[1];
                    return ord !== 0 ? (
                        ord
                    ) : (bool ? id : flip)(comparing(f))(x, y);
                }, 0);
            };
        };

        // Returns a new array, rather than mutating in-place

        // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
        function sortBy(f, xs) {
            return xs.slice()
                .sort(f);
        };

        // OTHER GENERICS --------------------------------------------------------

        // intercalate :: String -> [a] -> String
        function intercalate(s, xs) {
            return xs.join(s);
        };

        // lines :: String -> [String]
        function lines(s) {
            return s.split(/[\r\n]/);
        };

        // map :: (a -> b) -> [a] -> [b]
        function map(f, xs) {
            return xs.map(f);
        };

        // show :: Int -> a -> Indented String
        // show :: a -> String
        function show() {
            for (var _len = arguments.length,
                    x = Array(_len), _key = 0; _key < _len; _key++) {
                x[_key] = arguments[_key];
            }

            return JSON.stringify.apply(null, x.length > 1 ? [x[1], null, x[0]] : x);
        };

        // unlines :: [String] -> String
        function unlines(xs) {
            return xs.join('\n');
        };

        // EXAMPLE ---------------------------------------------------------------

        var strCities = "Shanghai\t24.3\tChina\tfalse\n\
    Beijing\t21.5\tChina\ttrue\n\
    Delhi\t11.0\tIndia\ttrue\n\
    Lagos\t16.0\tNigeria\ttrue\n\
    Karachi\t14.9\tPakistan\tfalse\n\
    Dhaka\t14.5\tBangladesh\ttrue\n\
    Guangzhou\t14.0\tChina\tfalse\n\
    Istanbul\t14.0\tTurkey\tfalse\n\
    Tokyo\t13.5\tJapan\ttrue";

        // Simplifying sorts by pre-segmenting this time -------------------------
        var lstCities = map(
            function (x) {
                return x.split('\t');
            },
            lines(strCities)
        );

        // Sample sort: IS CAPITAL  ('true' > 'false') THEN DESCENDING SIZE
        var xs = sortBy(
            mappendComparing(
                [ // Function + Bool pairs (record property + sort order)

                    // PRIMARY: isCapital, false=DESC
                    [function (x) { return x[3] }, false],

                    // SECONDARY: population size, false=DESC
                    [function (x) { return parseFloat(x[1], 10) }, false]
                ]
            ), lstCities);
        // Arrays of cells -> tab-delimited lines.
        return unlines(
            map(function (x) {
                return intercalate('\t', x);
            }, xs)
        );
    })();

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

    sa.setTheClipboardTo(strClip);

    strClip
1 Like

An interesting Keyboard Maestro exercise might be to design a plugin interface which simplified, for the user, the specification of a custom sort as a list of (Function, Bool) pairs,

i.e. what might be the most Maestronic way of simplifying the creation of a sequence of (Function, Bool) pairs like:

The Bools one would probably want as checkboxes to mark an option for Descending sorts.

The functions are obviously simpler in an ES6 idiom:

x => x[3]

seems less cumbersome than

function(x) { return x[3] }

But it might be possible to preprocess things a little to allow a simpler sort-order spec for quick macro assembly.

2 Likes

Updated code – a JavaScript version (with examples) of the sortOn(f, xs) function posted for Applescript at:

(() => {
    'use strict';

    // Sort a list by comparing the results of a key function applied to each
    // element. sortOn f is equivalent to sortBy (comparing f)

    // sortOn :: Ord b => (a -> b) -> [a] -> [a]
    const sortOn = (f, xs) => {
        // Functions and matching bools derived from argument f
        // which may be a single key function, or a list of key functions
        // each of which may or may not be followed by a direction bool
        const [fs, bs] = unzip(
                flatten([f])
                .reduceRight((a, x) =>
                    typeof x === 'boolean' ? {
                        asc: x,
                        fbs: a.fbs
                    } : {
                        asc: true,
                        fbs: [
                            [x, a.asc]
                        ].concat(a.fbs)
                    }, {
                        asc: true,
                        fbs: []
                    })
                .fbs
            ),
            iLast = fs.length;
        // decorate-sort-undecorate
        return sortBy(mappendComparing(
                // functions that access pre-calculated values by position
                // in the decorated ('Schwartzian') version of xs
                zip(
                    fs.map((_, i) => x => x[i]),
                    bs
                )
            ), xs.map( // xs decorated with precalculated key function values
                x => fs.reduceRight(
                    (a, g) => [g(x)].concat(a), [
                        x
                    ])))
            .map(x => x[iLast]); // undecorated version of data, post sort.
    };

    // -----------------------------------------------------------------------

    // compare :: a -> a -> Ordering
    const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);

    // flatten :: Tree a -> [a]
    const flatten = t =>
        Array.isArray(t) ? (
            [].concat.apply([], t.map(flatten))
        ) : t;

    // mappendComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
    const mappendComparing = fboolPairs =>
        (x, y) => fboolPairs.reduce(
            (ord, [f, b]) => ord !== 0 ? (
                ord
            ) : (
                b ? compare(f(x), f(y)) : compare(f(y), f(x))
            ), 0
        );

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

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = (f, xs) =>
        xs.slice()
        .sort(f);

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

    // zip :: [a] -> [b] -> [(a, b)]
    const zip = (xs, ys) =>
        xs.slice(0, Math.min(xs.length, ys.length))
        .map((x, i) => [x, ys[i]]);

    // TEST -------------------------------------------------------------------

    // Data to sort

    // greekLetterNames :: () -> [String]
    const greekLetterNames = () => [
        "alpha", "beta", "gamma", "delta", "epsilon", "zeta",
        "eta", "theta", "iota", "kappa", "lambda", "mu"
    ];

    // cities :: () -> [Dict]
    const cities = () => [{
            city: "Shanghai",
            pop: 24.3,
            country: "China",
            capital: false
        },
        {
            city: "Beijing",
            pop: 21.5,
            country: "China",
            capital: true
        },
        {
            city: "Delhi",
            pop: 11.0,
            country: "India",
            capital: true
        },
        {
            city: "Lagos",
            pop: 16.0,
            country: "Nigeria",
            capital: true
        },
        {
            city: "Karachi",
            pop: 14.9,
            country: "Pakistan",
            capital: false
        },
        {
            city: "Dhaka",
            pop: 14.5,
            country: "Bangladesh",
            capital: true
        },
        {
            city: "Guangzhou",
            pop: 14.0,
            country: "China",
            capital: false
        },
        {
            city: "Istanbul",
            pop: 14.0,
            country: "Turkey",
            capital: false
        },
        {
            city: "Tokyo",
            pop: 13.5,
            country: "Japan",
            capital: true
        }
    ];

    // Key Functions for sort descriptors ------------------------------------

    // country :: Dict -> String
    const country = x => x.country;

    // isCapital :: Dict -> Bool
    const isCapital = x => x.capital;

    // population :: Dict -> Num
    const population = x => x.pop;

    // reverseString :: String -> String
    const reverseString = s =>
        s.split('')
        .reverse()
        .join('');

    // stringLength :: String -> Int
    const stringLength = s =>
        s.length;

    return [
        // Sorting from suffix toward prefix, ASCENDING

        sortOn(reverseString, greekLetterNames()),

        // From -da up to -mu
        // -> ["lambda","alpha","gamma","kappa","eta","beta","theta",
        //  "zeta","delta","iota","epsilon","mu"]

        // Sorting from suffix toward prefix, DESCENDING

        sortOn([reverseString, false], greekLetterNames()),

        // From -mu down to -da
        // -> ["mu","epsilon","iota","delta","zeta","theta","beta","eta",
        //     "kappa","gamma","alpha","lambda"]


        // Primary sort : Country name Ascending ('Bangladesh' first)
        // Secondary sort : isCapital Boolean Descending (True then False)
        //     Beijing, as capital, comes before Shanghai and Guangzhou.
        // Tertiary sort : Population Descending - Shanghai before Guangzhou

        sortOn([country, isCapital, false, population, false], cities()),

        // Key functions can optionally be bracketed with any direction bools
        // that follow them, for legibility.

        sortOn([country, [isCapital, false], [population, false]], cities())
    ];
})();