As a rough sketch (first draft below) if you prepare some notes which share a prototype,
and select, in order of right -> left display, those which you want to compare,
and that prototype specifies a set of key attributes,
Then one approach would be to use the osascript interface to read off the names and values of these key attributes into rough MMD table markup
| Name | K55 | K250 | K475 |
| ------------: | :--------------: | :----------------: | :----------------: |
| Reviews | ★★★★ | ★★★★ | ★★★★ |
| Pod<br>Compatibility | K-Cup | K-Cup<br>Vue<br>K-Mug<br>K-Carafe | K-Cup<br>K-Mug<br>K-Carafe<br>Vue |
| KCup<br>Compatibility | My KCup<br> Reusable Filter | Keurig 2.0 My KCup<br>Reusable Filter | Keurig 2.0 My KCup<br>Reusable Filter |
| Brew<br>Size | 3 | 0 | 11 |
| Reservoir<br>Capacity | 48 | 0 | 70 |
| Display<br>&<br>Control | No display<br>button controls | Black & White<br>Touchscreen | Color<br>Touchscreen |
| Compact<br>Size | | | |
| Multiple<br>Colors | ✓ | ✓ | ✓ |
| Auto<br>Off | ✓ | | ✓ |
| Auto<br>On | | | ✓ |
| Auto<br>Brew | | | ✓ |
| High<br>Altitude | | ✓ | ✓ |
| Preferred<br>Language | | ✓ | ✓ |
| Favorite<br>Brew | | | ✓ |
| Strength<br>Control | | ✓ | ✓ |
| Temperature<br>Control | | | ✓ |
| Digital<br>Clock | | | ✓ |
| Illuminated<br>Reservoir | | | ✓ |
| Cord<br>Storage | | | ✓ |
For CSS application and rendering (e.g. with Marked 2):
| Name | K55 | K250 | K475 |
|---|---|---|---|
| Reviews | ★★★★ | ★★★★ | ★★★★ |
| Pod Compatibility |
K-Cup | K-Cup Vue K-Mug K-Carafe |
K-Cup K-Mug K-Carafe Vue |
| KCup Compatibility |
My KCup Reusable Filter |
Keurig 2.0 My KCup Reusable Filter |
Keurig 2.0 My KCup Reusable Filter |
| Brew Size |
3 | 0 | 11 |
| Reservoir Capacity |
48 | 0 | 70 |
| Display & Control |
No display button controls |
Black & White Touchscreen |
Color Touchscreen |
| Compact Size |
|||
| Multiple Colors |
✓ | ✓ | ✓ |
| Auto Off |
✓ | ✓ | |
| Auto On |
✓ | ||
| Auto Brew |
✓ | ||
| High Altitude |
✓ | ✓ | |
| Preferred Language |
✓ | ✓ | |
| Favorite Brew |
✓ | ||
| Strength Control |
✓ | ✓ | |
| Temperature Control |
✓ | ||
| Digital Clock |
✓ | ||
| Illuminated Reservoir |
✓ | ||
| Cord Storage |
✓ |
(Tho I notice that the forum software here is not displaying the centering of the data cells)
JavaScript Source
(() => {
'use strict';
ObjC.import('AppKit');
// MMD table source from selected notes which share a prototype
// Rob Trew 2020
// Ver 0.03
// Slightly fuller and more commented mmdTableFromRulerAndRowsLR function.
// (Standalone version - removed library dependency)
// MMD now copied to clipboard, for example for:
// Marked 2 > Preview > Clipboard Preview
const main = () => {
const inner = () => {
const docs = Application('Tinderbox 8').documents;
return either(
alert('Problem')
)(
rows => either(
alert('Unrecognised ruler format')
)(
// Value returned, as well as copied to clipboard.
mmd => mmd
)(
bindLR(
mmdTableFromRulerAndRowsLR([1, 0])(
tail(rows) // Skip 1st in this case (image files)
)
)(compose(Right, copyText))
)
)(
bindLR(
0 < docs.length ? (
Right(docs.at(0))
) : Left('No documents open in Tinderbox 8.')
)(
doc => bindLR(selectedPeerSetLR(doc))(
colNotes => {
const
rows = keyPrototypeAttributesFromNote(doc)(
colNotes[0]
);
return Right(
transpose([
rows.map(camelSplit),
...attributeValueListsFromNotes(rows)(
renderedValue
)(colNotes)
])
);
}
)
)
);
};
// attributeValueListsFromNotes ::
// [String] -> (String -> String) -> [Note] -> [[String]]
const attributeValueListsFromNotes = ks =>
f => notes => notes.map(x => {
const attribs = x.attributes;
return ks.map(k => {
const attrib = attribs.byName(k);
return f(attrib.type())(
k
)(attrib.value());
})
});
// keyPrototypeAttributesFromNote ::
// Document -> Note -> [String]
const keyPrototypeAttributesFromNote = doc =>
note => splitOn(';')(
doc.findNoteIn({
withPath: note.attributeOf({
named: 'Prototype'
}).value()
}).attributeOf({
named: 'KeyAttributes'
}).value()
);
// mmdTableFromRulerAndRowsLR ::
// [LeftCenterRight (-1|0|1)] -> [[String]] -> Either String String
const mmdTableFromRulerAndRowsLR = alignments =>
rows => 0 < rows.length ? (() => {
const
unknownAlignments = alignments.filter(
x => ![-1, 0, 1].includes(x)
);
return 0 < unknownAlignments.length ? (
Left(
'Alignments are drawn from {-1, 0, 1}. Found: ' +
unknownAlignments.toString()
)
) : (() => {
const
cols = transpose(rows),
intCols = cols.length,
// Ruler expanded to full column count if needed,
// using either last specified alignment or
// or centering (0) if the alignment list is empty.
ruler = take(intCols)(
alignments.concat(
replicate(intCols)(
0 < alignments.length ? (
last(alignments)
) : 0
)
)
),
rulerFns = {
'-1': [justifyLeft, x => ':' + x + '-'],
'0': [center, x => ':' + x + ':'],
'1': [justifyRight, x => '-' + x + ':']
};
return Right(unlines(
map(
row => '| ' + row.join(' | ') + ' |'
)(
transpose(zipWithList(
alignment => cells => {
const widest = maximum(
map(cell => cell.includes('<br>') ? (
maximum(map(length)(
splitOn('<br>')(cell)
))
) : cell.length)(cells)
);
return map(
rulerFns[alignment][0](widest)(' ')
)([
// Title line.
cells[0],
// Second row MMD ruler.
rulerFns[alignment][1](
'-'.repeat(widest - 2)
),
// Rest.
...cells.slice(1)
]);
}
)(ruler)(cols)))
));
})()
})() : Left('No rows to tabulate.');
// renderedValue :: String -> String -> String -> String
const renderedValue = strType =>
strName => strValue => ({
'boolean': s => 'true' !== s ? (
''
) : '✓',
'list': s => s.replace(/\;/g, '<br>'),
'number': s => strName !== 'Reviews' ? (
s
) : '★'.repeat(parseInt(s))
} [strType] || identity)(strValue);
// selectedPeerSetLR :: Document -> Selections -> Either String Notes
const selectedPeerSetLR = doc => {
const selns = doc.selections;
return bindLR(
0 < selns.length ? (
Right(selns())
) : Left('Nothing selected in ' + doc.name())
)(notes => {
const
prototypes = notes.map(x => x.attributeOf({
named: 'Prototype'
}).value());
return allSame(prototypes) ? (
Right(notes)
) : Left(
'Selected notes have differing prototypes: ' +
nub(prototypes).join(', ')
)
});
};
// camelSplit :: String -> String
const camelSplit = s =>
splitBy(
a => b => isLower(a) && isUpper(b)
)(s)
.join('<br>')
.replace(/And\</, '&<')
// 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 => {
// String copied to general pasteboard.
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
return inner();
};
// GENERIC FUNCTIONS ----------------------------
// https://github.com/RobTrew/prelude-jxa
// 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
});
// allSame :: [a] -> Bool
const allSame = xs =>
// True if all elements of xs are identical
// in terms of the === operator.
0 === xs.length || (() => {
const x = xs[0];
return xs.every(y => x === y)
})();
// bindLR (>>=) :: Either a ->
// (a -> Either b) -> Either b
const bindLR = m =>
mf => undefined !== m.Left ? (
m
) : mf(m.Right);
// center :: Int -> Char -> String -> String
const center = n =>
// Size of space -> filler Char -> String -> Centered String
c => s => {
const gap = n - s.length;
return 0 < gap ? (() => {
const pre = c.repeat(Math.floor(gap / 2));
return pre + s + pre + c.repeat(gap % 2);
})() : s
};
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
x => fs.reduceRight((a, f) => f(a), x);
// 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 => n > s.length ? (
s.padStart(n, c)
) : s;
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = fl =>
fr => e => 'Either' === e.type ? (
undefined !== e.Left ? (
fl(e.Left)
) : fr(e.Right)
) : undefined;
// enumFromTo :: Int -> Int -> [Int]
const enumFromTo = m =>
n => Array.from({
length: 1 + n - m
}, (_, i) => m + i);
// identity :: a -> a
const identity = x =>
// The identity function. (`id`, in Haskell)
x;
// isLower :: Char -> Bool
const isLower = c =>
/[a-z]/.test(c);
// isUpper :: Char -> Bool
const isUpper = c =>
/[A-Z]/.test(c);
// last :: [a] -> a
const last = xs =>
// The last item of a list.
0 < xs.length ? xs.slice(-1)[0] : undefined;
// length :: [a] -> Int
const length = xs =>
// Returns Infinity over objects without finite
// length. This enables zip and zipWith to choose
// the shorter argument when one is non-finite,
// like cycle, repeat etc
(Array.isArray(xs) || 'string' === typeof xs) ? (
xs.length
) : Infinity;
// map :: (a -> b) -> [a] -> [b]
const map = f => xs =>
(Array.isArray(xs) ? (
xs
) : xs.split('')).map(f);
// maximum :: Ord a => [a] -> a
const maximum = xs =>
// The largest value in a non-empty list.
0 < xs.length ? (
xs.slice(1).reduce(
(a, x) => x > a ? (
x
) : a, xs[0]
)
) : undefined;
// replicate :: Int -> a -> [a]
const replicate = n =>
// A list of n copies of x.
x => Array.from({
length: n
}, () => x);
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(JSON.stringify)
.join(' -> ')
);
// snd :: (a, b) -> b
const snd = tpl => tpl[1];
// Splitting not on a delimiter, but wherever the relationship
// between consecutive terms matches a binary predicate
// splitBy :: (a -> a -> Bool) -> [a] -> [[a]]
// splitBy :: (String -> String -> Bool) -> String -> [String]
const splitBy = p => xs =>
(xs.length < 2) ? [xs] : (() => {
const
bln = 'string' === typeof xs,
ys = bln ? xs.split('') : xs,
h = ys[0],
parts = ys.slice(1)
.reduce(([acc, active, prev], x) =>
p(prev)(x) ? (
[acc.concat([active]), [x], x]
) : [acc, active.concat(x), x], [
[],
[h],
h
]);
return (bln ? (
ps => ps.map(cs => ''.concat.apply('', cs))
) : x => x)(parts[0].concat([parts[1]]));
})();
// splitOn :: [a] -> [a] -> [[a]]
// splitOn :: String -> String -> [String]
const splitOn = pat => src =>
/* A list of the strings delimited by
instances of a given pattern in s. */
('string' === typeof src) ? (
src.split(pat)
) : (() => {
const
lng = pat.length,
tpl = findIndices(matching(pat))(src).reduce(
(a, i) => Tuple(
fst(a).concat([src.slice(snd(a), i)])
)(lng + i),
Tuple([])(0),
);
return fst(tpl).concat([src.slice(snd(tpl))]);
})();
// str :: a -> String
const str = x => x.toString();
// tail :: [a] -> [a]
const tail = xs =>
// A new list consisting of all
// items of xs except the first.
0 < xs.length ? xs.slice(1) : [];
// take :: Int -> [a] -> [a]
// take :: Int -> String -> String
const take = n => xs =>
'GeneratorFunction' !== xs
.constructor.constructor.name ? (
xs.slice(0, n)
) : [].concat.apply([], Array.from({
length: n
}, () => {
const x = xs.next();
return x.done ? [] : [x.value];
}));
// If some of the rows are shorter than the following rows,
// their elements are skipped:
// > transpose [[10,11],[20],[],[30,31,32]] == [[10,20,30],[11,31],[32]]
// transpose :: [[a]] -> [[a]]
const transpose = xss => {
const go = xss =>
0 < xss.length ? (() => {
const
h = xss[0],
t = xss.slice(1);
return 0 < h.length ? (
[
[h[0]].concat(t.reduce(
(a, xs) => a.concat(
0 < xs.length ? (
[xs[0]]
) : []
),
[]
))
].concat(go([h.slice(1)].concat(
t.map(xs => xs.slice(1))
)))
) : go(t);
})() : [];
return go(xss);
};
// unlines :: [String] -> String
const unlines = xs => xs.join('\n');
// zipWithList :: (a -> b -> c) -> [a] -> [b] -> [c]
const zipWithList = f =>
xs => ys => {
const
lng = Math.min(length(xs), length(ys)),
vs = take(lng)(ys);
return take(lng)(xs)
.map((x, i) => f(x)(vs[i]));
};
// MAIN ---
return main();
})();

