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?
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?
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)
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)
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.
Info on summary table, and on related topics $TableExpression and $TableHeading.
There are other options for markdown tables.
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)
(() => {
'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();
})();
Wow! Thanks all. This is great
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:
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:
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.)
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")
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.