Based on @complexpoint excellent work, I created this macro.
@jessegrosjean would be possible to obtain (via scripting) the contents of the full outline and the path to the current file ?
In the meantime, this is a solution, I think.
Code:
(() => {
"use strict";
// ---------- DEMO OF COPY BIKE AS TAB OUTLINE ----------
ObjC.import("AppKit");
// unlocked2412 based on earlier work by Rob Trew @2022
// Draft ver 0.001
// DEMO: copy Bike selection as tab outline
// main :: IO ()
const main = () => {
const
se = Application("System Events");
return (
$.NSPasteboard.generalPasteboard.clearContents,
either(
alert("No clipboard for Bike")
)(
txt => Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
}
).displayNotification(txt, {
withTitle: "Copied as Indented Text",
subtitle: "Bike outline:"
})
)(
bindLR(
bindLR(
bikeFrontDocFilePathLR()
)(
compose(readFileLR, fst)
)
)(
xml => bindLR(
tabOutlineFromBikeStringLR(
xml
)
)(
compose(Right, copyText)
)
)
)
);
};
// --------------- TAB OUTLINE FROM BIKE ----------------
// tabOutlineFromFromBikeStringLR ::
// HTML String -> Either String String
const tabOutlineFromBikeStringLR =
bikeHTML => {
return bindLR(treeFromBikeStringLR(bikeHTML))(
tree => Right(
// Each node decorated with an
// outline level property.
tabOutlineFromForest(
map(
fmapTree(node => node.text)
)(
(
tree.nest
)
)
)
)
);
}
// ---------------------- BIKE -----------------------
// bikeFrontDocFilePathLR :: () ->
// Either String (FilePath, String)
const bikeFrontDocFilePathLR = () => {
// ObjC.import ('AppKit')
const
appProcess = Application('System Events')
.applicationProcesses.byName("Bike"),
ws = appProcess.windows;
return bindLR(
0 < ws.length ? Right(
ws.at(0).attributes.byName('AXDocument').value()
) : Left(`No document windows open in Bike.`)
)(
docURL => null !== docURL ? (
Right([
decodeURIComponent(docURL.slice(7)),
appProcess.bundleIdentifier()
])
) : Left(`No saved document active in ${appName}.`)
);
};
// treeFromBikeStringLR :: Bike String ->
// Either String [Tree String]
const treeFromBikeStringLR = s => {
const
error = $(),
node = $.NSXMLDocument.alloc
.initWithXMLStringOptionsError(
s, 0, error
);
return node.isNil() ? (() => {
const
problem = ObjC.unwrap(
error.localizedDescription
);
return Left(
`Not parseable as Bike:\n\n${problem}`
);
})() : treeFromBikeXMLNodeLR(node);
};
// treeFromBikeXMLNodeLR :: XML Node ->
// Either String Tree String
const treeFromBikeXMLNodeLR = xmlRootNode => {
const
unWrap = ObjC.unwrap,
topNode = xmlRootNode.childAtIndex(0);
return bindLR(
"html" === unWrap(topNode.name) ? (
Right(topNode.childAtIndex(1))
) : Left("Expected top-level <html> node.")
)(body => bindLR(
"body" === unWrap(body.name) ? (
Right(body.childAtIndex(0))
) : Left("Expected a <body> node in bike HTML.")
)(topUL => "ul" === unWrap(topUL.name) ? (
Right(
Node({
id: attribVal(topUL)("id"),
text: "[Virtual Root]"
})(unWrap(topUL.children).map(
lineTree
))
)
) : Left("Expected a top <ul> node in bike HTML.")));
};
// lineTree :: XMLNode LI -> Tree String
const lineTree = node => {
const
unWrap = ObjC.unwrap,
pNode = node.childAtIndex(0);
return Node(
unWrap(node.attributes).reduce(
(a, attrib) => Object.assign(a, {
[unWrap(attrib.name)]: unWrap(
attrib.stringValue
)
}), {
text: unWrap(pNode.stringValue)
}
)
)(
1 < parseInt(node.childCount, 10) ? (
unWrap(pNode.nextSibling.children)
.map(lineTree)
) : []
);
};
// attribVal :: XMLNode -> String -> String
const attribVal = xmlNode =>
// The value of a named attribute of the XML node.
k => ObjC.unwrap(
xmlNode.attributeForName(k).stringValue
) || "";
// ---------------- JS TREES ------------------------
// tabOutlineFromForest :: [Tree String] -> String
const tabOutlineFromForest = trees =>
trees.flatMap(
foldTree(x => xs => [
x,
...xs.flat().map(s => `\t${s}`)
])
).join("\n");
// ---------------- LIBRARY FUNCTIONS ----------------
// ----------------------- JXA -----------------------
// https://github.com/RobTrew/prelude-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
);
};
// appIsInstalled :: String -> Bool
const appIsInstalled = bundleID =>
Boolean(
$.NSWorkspace.sharedWorkspace
.URLForApplicationWithBundleIdentifier(
bundleID
)
.fileSystemRepresentation
);
// clipOfTypeLR :: String -> Either String String
const clipOfTypeLR = utiOrBundleID => {
const
clip = ObjC.deepUnwrap(
$.NSString.alloc.initWithDataEncoding(
$.NSPasteboard.generalPasteboard
.dataForType(utiOrBundleID),
$.NSUTF8StringEncoding
)
);
return 0 < clip.length ? (
Right(clip)
) : Left(
"No clipboard content found " + (
`for type '${utiOrBundleID}'`
)
);
};
// copyText :: String -> IO String
const copyText = s => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
// --------------------- GENERIC ---------------------
// https://github.com/RobTrew/prelude-jxa
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: 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 || []
});
// Right :: b -> Either a b
const Right = x => ({
type: "Either",
Right: x
});
// 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];
}
}
}
});
// bindLR (>>=) :: Either a ->
// (a -> Either b) -> Either b
const bindLR = m =>
mf => m.Left ? (
m
) : mf(m.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);
// enumFromTo :: Int -> Int -> [Int]
const enumFromTo = m =>
n => Array.from({
length: 1 + n - m
}, (_, i) => m + i);
// fmapTree :: (a -> b) -> Tree a -> Tree b
const fmapTree = f => {
// A new tree. The result of a
// structure-preserving application of f
// to each root in the existing tree.
const go = t => Node(
f(t.root)
)(
t.nest.map(go)
);
return go;
};
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// foldTree :: (a -> [b] -> b) -> Tree a -> b
const foldTree = f => {
// The catamorphism on trees. A summary
// value obtained by a depth-first fold.
const go = tree => f(
root(tree)
)(
nest(tree).map(go)
);
return go;
};
// forestOutline :: String -> (a -> String) ->
// Forest a -> String
const forestOutline = indentUnit =>
// An indented outline of the nodes
// (each stringified by f) of a forest.
f => forest => forest.flatMap(
foldTree(
x => xs => 0 < xs.length ? [
f(x), ...xs.flat(1)
.map(s => `${indentUnit}${s}`)
] : [f(x)]
)
).join("\n");
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = eqOp =>
// A list of lists, each containing only elements
// equal under the given equality operator,
// such that the concatenation of these lists is xs.
xs => 0 < xs.length ? (() => {
const [h, ...t] = xs;
const [groups, g] = t.reduce(
([gs, a], x) => eqOp(x)(a[0]) ? (
Tuple(gs)([...a, x])
) : Tuple([...gs, a])([x]),
Tuple([])([h])
);
return [...groups, g];
})() : [];
// map :: (a -> b) -> [a] -> [b]
const map = f =>
// The list obtained by applying f
// to each element of xs.
// (The image of xs under f).
xs => [...xs].map(f);
// mapAccumL :: (acc -> x -> (acc, y)) -> acc ->
// [x] -> (acc, [y])
const mapAccumL = f =>
// A tuple of an accumulation and a list
// obtained by a combined map and fold,
// with accumulation from left to right.
acc => xs => [...xs].reduce(
([a, bs], x) => second(
v => bs.concat(v)
)(
f(a)(x)
),
Tuple(acc)([])
);
// nest :: Tree a -> [a]
const nest = tree =>
tree.nest;
// on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
const on = f =>
// e.g. groupBy(on(eq)(length))
g => a => b => f(g(a))(g(b));
// readFileLR :: FilePath -> Either String IO String
const readFileLR = fp => {
// Either a message or the contents of any
// text file at the given filepath.
const
e = $(),
ns = $.NSString
.stringWithContentsOfFileEncodingError(
$(fp).stringByStandardizingPath,
$.NSUTF8StringEncoding,
e
);
return ns.isNil() ? (
Left(ObjC.unwrap(e.localizedDescription))
) : Right(ObjC.unwrap(ns));
};
// snd :: (a, b) -> b
const snd = tpl =>
// Second member of a pair.
tpl[1];
// treeWithLevels :: Int -> Tree Dict -> Tree Dict
const treeWithLevels = topLevel =>
// A tree in which each root dictionary is
// decorated with an integer 'level' value,
// where the level of the top node is given,
// the level of its children is topLevel + 1,
// and so forth downwards.
tree => {
const go = level =>
node => {
const
nodeRoot = node.root,
fullLevel = parseInt(
nodeRoot.indent, 10
) || level;
return Node(
Object.assign({}, nodeRoot, {
level: fullLevel
})
)(
node.nest.map(go(1 + fullLevel))
);
};
return go(topLevel)(tree);
};
// root :: Tree a -> a
const root = tree =>
// The value attached to a tree node.
tree.root;
// second :: (a -> b) -> ((c, a) -> (c, b))
const second = f =>
// A function over a simple value lifted
// to a function over a tuple.
// f (a, b) -> (a, f(b))
([x, y]) => Tuple(x)(f(y));
// MAIN
return main();
})();
KM Macro:
Copy as Tab Outline for Bike.app (preview 26+).kmmacros.zip (5.6 KB)