Plain (tab or space)-indented outlines pasted into Apple Mail don't respond to Mail's:
Format > Indentation > Increase | Decrease
⌘]
⌘[
This macro copies an outline selected in Hog Bay's Bike Outliner to Mail's own indentation format, for direct pasting into Mail.app.
Copy selected lines from BIKE for Mail-indented pasting.kmmacros (8.4 KB)
Expand disclosure triangle to view JS Source
(() => {
"use strict";
ObjC.import("AppKit");
// Draft of "Copy As Mail.app indented"
// for Bike 1.1
// Rob Trew @2022
// Ver 0.03
// main :: IO ()
const main = () => {
const
bike = Application("Bike"),
doc = bike.documents.at(0);
return either(
alert("Copy as Mail.app indentation")
)(
x => x
)(
doc.exists() ? (() => {
const
selectedRows = doc.rows.where({
selected: true
}),
n = selectedRows.length,
levels = selectedRows.level(),
minLevel = Math.min(...levels);
return (
setClipOfTextType("public.html")(
mailQuoteBlocksFromForest(
forestFromIndentedLines(
zip(
levels.map(x => x - minLevel)
)(
selectedRows.name()
)
)
)
),
Right(
`${n} row(s) copied as Mail.app indented lines.`
)
);
})() : Left("No document open in Bike")
);
};
// ------- INDENTED MAIL.APP HTML FROM FOREST --------
// mailQuoteBlocksFromForest :: [Tree String] -> String
const mailQuoteBlocksFromForest = forest => {
const
style = "style=\"margin: 0px 0px 0px 40px;\"",
go = tree => {
const xs = tree.nest;
return [
`<div>${tree.root || " "}</div>`,
...(
0 < xs.length ? [
[
`<blockquote ${style}>`,
xs.flatMap(go).join("\n"),
"</blockquote>"
]
.join("\n")
] : []
)
]
.join("\n");
};
return forest.flatMap(go).join("\n");
};
// ----------------------- 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
);
};
// setClipOfTextType :: String -> String -> IO String
const setClipOfTextType = utiOrBundleID =>
txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(txt),
utiOrBundleID
),
txt
);
};
// --------------------- GENERIC ---------------------
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: "Either",
Right: x
});
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = fl =>
// Application of the function fl to the
// contents of any Left value in e, or
// the application of fr to its Right value.
fr => e => e.Left ? (
fl(e.Left)
) : fr(e.Right);
// Node :: a -> [Tree a] -> Tree a
const Node = v =>
// Constructor for a Tree node which connects a
// value of some kind to a list of zero or
// more child trees.
xs => ({
type: "Node",
root: v,
nest: xs || []
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
// A pair of values, possibly of
// different types.
b => ({
type: "Tuple",
"0": a,
"1": b,
length: 2,
*[Symbol.iterator]() {
for (const k in this) {
if (!isNaN(k)) {
yield this[k];
}
}
}
});
// forestFromIndentedLines :: [(Int, String)] ->
// [Tree String]
const forestFromIndentedLines = tuples => {
const go = xs =>
0 < xs.length ? (() => {
// First line and its sub-tree,
const [depth, body] = xs[0],
[tree, rest] = span(x => depth < x[0])(
xs.slice(1)
);
// followed by the rest.
return [
Node(body)(go(tree))
].concat(go(rest));
})() : [];
return go(tuples);
};
// span :: (a -> Bool) -> [a] -> ([a], [a])
const span = p =>
// Longest prefix of xs consisting of elements which
// all satisfy p, tupled with the remainder of xs.
xs => {
const i = xs.findIndex(x => !p(x));
return -1 !== i ? (
Tuple(xs.slice(0, i))(
xs.slice(i)
)
) : Tuple(xs)([]);
};
// zip :: [a] -> [b] -> [(a, b)]
const zip = xs =>
// The paired members of xs and ys, up to
// the length of the shorter of the two lists.
ys => Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => Tuple(xs[i])(ys[i]));
// MAIN ---
return main();
})();