Tab-indented and space-indented outlines pasted directly into Mail.app with the default Edit > Paste do not respond to Mail's:
Format > Indentation >
Decrease ⌘[
Increase ⌘]
This macro pastes text outlines in Mail's own indentation format, making them compatible with the commands above.
Paste text outline to Mail.app in Format > Indentation style.kmmacros (12 KB)
Expand disclosure triangle to view JS Source
(() => {
"use strict";
ObjC.import("AppKit");
// Paste text outline to Mail.app
// in Mail > Format > Indentation format
// ( responds to Increase ⌘] and Decrease ⌘[ )
// Rob Trew @2022
// Ver 0.01
// main :: IO ()
const main = () =>
either(
alert("Paste as Mail.app indented")
)(
x => x
)(
bindLR(
clipTextLR()
)(
compose(
Right,
setClipOfTextType("public.html"),
mailQuoteBlocksFromForest,
forestFromIndentedLines,
indentLevelsFromLines,
lines
)
)
);
// ------- 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
);
};
// clipTextLR :: () -> Either String String
const clipTextLR = () => {
// Either a message, (if no clip text is found),
// or the string contents of the clipboard.
const
v = ObjC.unwrap(
$.NSPasteboard.generalPasteboard
.stringForType($.NSPasteboardTypeString)
);
return Boolean(v) && 0 < v.length ? (
Right(v)
) : Left("No utf8-plain-text found in clipboard.");
};
// 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
});
// 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];
}
}
}
});
// bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
const bimap = f =>
// Tuple instance of bimap.
// A tuple of the application of f and g to the
// first and second values respectively.
g => tpl => Tuple(f(tpl[0]))(
g(tpl[1])
);
// bindLR (>>=) :: Either a ->
// (a -> Either b) -> Either b
const bindLR = lr =>
// Bind operator for the Either option type.
// If lr has a Left value then lr unchanged,
// otherwise the function mf applied to the
// Right value in lr.
mf => "Left" in lr ? (
lr
) : mf(lr.Right);
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
// A function defined by the right-to-left
// composition of all the functions in fs.
fs.reduce(
(f, g) => x => f(g(x)),
x => 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);
// first :: (a -> b) -> ((a, c) -> (b, c))
const first = f =>
// A simple function lifted to one which applies
// to a tuple, transforming only its first item.
([x, y]) => Tuple(f(x))(y);
// 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);
};
// indentLevelsFromLines :: [String] -> [(Int, String)]
const indentLevelsFromLines = xs => {
const
pairs = xs.map(
x => bimap(
cs => cs.length
)(
cs => cs.join("")
)(
span(isSpace)([...x])
)
),
indentUnit = pairs.reduce(
(a, [i]) => 0 < i ? (
i < a ? i : a
) : a,
Infinity
);
return [Infinity, 0].includes(indentUnit) ? (
pairs
) : pairs.map(first(n => n / indentUnit));
};
// isSpace :: Char -> Bool
const isSpace = c =>
// True if c is a white space character.
(/\s/u).test(c);
// lines :: String -> [String]
const lines = s =>
// A list of strings derived from a single string
// which is delimited by \n or by \r\n or \r.
Boolean(s.length) ? (
s.split(/\r\n|\n|\r/u)
) : [];
// 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)([]);
};
// MAIN ---
return main();
})();