Sum of numeric rows in Bike

A script which:

  • copies to the clipboard, and
  • displays in a dialog

the sum of all numeric rows which are either:

  1. visible and descending from (i.e. indented under) the current row, or
  2. (if the selection is extended) in the set of selected rows.

Here, for example, the selection is not extended, and all descendant numeric lines are summed, with the sum copied to the clipboard:

Screenshot 2022-08-20 at 16.17.05

whereas in this case, the selection is extended, so only the selected lines are summed:

Screenshot 2022-08-20 at 16.20.41


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

    ObjC.import("AppKit");

    // SUM of the numeric rows in the extended selection
    // OR
    //  (if the selection is not extended)
    //  Sum of all visible numeric rows descending
    //  from the current line.

    // Copied to the clipboard, and displayed in a dialog.

    // Rob Trew @2022
    // Ver 0.02

    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return either(
            alert("Sum of numbers in Bike")
        )(
            compose(
                alert("Copied to clipboard"),
                copyText
            )
        )(
            doc.exists() ? Right(
                (
                    Boolean(doc.selectedText()) ? (
                        selectedRowsForest(doc)
                    ) : [
                        visibleSubTreeOfRow(
                            doc.selectionRow()
                        )
                    ]
                )
                .reduce(treeSum, 0)
                .toFixed(2)
            ) : Left("No document open in Bike.")
        );
    };

    // ---------------------- BIKE -----------------------

    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree {text:String, body:Int}]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const
                    [depth, body] = xs[0],
                    [tree, rest] = span(x => depth < x[0])(
                        xs.slice(1)
                    );

                return [
                    Node({
                        text: body,
                        level: depth
                    })(go(tree))
                ]
                .concat(go(rest));
            })() : [];

        return go(tuples);
    };


    // selectedRowsForest :: Bike Doc -> IO [Tree String]
    const selectedRowsForest = doc => {
    // Forest of currently selected rows.
        const
            rows = doc.rows.where({
                _and: [
                    {selected: true},
                    {_not: [{
                        name: ""
                    }]}
                ]
            });

        return forestFromIndentedLines(
            zip(
                rows.level()
            )(
                rows.name()
            )
        );
    };


    // treeSum :: (Float, {text::String}) -> Float
    const treeSum = (a, tree) =>
        // Float accumulator updated by any
        // numeric node texts in the tree.
        a + foldTree(
            x => ns => ns.reduce(add, 0) + (
                parseFloat(x.text, 10) || 0
            )
        )(tree);


    // visibleSubTreeOfRow :: Bike Row -> IO Tree String
    const visibleSubTreeOfRow = row => {
    // Tree of the given row and all its
    // visible descendants.
        const go = r =>
            Node(
                {text: r.name()}
            )(
                r.containsRows() ? (
                    r.rows.where({
                        visible: true
                    })().map(go)
                ) : []
            );

        return go(row);
    };


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


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


    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
    // Constructor for a Tree node which connects a
    // value of some kind to a list of zero or
    // more child trees.
        xs => ({
            type: "Node",
            root: v,
            nest: xs || []
        });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
    // A pair of values, possibly of
    // different types.
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


    // add (+) :: Num a => (a, a) -> a
    const add = (a, b) =>
    // Curried addition.
        a + b;


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


    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f => {
    // The catamorphism on trees. A summary
    // value obtained by a depth-first fold.
        const go = tree => f(
            root(tree)
        )(
            nest(tree).map(go)
        );

        return go;
    };


    // nest :: Tree a -> [a]
    const nest = tree => {
    // Allowing for lazy (on-demand) evaluation.
    // If the nest turns out to be a function –
    // rather than a list – that function is applied
    // here to the root, and returns a list.
        const xs = tree.nest;

        return "function" !== typeof xs ? (
            xs
        ) : xs(root(tree));
    };


    // root :: Tree a -> a
    const root = tree =>
    // The value attached to a tree node.
        tree.root;


    // span :: (a -> Bool) -> [a] -> ([a], [a])
    const span = p =>
    // Longest prefix of xs consisting of elements which
    // all satisfy p, tupled with the remainder of xs.
        xs => {
            const i = xs.findIndex(x => !p(x));

            return -1 !== i ? (
                Tuple(xs.slice(0, i))(
                    xs.slice(i)
                )
            ) : Tuple(xs)([]);
        };


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
    // The paired members of xs and ys, up to
    // the length of the shorter of the two lists.
        ys => Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => [xs[i], ys[i]]);


    // MAIN ---
    return main();
})();

To test in Script Editor, set the language selector at top level to JavaScript (rather than AppleScript).

See: Using Scripts - Bike


PS make sure that you copy the whole of the JS source text behind the disclosure triangle above, right down to the last lines, which look like:

    // MAIN ---
    return main();
})();
3 Likes

Updated above (Ver 0.02) to properly exclude any rows which are hidden by folding.


Keyboard Maestro version, bound by default to ^= on the keyboard:

1 Like

FWIW a variant illustrative sketch which:

  • Gathers a list of number values in all descendant leaves (no further rows indented beneath them), but ignores any parent rows.
  • derives values for mean, median, mode and standard deviation (as well as sum and sample size)
  • Copies and displays the result as a JSON record.

Copies and displays:

{
  "sampleSize": 10,
  "sampleSum": 605,
  "sampleMinimum": 49,
  "sampleMaximum": 76,
  "sampleMean": 60.5,
  "sampleMedian": 62.5,
  "sampleMode": [
    51
  ],
  "standardDeviation": 9.036038955205981
}

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

    // Simple stats:
    //  Mean, median, mode, standard deviation
    //  as well as sum and sample size

    // Calculated from the list of all childless (numeric)
    // LEAF nodes that descend from the selected row(s).

    ObjC.import("AppKit");

    // Arithemic summaries of the numeric rows
    // in the extended selection,
    //
    // OR
    //  (if the selection is not extended)
    //  Sum of all visible numeric rows descending
    //  from the current line.

    // This rough sketch aims to obtain
    // size, sum, mean, median, mode,
    // and standard deviation of the numeric rows that
    // are visible (not hidden by folding) in the
    // selected rows, or the subtree of the current row.

    // Rob Trew @2022
    // Ver 0.01

    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return either(
            alert("Summary of numbers in Bike")
        )(
            compose(
                alert("Copied to clipboard"),
                copyText,
                showJSON,
                simpleStats
            )
        )(
            doc.exists() ? Right(
                (
                    Boolean(doc.selectedText()) ? (
                        selectedRowsForest(doc)
                    ) : [
                        visibleSubTreeOfRow(
                            doc.selectionRow()
                        )
                    ]
                )
                .reduce(leafNumbers, [])
            ) : Left("No document open in Bike.")
        );
    };


    // simpleStats :: [Num] -> Dict
    const simpleStats = xs => {
        const
            ns = sort(xs),
            sampleSize = ns.length,
            sampleSum = sum(ns),
            sampleMean = sampleSum / sampleSize,
            deviations = ns.map(n => (n - sampleMean) ** 2);

        return {
            sampleSize,
            sampleSum,
            sampleMinimum: Math.min(...ns),
            sampleMaximum: Math.max(...ns),
            sampleMean,
            sampleMedian: median(ns),
            sampleMode: mode(ns),
            standardDeviation: Math.sqrt(
                sum(deviations) / sampleSize
            )
        };
    };


    // ---------------------- BIKE -----------------------

    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree {text:String, body:Int}]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const
                    [depth, body] = xs[0],
                    [tree, rest] = span(x => depth < x[0])(
                        xs.slice(1)
                    );

                return [
                    Node({
                        text: body,
                        level: depth
                    })(go(tree))
                ]
                .concat(go(rest));
            })() : [];

        return go(tuples);
    };


    // selectedRowsForest :: Bike Doc -> IO [Tree String]
    const selectedRowsForest = doc => {
    // Forest of currently selected rows.
        const
            rows = doc.rows.where({
                _and: [
                    {selected: true},
                    {_not: [{
                        name: ""
                    }]}
                ]
            });

        return forestFromIndentedLines(
            zip(
                rows.level()
            )(
                rows.name()
            )
        );
    };


    // - ACCUMULATION OF ALL NUMERIC VALUES IN A SUBTREE -
    //   (not used in the default example above,
    //    see, instead, the *leafNumbers* function below)

    // treeNumbers :: ([Num], Tree String) -> [Num]
    const treeNumbers = (ns, tree) =>
        // An accumulation of numeric values, updated by
        // any numeric strings in the tree node and its
        // descendants.
        [
            ...ns,
            ...foldTree(x => vs => {
                const
                    maybeN = parseFloat(x.text, 10),
                    sofar = vs.flat();

                return isNaN(maybeN) ? (
                    sofar
                ) : [maybeN, ...sofar];
            })(tree)
        ];


    // ---- ACCUMULATION ONLY OF THE SUBTREE *LEAVES* ----

    // leafNumbers :: ([Num], Tree String) -> [Num]
    const leafNumbers = (ns, tree) =>
    // An accumulation of numeric values, updated by
    // any numeric strings in the **leaves**
    // descending from the given tree node.
        [
            ...ns,
            ...foldTree(
                x => vs => 0 === vs.length ? (() => {
                    const maybeN = parseFloat(x.text, 10);

                    return isNaN(maybeN) ? (
                        []
                    ) : [maybeN];
                })() : vs.flat()
            )(tree)
        ];


    // visibleSubTreeOfRow :: Bike Row -> IO Tree String
    const visibleSubTreeOfRow = row => {
    // Tree of the given row and all its
    // visible descendants.
        const go = r =>
            Node(
                {text: r.name()}
            )(
                r.containsRows() ? (
                    r.rows.where({visible: true})()
                    .map(go)
                ) : []
            );

        return go(row);
    };


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


    // copyText :: String -> IO String
    const copyText = s => {
        const pb = $.NSPasteboard.generalPasteboard;

        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };


    // --------------------- GENERIC ---------------------

    // Just :: a -> Maybe a
    const Just = x => ({
        type: "Maybe",
        Just: x
    });


    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Nothing :: Maybe a
    const Nothing = () => ({
        type: "Maybe",
        Nothing: true
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
    // Constructor for a Tree node which connects a
    // value of some kind to a list of zero or
    // more child trees.
        xs => ({
            type: "Node",
            root: v,
            nest: xs || []
        });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
    // A pair of values, possibly of
    // different types.
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


    // add (+) :: Num a => a -> a -> a
    const add = a =>
    // Curried addition.
        b => a + b;


    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = mb =>
    // Nothing if mb is Nothing, or the application of the
    // (a -> Maybe b) function mf to the contents of mb.
        mf => mb.Nothing ? (
            mb
        ) : mf(mb.Just);


    // comparing :: Ord a => (b -> a) -> b -> b -> Ordering
    const comparing = f =>
    // The ordering of f(x) and f(y) as a value
    // drawn from {-1, 0, 1}, representing {LT, EQ, GT}.
        x => y => {
            const
                a = f(x),
                b = f(y);

            return a < b ? -1 : (a > b ? 1 : 0);
        };


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


    // even :: Int -> Bool
    const even = n =>
    // True if 2 is a factor of n.
        0 === n % 2;


    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f => {
    // The catamorphism on trees. A summary
    // value obtained by a depth-first fold.
        const go = tree => f(
            root(tree)
        )(
            nest(tree).map(go)
        );

        return go;
    };

    // insertWith :: Ord k => (a -> a -> a) ->
    // k -> a -> Map k a -> Map k a
    const insertWith = f =>
    // A new dictionary updated with a (k, f(v)(x)) pair.
    // Where there is no existing v for k, the supplied
    // x is used directly.
        k => x => dict => Object.assign({},
            dict, {
                [k]: k in dict ? (
                    f(dict[k])(x)
                ) : x
            });


    // maximumByMay :: (a -> a -> Ordering) ->
    // [a] -> Maybe a
    const maximumByMay = f =>
    // Nothing, if the list is empty,
    // or just the maximum value when compared
    // in terms of f.
        xs => Boolean(xs.length) ? (
            Just(xs.slice(1).reduce(
                (a, x) => 0 < f(a)(x) ? (
                    a
                ) : x,
                xs[0]
            ))
        ) : Nothing();


    // maybe :: b -> (a -> b) -> Maybe a -> b
    const maybe = v =>
    // Default value (v) if m is Nothing, or f(m.Just)
        f => m => "Just" in m ? (
            f(m.Just)
        ) : v;

    // median :: [Num] -> Num
    const median = ns => {
    // The ordinally middle value in the sorted list,
    // or the mean of two mid values if the length of
    // the list is even.
        const sampleSize = ns.length;

        return 0 < sampleSize ? (
            1 < sampleSize ? (() => {
                const i = Math.floor(sampleSize / 2);

                return even(sampleSize) ? (
                    (ns[i - 1] + ns[i]) / 2
                ) : ns[i];
            })() : ns[0]
        ) : null;
    };


    // mode :: [Num] -> Maybe [Num]
    const mode = ns =>
    // Either null, if the list is empty, or is of
    // length > 1 and no value appears more than once,
    // or a list containing the one or more most
    // frequently occurring values in the sample.
        1 === ns.length ? (
            [ns[0]]
        ) : (() => {
            const kvs = Object.entries(valueCounts(ns));

            return maybe(null)(x => x)(
                bindMay(
                    maximumByMay(comparing(snd))(kvs)
                )(
                    ([, v]) => 1 < v ? Just(
                        kvs.flatMap(
                            kv => v === kv[1] ? (
                                [JSON.parse(kv[0])]
                            ) : []
                        )
                    ) : Nothing()
                )
            );
        })();


    // nest :: Tree a -> [a]
    const nest = tree => {
    // Allowing for lazy (on-demand) evaluation.
    // If the nest turns out to be a function –
    // rather than a list – that function is applied
    // here to the root, and returns a list.
        const xs = tree.nest;

        return "function" !== typeof xs ? (
            xs
        ) : xs(root(tree));
    };


    // root :: Tree a -> a
    const root = tree =>
    // The value attached to a tree node.
        tree.root;


    // valueCounts :: Num -> Dict String Int
    const valueCounts = vs =>
    // Dictionary of chars, with the
    // frequency of each in cs.
        [...vs].reduce(
            (a, v) => insertWith(add)(v)(
                1
            )(a), {}
        );


    // showJSON :: a -> String
    const showJSON = x =>
    // Indented JSON representation of the value x.
        JSON.stringify(x, null, 2);


    // snd :: (a, b) -> b
    const snd = tpl =>
    // Second member of a pair.
        tpl[1];


    // sum :: [Num] -> Num
    const sum = xs =>
    // The numeric sum of all values in xs.
        xs.reduce((a, x) => a + x, 0);


    // sort :: Ord a => [a] -> [a]
    const sort = xs =>
    // An A-Z sorted copy of xs.
        xs.slice().sort(
            (a, b) => a < b ? (
                -1
            ) : (
                a > b ? (
                    1
                ) : 0
            )
        );

    // span :: (a -> Bool) -> [a] -> ([a], [a])
    const span = p =>
    // Longest prefix of xs consisting of elements which
    // all satisfy p, tupled with the remainder of xs.
        xs => {
            const i = xs.findIndex(x => !p(x));

            return -1 !== i ? (
                Tuple(xs.slice(0, i))(
                    xs.slice(i)
                )
            ) : Tuple(xs)([]);
        };


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
    // The paired members of xs and ys, up to
    // the length of the shorter of the two lists.
        ys => Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => [xs[i], ys[i]]);


    // MAIN ---
    return main();
})();

2 Likes