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