Sum and running totals of any numbers in selected text lines

A variation on the theme of summing numbers in text copied to clipboard.

This version aims to:

  • copy selected lines
  • ignore non-numeric content in each line, focusing on the first space-delimited numeric string (if any)
  • place a sum total value in a clipSumTotal variable
  • generate and optionally display a simple report with running totals

e.g. from selected lines, including some numbers like:

Inbox:
    Alpha 21
    Beta  35.1
    Gamma 41 68 67 91
Working:
    Delta
    Epsilon 1015
    Zeta 16
    71.2 Eta
    18.5 Theta
    Iota
  • The variable clipSumTotal would contain the value 1443.80, and
  • the report sheet would be:
Lines                       Value    Running total

Inbox:                       0.00       0.00
    Alpha 21           ->   21.00      21.00
    Beta  35.1         ->   35.10      56.10
    Gamma 41 68 67 91  ->  267.00     323.10
Working:                     0.00     323.10
    Delta                    0.00     323.10
    Epsilon 1015       -> 1015.00    1338.10
    Zeta 16            ->   16.00    1354.10
    71.2 Eta           ->   71.20    1425.30
    18.5 Theta         ->   18.50    1443.80
    Iota                     0.00    1443.80

                           Total:    1443.80

Sum and running totals of any numbers in selected text lines.kmmacros (13 KB)

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

    ObjC.import("AppKit");

    // Rob Trew @2021
    // MIT License

    // Ver 0.06 
	//     - aims to be locale independent, and
	//     - read more than one number per line.

    // Summation of any numbers in clipboard (ignoring text)
    // with running totals.
    // Only first number (if any) in each line is used.

    // Return value is a report with running totals.
    // Sum total is stored in KMVar "clipSumTotal"

    // main :: IO ()
    const main = () =>
        either(
            alert("Sum of numbers in clipboard")
        )(
            report => report
        )(
            bindLR(
                clipTextLR()
            )(
                clip => Right(
                    apFn(summarySheet)(
                        valuesAndTotalsFromLines
                    )(
                        lines(
                            clip.replace(/\t/ug, "    ")
                        )
                    )
                )
            )
        );

    // ---------------- PARSED AND SUMMED ----------------
    // valuesAndTotalsFromLines :: [String] -> (Float, Float)
    const valuesAndTotalsFromLines = xs => {
        // A list of (lineValue, runningTotal) pairs
        // derived from a list of lines.
        const
            locale = $.NSLocale.currentLocale,
            [fmtrFloat, fmtrCurrency] = [
                $.NSNumberFormatterDecimalStyle,
                $.NSNumberFormatterCurrencyAccountingStyle
            ].map(numberStyle => {
                const
                    fmtr = $.NSNumberFormatter.alloc.init;

                return (
                    fmtr.locale = locale,
                    fmtr.numberStyle = numberStyle,
                    fmtr
                );
            }),
            parseLocaleNum = s => (
                fmtrFloat.numberFromString(s)
                .doubleValue || (
                    fmtrCurrency.numberFromString(s)
                    .doubleValue
                )
            );

        return scanl(a => x => {
            const
                n = words(x).reduce(
                    (m, w) => Boolean(w) ? (
                        m + (parseLocaleNum(w) || 0)
                    ) : m,
                    0
                );

            return Tuple(n)(a[1] + n);
        })(
            Tuple("")(0)
        )(xs).slice(1);
    };

    // ------------------- FORMATTING --------------------

    // alignPoint :: String -> Int -> Int -> Float -> String
    const alignPoint = decimalMark =>
        // A string rendering of a floating point number
        // aligned on the decimal point allowing lw spaces
        // to the left, and rw spaces to the right.
        lw => rw => n => {
            const
                ab = intFracStrings(n),
                leftPart = justifyRight(lw)(" ")(ab[0]),
                rightPart = justifyLeft(rw)(" ")(ab[1]);

            return `${leftPart}${decimalMark}${rightPart}`;
        };


    // intFracStrings :: Float -> (String, String)
    const intFracStrings = n => {
        const ab = `${n.toFixed(2)}`.split(".");

        return Tuple(ab[0])(ab[1] || "");
    };


    // partWidths :: [(String, String)] -> (Int, Int)
    const partWidths = abs => [fst, snd].map(
        f => maximum(abs.map(x => f(x).length))
    );


    // summarySheet :: [String] ->
    //     [(Float, Float)] -> String
    // eslint-disable-next-line max-lines-per-function
    const summarySheet = xs => runningTotals => {
        const
            kValues = "Value",
            kTotals = "Running total",
            decimalMark = ObjC.unwrap(
                $.NSLocale.currentLocale
                .decimalSeparator
            ),
            aligned = alignPoint(decimalMark),
            maxLineWidth = maximum(xs.map(x => x.length)),
            [
                [nlw, nrw],
                [tlw, trw]
            ] = apList([partWidths])([fst, snd].map(
                f => runningTotals.map(
                    compose(
                        intFracStrings,
                        f
                    )
                )
            )),
            nWidth = Math.max(
                kValues.length, nlw + nrw + 2
            ),
            lblLine = justifyLeft(maxLineWidth)(" ")(
                "Lines"
            ),
            lblValue = justifyRight(4 + nWidth)(" ")(kValues),
            hdr = `${lblLine}${lblValue}    ${kTotals}`,
            sumTotal = aligned(tlw)(trw)(
                0 < runningTotals.length ? (
                    last(runningTotals)[1]
                ) : 0
            ),
            kv = `Total:    ${sumTotal}`,
            ftr = justifyRight(
                maxLineWidth + 11 + nlw + nrw + tlw + trw
            )(" ")(kv);

        const
            rows = zipWith(s => ab => {
                const
                    txt = justifyLeft(maxLineWidth)(" ")(s),
                    n = `${aligned(nlw)(nrw)(ab[0])}`,
                    sofar = `${aligned(tlw)(trw)(ab[1])}`,
                    arrow = ab[0] ? (
                        " -> "
                    ) : "    ";

                return `${txt} ${arrow}${n}    ${sofar}`;
            })(xs)(runningTotals)
            .join("\n");

        return (
            Application("Keyboard Maestro Engine")
            .setvariable("clipSumTotal", {
                to: sumTotal
            }),
            `${hdr}\n\n${rows}\n\n${ftr}`
        );
    };


    // ----------------------- 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 = () => (
        v => Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.")
    )(
        ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString))
    );

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


    // apFn :: (a -> b -> c) -> (a -> b) -> (a -> c)
    const apFn = f =>
        // Applicative instance for functions.
        // f(x) applied to g(x).
        g => x => f(x)(
            g(x)
        );


    // apList (<*>) :: [(a -> b)] -> [a] -> [b]
    const apList = fs =>
        // The sequential application of each of a list
        // of functions to each of a list of values.
        // apList([x => 2 * x, x => 20 + x])([1, 2, 3])
        //     -> [2, 4, 6, 21, 22, 23]
        xs => fs.flatMap(f => xs.map(f));


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


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


    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];


    // justifyLeft :: Int -> Char -> String -> String
    const justifyLeft = n =>
        // The string s, followed by enough padding (with
        // the character c) to reach the string length n.
        c => s => n > s.length ? (
            s.padEnd(n, c)
        ) : s;


    // justifyRight :: Int -> Char -> String -> String
    const justifyRight = n =>
        // The string s, preceded by enough padding (with
        // the character c) to reach the string length n.
        c => s => Boolean(s) ? (
            s.padStart(n, c)
        ) : "";


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : null;


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


    // maximum :: Ord a => [a] -> a
    const maximum = xs => (
        // The largest value in a non-empty list.
        ys => 0 < ys.length ? (
            ys.slice(1).reduce(
                (a, y) => y > a ? (
                    y
                ) : a, ys[0]
            )
        ) : undefined
    )(xs);


    // scanl :: (b -> a -> b) -> b -> [a] -> [b]
    const scanl = f =>
        // The series of interim values arising
        // from a catamorphism. Parallel to foldl.
        startValue => xs => xs.reduce((a, x) => {
            const v = f(a[0])(x);

            return [v, a[1].concat(v)];
        }, [startValue, [startValue]])[1];


    // words :: String -> [String]
    const words = s =>
        // List of space-delimited sub-strings.
        s.split(/\s+/u);


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


    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

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

UPDATE

With the help of @JimmyHartington, this has now been updated:

  • to be more locale-independent (should, I think, allow for simple currency values, as well as variations in the character used to delimit decimal values)
  • and to allow for reading several numeric values from one line.
3 Likes

Hi

Nice one.

In Denmark we use the "," (comma) as decimal separator.
Where do I need to change the script to work with these numbers?

Good question, and I should look into detection of the separator used in the active locale.

In the meanwhile:

  • in the alignPoint function the return line -> return ${leftPart},${rightPart}; (i.e. a comma between leftPart and rightPart, in lieu of a dot
  • I think that JavaScript's isNaN predicate should be locale-aware, so I hope you won't have to change anything else, but do let me know ...

It now returns with comma. But misses numbers with comma. See 78,50 Eta

Inbox:              0,00      0,00
    Alpha 34   ->  34,00     34,00
    Beta 45    ->  45,00     79,00
    Gamma 67   ->  67,00    146,00
                    0,00    146,00
Working:            0,00    146,00
    Delta           0,00    146,00
    Epsilon         0,00    146,00
    Zeta            0,00    146,00
                    0,00    146,00
    78,50 Eta       0,00    146,00
    400 Theta  -> 400,00    546,00
    Iotal           0,00    546,00

                  TOTAL    546,00

Thanks, that's very helpful – I'll look up more locale-sensitive recognition of number strings by JS

In the meanwhile, does this seem to return a comma on your system:

(() => {
    "use strict";

    ObjC.import("AppKit");

    return ObjC.unwrap(
        $.NSLocale.currentLocale.decimalSeparator
    );
})();

Yes it does.

Progress : -)

and does this seem to return a numeric value on your system:

(() => {
    "use strict";

    ObjC.import("AppKit");

    const main = () =>
        parseLocaleNum("78,5");


    // --------- LOCALE-SPECIFIC NUMBER PARSING ----------

    // parseLocaleNum :: String -> (Num | NaN)
    const parseLocaleNum = s =>
        parseLocaleFloat(s) || parseLocaleCurrency(s);


    // parseLocaleCurrency :: String -> Float
    const parseLocaleCurrency = s =>
        // ObjC.import('AppKit')
        parseLocaleNumeric(
            $.NSNumberFormatterCurrencyAccountingStyle
        )(s);


    // parseLocaleFloat :: String -> Float
    const parseLocaleFloat = s =>
        // ObjC.import('AppKit')
        parseLocaleNumeric(
            $.NSNumberFormatterDecimalStyle
        )(s);


    // parseLocaleNumeric :: NSNumberFormatterStyle -> 
    // String -> (Num | NaN)
    const parseLocaleNumeric = nsNumStyle =>
        s => {
            const formatter = $.NSNumberFormatter.alloc.init;

            return (
                formatter.locale = $.NSLocale.currentLocale,
                formatter.numberStyle = nsNumStyle,
                formatter.numberFromString(s).doubleValue
            );
        };

    return main();
})();

It returns this "78.5". With a period.

And this is the settings from System Preferences. It is in Danish. But you can see that the decimal is set to a comma.

Thanks ! It looks like I need to sit down with this for a moment and experiment. That will probably have to be Sunday now ...

And I say thank you. This is no rush for me. And for that matter not required by you to make it work for all locales.

1 Like

Last experiment before the weekend, if you have time – what do you get if you change the main() function in the second piece of code to:

const main = () =>
        typeof parseLocaleNum("78,5");

I'm wondering if it's giving us a Float value which JS then parochially represents with a "." or whether it's giving us a string value.

I get the output "number"

1 Like

First draft of Ver 0.04, aiming for locale-independence:

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

    ObjC.import("AppKit");

    // Ver 0.04 draft attempt at locale independence.

    // Summation of any numbers in clipboard (ignoring text)
    // with running totals.
    // Only first number (if any) in each line is used.

    // Return value is a report with running totals.
    // Sum total is stored in KMVar "clipSumTotal"

    // main :: IO ()
    const main = () =>
        either(
            alert("Sum of numbers in clipboard")
        )(
            report => report
        )(
            bindLR(
                clipTextLR()
            )(
                clip => Right(
                    apFn(summarySheet)(
                        valuesAndTotalsFromLines
                    )(
                        lines(clip.replace(
                            /\t/ug, "    "
                        ))
                    )
                )
            )
        );

    // ---------------- PARSED AND SUMMED ----------------
    const valuesAndTotalsFromLines = xs => {
        const
            locale = $.NSLocale.currentLocale,
            [fmtrFloat, fmtrCurrency] = [
                $.NSNumberFormatterDecimalStyle,
                $.NSNumberFormatterCurrencyAccountingStyle
            ].map(numberStyle => {
                const
                    fmtr = $.NSNumberFormatter.alloc.init;

                return (
                    fmtr.locale = locale,
                    fmtr.numberStyle = numberStyle,
                    fmtr
                );
            }),
            parseLocaleNum = s => (
                fmtrFloat.numberFromString(s)
                .doubleValue || (
                    fmtrCurrency.numberFromString(s)
                    .doubleValue
                )
            );

        return scanl(a => x => {
            const
                maybeNums = words(x).flatMap(
                    w => Boolean(w) ? (
                        parseLocaleNum(w)
                    ) : []
                ),
                indexOfFirstNumber = maybeNums.findIndex(
                    maybeNum => !isNaN(maybeNum)
                ),
                n = -1 !== indexOfFirstNumber ? (
                    maybeNums[indexOfFirstNumber]
                ) : 0;

            return Tuple(n)(a[1] + n);
        })(
            Tuple("")(0)
        )(xs).slice(1);
    };

    // ------------------- FORMATTING --------------------

    // alignPoint :: String -> Int -> Int -> Float -> String
    const alignPoint = decimalMark =>
        // A string rendering of a floating point number
        // aligned on the decimal point allowing lw spaces
        // to the left, and rw spaces to the right.
        lw => rw => n => {
            const
                ab = intFracStrings(decimalMark)(n),
                leftPart = justifyRight(lw)(" ")(ab[0]),
                rightPart = justifyLeft(rw)(" ")(ab[1]);

            return `${leftPart}${decimalMark}${rightPart}`;
        };


    // intFracStrings :: Float -> (String, String)
    const intFracStrings = decimalMark =>
        n => {
            const
                ab = `${n.toFixed(2)}`.split(
                    decimalMark
                );

            return Tuple(ab[0])(ab[1] || "");
        };


    // partWidths :: [(String, String)] -> (Int, Int)
    const partWidths = abs => [fst, snd].map(
        f => maximum(abs.map(x => f(x).length))
    );


    // summarySheet :: [String] ->
    //     [(Float, Float)] -> String
    // eslint-disable-next-line max-lines-per-function
    const summarySheet = xs => runningTotals => {
        const
            kValues = "Value",
            kTotals = "Running total",
            decimalMark = ObjC.unwrap(
                $.NSLocale.currentLocale
                .decimalSeparator
            ),
            aligned = alignPoint(decimalMark),
            maxLineWidth = maximum(xs.map(x => x.length)),
            [
                [nlw, nrw],
                [tlw, trw]
            ] = apList([partWidths])([fst, snd].map(
                f => runningTotals.map(
                    compose(
                        intFracStrings(decimalMark),
                        f
                    )
                )
            )),

            nWidth = 1 + Math.max(
                kValues.length, nlw + nrw
            ),
            totalsWidth = 1 + Math.max(
                kTotals.length, tlw + trw
            ),
            lblLine = justifyLeft(maxLineWidth)(" ")(
                "Lines"
            ),
            lblValue = justifyLeft(nWidth)(" ")(kValues),
            lblSum = justifyLeft(totalsWidth)(" ")(
                kTotals
            ),
            hdr = `${lblLine}     ${lblValue}    ${lblSum}`,
            ftr = justifyRight(
                maxLineWidth + nWidth + 4
            )(" ")("    TOTAL"),
            sumTotal = aligned(tlw)(trw)(
                0 < runningTotals.length ? (
                    last(runningTotals)[1]
                ) : 0
            );

        const
            rows = zipWith(s => ab => {
                const
                    txt = justifyLeft(maxLineWidth)(" ")(s),
                    n = `${aligned(nlw)(nrw)(ab[0])}`,
                    sofar = `${aligned(tlw)(trw)(ab[1])}`,
                    arrow = ab[0] ? (
                        " -> "
                    ) : "    ";

                return `${txt} ${arrow}${n}    ${sofar}`;
            })(xs)(runningTotals)
            .join("\n");

        return (
            Application("Keyboard Maestro Engine")
            .setvariable("clipSumTotal", {
                to: sumTotal
            }),
            `${hdr}\n\n${rows}\n\n${ftr}    ${sumTotal}`
        );
    };


    // ----------------------- 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 = () => (
        v => Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.")
    )(
        ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString))
    );

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


    // apFn :: (a -> b -> c) -> (a -> b) -> (a -> c)
    const apFn = f =>
        // Applicative instance for functions.
        // f(x) applied to g(x).
        g => x => f(x)(
            g(x)
        );


    // apList (<*>) :: [(a -> b)] -> [a] -> [b]
    const apList = fs =>
        // The sequential application of each of a list
        // of functions to each of a list of values.
        // apList([x => 2 * x, x => 20 + x])([1, 2, 3])
        //     -> [2, 4, 6, 21, 22, 23]
        xs => fs.flatMap(f => xs.map(f));


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


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


    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];


    // justifyLeft :: Int -> Char -> String -> String
    const justifyLeft = n =>
        // The string s, followed by enough padding (with
        // the character c) to reach the string length n.
        c => s => n > s.length ? (
            s.padEnd(n, c)
        ) : s;


    // justifyRight :: Int -> Char -> String -> String
    const justifyRight = n =>
        // The string s, preceded by enough padding (with
        // the character c) to reach the string length n.
        c => s => Boolean(s) ? (
            s.padStart(n, c)
        ) : "";


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : null;


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


    // maximum :: Ord a => [a] -> a
    const maximum = xs => (
        // The largest value in a non-empty list.
        ys => 0 < ys.length ? (
            ys.slice(1).reduce(
                (a, y) => y > a ? (
                    y
                ) : a, ys[0]
            )
        ) : undefined
    )(xs);


    // scanl :: (b -> a -> b) -> b -> [a] -> [b]
    const scanl = f =>
        // The series of interim values arising
        // from a catamorphism. Parallel to foldl.
        startValue => xs => xs.reduce((a, x) => {
            const v = f(a[0])(x);

            return [v, a[1].concat(v)];
        }, [startValue, [startValue]])[1];


    // words :: String -> [String]
    const words = s =>
        // List of space-delimited sub-strings.
        s.split(/\s+/u);


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


    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

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

It works and does the sum.
But in the result the decimal delimeter is still period and there is a comma after the number.

Lines             Value      Running total 

Inbox:              0.00,      0.00,
    Alpha 34   ->  34.00,     34.00,
    Beta 45    ->  45.00,     79.00,
    Gamma 67   ->  67.00,    146.00,
                    0.00,    146.00,
Working:            0.00,    146.00,
    Delta           0.00,    146.00,
    Epsilon         0.00,    146.00,
    Zeta            0.00,    146.00,
                    0.00,    146.00,
    78,50 Eta  ->  78.50,    224.50,
    400 Theta  -> 400.00,    624.50,
    Iotal           0.00,    624.50,

                   TOTAL    624.50,
1 Like

Ver 0.05

(I think Ver 0.04 may have assumed that JS itself works with "," when the locale is Dansk, but it seems not)

Does this work better on your system ?

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

    ObjC.import("AppKit");

    // Ver 0.05 draft attempt at locale independence.

    // Summation of any numbers in clipboard (ignoring text)
    // with running totals.
    // Only first number (if any) in each line is used.

    // Return value is a report with running totals.
    // Sum total is stored in KMVar "clipSumTotal"

    // main :: IO ()
    const main = () =>
        either(
            alert("Sum of numbers in clipboard")
        )(
            report => report
        )(
            bindLR(
                clipTextLR()
            )(
                clip => Right(
                    apFn(summarySheet)(
                        valuesAndTotalsFromLines
                    )(
                        lines(clip.replace(
                            /\t/ug, "    "
                        ))
                    )
                )
            )
        );

    // ---------------- PARSED AND SUMMED ----------------
    const valuesAndTotalsFromLines = xs => {
        const
            locale = $.NSLocale.currentLocale,
            [fmtrFloat, fmtrCurrency] = [
                $.NSNumberFormatterDecimalStyle,
                $.NSNumberFormatterCurrencyAccountingStyle
            ].map(numberStyle => {
                const
                    fmtr = $.NSNumberFormatter.alloc.init;

                return (
                    fmtr.locale = locale,
                    fmtr.numberStyle = numberStyle,
                    fmtr
                );
            }),
            parseLocaleNum = s => (
                fmtrFloat.numberFromString(s)
                .doubleValue || (
                    fmtrCurrency.numberFromString(s)
                    .doubleValue
                )
            );

        return scanl(a => x => {
            const
                maybeNums = words(x).flatMap(
                    w => Boolean(w) ? (
                        parseLocaleNum(w)
                    ) : []
                ),
                indexOfFirstNumber = maybeNums.findIndex(
                    maybeNum => !isNaN(maybeNum)
                ),
                n = -1 !== indexOfFirstNumber ? (
                    maybeNums[indexOfFirstNumber]
                ) : 0;

            return Tuple(n)(a[1] + n);
        })(
            Tuple("")(0)
        )(xs).slice(1);
    };

    // ------------------- FORMATTING --------------------

    // alignPoint :: String -> Int -> Int -> Float -> String
    const alignPoint = decimalMark =>
        // A string rendering of a floating point number
        // aligned on the decimal point allowing lw spaces
        // to the left, and rw spaces to the right.
        lw => rw => n => {
            const
                ab = intFracStrings(n),
                leftPart = justifyRight(lw)(" ")(ab[0]),
                rightPart = justifyLeft(rw)(" ")(ab[1]);

            return `${leftPart}${decimalMark}${rightPart}`;
        };


    // intFracStrings :: Float -> (String, String)
    const intFracStrings = n => {
            const
                ab = `${n.toFixed(2)}`.split(".");

            return Tuple(ab[0])(ab[1] || "");
        };


    // partWidths :: [(String, String)] -> (Int, Int)
    const partWidths = abs => [fst, snd].map(
        f => maximum(abs.map(x => f(x).length))
    );


    // summarySheet :: [String] ->
    //     [(Float, Float)] -> String
    // eslint-disable-next-line max-lines-per-function
    const summarySheet = xs => runningTotals => {
        const
            kValues = "Value",
            kTotals = "Running total",
            decimalMark = ObjC.unwrap(
                $.NSLocale.currentLocale
                .decimalSeparator
            ),
            aligned = alignPoint(decimalMark),
            maxLineWidth = maximum(xs.map(x => x.length)),
            [
                [nlw, nrw],
                [tlw, trw]
            ] = apList([partWidths])([fst, snd].map(
                f => runningTotals.map(
                    compose(
                        intFracStrings,
                        f
                    )
                )
            )),

            nWidth = 1 + Math.max(
                kValues.length, nlw + nrw
            ),
            totalsWidth = 1 + Math.max(
                kTotals.length, tlw + trw
            ),
            lblLine = justifyLeft(maxLineWidth)(" ")(
                "Lines"
            ),
            lblValue = justifyLeft(nWidth)(" ")(kValues),
            lblSum = justifyLeft(totalsWidth)(" ")(
                kTotals
            ),
            hdr = `${lblLine}     ${lblValue}    ${lblSum}`,
            ftr = justifyRight(
                maxLineWidth + nWidth + 4
            )(" ")("    TOTAL"),
            sumTotal = aligned(tlw)(trw)(
                0 < runningTotals.length ? (
                    last(runningTotals)[1]
                ) : 0
            );

        const
            rows = zipWith(s => ab => {
                const
                    txt = justifyLeft(maxLineWidth)(" ")(s),
                    n = `${aligned(nlw)(nrw)(ab[0])}`,
                    sofar = `${aligned(tlw)(trw)(ab[1])}`,
                    arrow = ab[0] ? (
                        " -> "
                    ) : "    ";

                return `${txt} ${arrow}${n}    ${sofar}`;
            })(xs)(runningTotals)
            .join("\n");

        return (
            Application("Keyboard Maestro Engine")
            .setvariable("clipSumTotal", {
                to: sumTotal
            }),
            `${hdr}\n\n${rows}\n\n${ftr}    ${sumTotal}`
        );
    };


    // ----------------------- 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 = () => (
        v => Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.")
    )(
        ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString))
    );

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


    // apFn :: (a -> b -> c) -> (a -> b) -> (a -> c)
    const apFn = f =>
        // Applicative instance for functions.
        // f(x) applied to g(x).
        g => x => f(x)(
            g(x)
        );


    // apList (<*>) :: [(a -> b)] -> [a] -> [b]
    const apList = fs =>
        // The sequential application of each of a list
        // of functions to each of a list of values.
        // apList([x => 2 * x, x => 20 + x])([1, 2, 3])
        //     -> [2, 4, 6, 21, 22, 23]
        xs => fs.flatMap(f => xs.map(f));


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


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


    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];


    // justifyLeft :: Int -> Char -> String -> String
    const justifyLeft = n =>
        // The string s, followed by enough padding (with
        // the character c) to reach the string length n.
        c => s => n > s.length ? (
            s.padEnd(n, c)
        ) : s;


    // justifyRight :: Int -> Char -> String -> String
    const justifyRight = n =>
        // The string s, preceded by enough padding (with
        // the character c) to reach the string length n.
        c => s => Boolean(s) ? (
            s.padStart(n, c)
        ) : "";


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : null;


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


    // maximum :: Ord a => [a] -> a
    const maximum = xs => (
        // The largest value in a non-empty list.
        ys => 0 < ys.length ? (
            ys.slice(1).reduce(
                (a, y) => y > a ? (
                    y
                ) : a, ys[0]
            )
        ) : undefined
    )(xs);


    // scanl :: (b -> a -> b) -> b -> [a] -> [b]
    const scanl = f =>
        // The series of interim values arising
        // from a catamorphism. Parallel to foldl.
        startValue => xs => xs.reduce((a, x) => {
            const v = f(a[0])(x);

            return [v, a[1].concat(v)];
        }, [startValue, [startValue]])[1];


    // words :: String -> [String]
    const words = s =>
        // List of space-delimited sub-strings.
        s.split(/\s+/u);


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


    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

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

This works perfect on my system.

Does it use the right locale with you?

The only nitpicky detail is that the total is one character of in alignment. :slight_smile:

Lines             Value     Running total 

Inbox:              0,00      0,00
    Alpha 34   ->  34,00     34,00
    Beta 45    ->  45,00     79,00
    Gamma 67   ->  67,00    146,00
                    0,00    146,00
Working:            0,00    146,00
    Delta           0,00    146,00
    Epsilon         0,00    146,00
    Zeta            0,00    146,00
                    0,00    146,00
    78,50 Eta  ->  78,50    224,50
    400 Theta  -> 400,00    624,50
    Iotal           0,00    624,50

                  TOTAL    624,50
1 Like

And if you plan at some point to address multiple numbers pr. line?
See line with Alpha.

Lines                   Value     Running total 

Inbox:                    0,00      0,00
    Alpha 34 and 10  ->  34,00     34,00
    Beta 45          ->  45,00     79,00
    Gamma 67         ->  67,00    146,00
                          0,00    146,00
Working:                  0,00    146,00
    Delta                 0,00    146,00
    Epsilon               0,00    146,00
    Zeta                  0,00    146,00
                          0,00    146,00
    78,50 Eta        ->  78,50    224,50
    400 Theta        -> 400,00    624,50
    Total                 0,00    624,50

                        TOTAL    624,50

Yes, that's working here too on the sinking ex-EU island : -)

Sun going down on the weekend now, but I'll think about alignment and multiple numbers per line in due course, perhaps Sunday.

1 Like

Perfect. You are truly a gifted programmer.
Yeah in the still-in-EU Denmark I am also winding down for the weekend. Kids are eating candy and watching cartoons.

1 Like