Making a comparison table

Hello there,

I thought I saw a tutorial once that showed how to use Tinderbox to make comparison tables like on this page.

Did I imagine that? Is that a thing that can be done with Tinderbox?

2 Likes

To make a table like that you would have to build it up from scratch using HTML and export it. Very rough example attached. You’d need to style this with your own CSS, add images, etc.

Table Example.tbx (94.9 KB)

1 Like

Not impossible to translate the attribute set of each product note (or the leaves of a tree/outline of even depth) to a grid, but it might make sense to start with a more inherently grid-like model – in a spreadsheet for example.

One way to to generate tabular material is to write the data out as plain text in MultiMarkdown table format, choose a CSS defined style, and generate HTML in something like Brett Terpstra’s Marked 2 (https://marked2app.com)

2 Likes

A summary table might give you a simple, quick-and-dirty version of this.

You’d make a note for each product, putting them all into a container called “coffee makers” and filling in the pertinent attributes for each product. Then, you’d draw a summary table in the container; the columns would be the attributes of interest and the rows would be the products.

That’s 90° rotation from the Amazon example, but shows the same information.

1 Like

Info on summary table, and on related topics $TableExpression and $TableHeading.

There are other options for markdown tables.

1 Like

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

Wow! Thanks all. This is great :grinning:

This is great Rob! Question: can you include a sample tbx file? Where do you put the javascript code in tinderbox? Newbie.

tom

This is the file I used in that example (minus images to shed some bulk):

comparisonTable.tbx (89.3 KB)

The JavaScript for Automation source can be run either from:

  1. Script Editor (choosing JavaScript rather than AppleScript in top-left pull-down)
  2. or in any other context from which a JXA script can be run – I personally often assign a keystroke to an ‘Execute JavaScript for Automation’ action in Keyboard Maestro. Fastscripts is also good.
1 Like

PS I’ve also updated the JS source above so that MMD markup is copied to the clipboard when the script is run.

For example for Marked 2 > Preview > Clipboard Preview

I am not quite sure, if something like this could also be accomplished with TBs export code?

The part that eludes me (in an attempt to do it with export code) is that the osascript version depends on the active GUI selection to determine:

  • which subset of notes is to be compared
  • left to right column order. (it uses the sequence of GUI selection)

Perhaps others can help us on how to capture an ordered selection set in action code (and | or) export code ?

If I understand correctly, you want to capture references to the current in-app selection?

I don’t think there is such a notion in action code - as at v8.2.3†. For export, all pages export unless $HTMLDontExport has been set (which can be done via the HTML Inspector, Quickstamp, Get Info, stamps, etc…)

As an aside, List-type attributes hold input order. Sets, may get reordered. Sets auto-de-dupe but Lists don’t (you might want dupe values). But happily List.unique can give you a current list but will dupes deleted - i.e. only the first instance of any value is retained.

† Aha, runCommand gives action code a bridge to the command line and osascript. Not tested, but I guess you could use this to call out and read the $Name (assumed unique) of the current selection. There isn’t a notion of order within the selection other than underlying $OutlineOrder. IOW is you have notes ‘1’, ‘2’ and '‘£’ in the outline order, if you select them in the order 1/3/2 I don’t think that matters and Tinderbox would process then in order 1/2/3. As said, I’ve not tested this. Also note, runCommand wasn’t intended for continual app/shell interaction but rather to make occasional calls to shell scripts/tasks that the latter could do and which can’t be done in the app itself. Also, FWIW, a stamp runs the stamp code once on every note in the current UI selection (I assume in outline order).

Thanks - yes that’s exactly what I meant and I was speaking in the wrong category – not in terms of TBX set types but of ordered sets as in math more generally, so I should have said (TBX) list.

A hybrid approach does sound right - thanks.

(And I guess it might also be feasible cross the bridge in the other direction too, capturing a GUI selection list in osascript, and then using osascript evaluate over a TBX expression.)

1 Like

PS I haven’t yet found the trick of escaping quotes in shell expressions passed to runCommand:

Looking for an expression of the pattern:

$IDList = runCommand(" ... ")

and the osascript incantation at the bash shell would be some variant of:

osascript -l JavaScript -e "Application('Tinderbox 8').documents.at(0).selections().map(x => x.attributeOf({named: 'ID'}).value()).join(';')"

though at the shell we could also evaluate a slightly different form:

osascript -l JavaScript <<JXA_END 2>/dev/null
Application('Tinderbox 8')
    .documents.at(0)
    .selections()
    .map(x => x.attributeOf({
        named: 'ID'
    }).value()).join(';')
JXA_END

to obtain strings like

1579528320;1579528321;1579528354

This seems to work:

$IDList = runCommand("osascript -l JavaScript <<JXA_END 2>/dev/null
Application('Tinderbox 8')
    .documents.at(0)
    .selections()
    .map(
        x => x.attributeOf({
            named: 'ID'
        }).value()
    ).join(';')
JXA_END")
1 Like

Ah a long-standing challenge as there is no escape method for (straight) quotes - using a preceding backslash won’t work†. But, as long as the command line passed doesn’t need both single and double quotes, you can enclose a double quote in a pair of single quotes or vice versa. In basic terms, doing:

' blah "some value' + '"'

so as to pass blah "some value" through the process.

† But see the latter part of this article.

1 Like