Making a comparison table

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();
})();
2 Likes