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 value1443.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.