Is there an example of a script that would find a row/item, then export only the found row and its children to an opml file (overwriting the exported file if it exists - without interaction)?
The first question is how much information you want the OPML to capture.
If just the topic text, then the omniJS below should suffice for the selection -> OPML text part, but it would take a little more work if you also want to capture notes or other columns.
The file saving part could be done by calling the omniJS code from JXA.
(() => {
// Rob Trew (c) 2018
// main :: () -> IO String
const main = () =>
opmlFromTrees(
'Selected sub-tree',
x => x.root,
map(fmapPureOO(x => x.topic),
document.editors[0].selection.items
)
);
// OMNI-OUTLINE ---------------------------------------
// fmapPureOO :: (OOItem -> a) -> OOItem -> Tree OOItem
const fmapPureOO = f => item => {
const go = x => Node(f(x),
x.hasChildren ? (
x.children.map(go)
) : []);
return go(item);
};
// OPML -----------------------------------------------
// opmlFromTrees :: String -> (Tree -> String) -> [Tree] ->
// Optional String -> OPML String
const opmlFromTrees = (strTitle, fnText, xs) => {
const
// ents :: [(Regex, String)]
ents = zipWith.apply(null,
cons(
(x, y) => [new RegExp(x, 'g'), '&' + y + ';'],
map(words, ['& \' " < >', 'amp apos quot lt gt'])
)
),
// entCoded :: a -> String
entCoded = v => ents.reduce(
(a, [x, y]) => a.replace(x, y),
v.toString()
),
// Nest -> Comma-delimited row indices of all parents in tree
// expands :: [textNest] -> String
expands = xs => {
const indexAndMax = (n, xs) =>
mapAccumL((m, node) =>
node.nest.length > 0 ? (() => {
const sub = indexAndMax(m + 1, node.nest);
return [sub[0], cons(m, concat(sub[1]))];
})() : [m + 1, []], n, xs);
return concat(indexAndMax(0, xs)[1]).join(',');
};
// go :: String -> Dict -> String
const go = indent => node =>
indent + '<outline ' + unwords(map(
([k, v]) => k + '="' + entCoded(v) + '"',
cons(['text', fnText(node) || ''], node.kvs || [])
)) + (node.nest.length > 0 ? (
'>\n' +
unlines(map(go(indent + ' '), node.nest)) +
'\n' +
indent + '</outline>'
) : '/>');
// OPML serialization -----------------------------
return unlines(concat([
[
'<?xml version=\"1.0\" encoding=\"utf-8\"?>',
'<opml version=\"2.0\">',
' <head>',
' <title>' + (strTitle || '') + '</title>',
' <expansionState>' + expands(xs) +
'</expansionState>',
' </head>',
' <body>'
],
map(go(' '), xs), [
' </body>',
'</opml>'
]
]));
};
// GENERIC --------------------------------------------
// concat :: [[a]] -> [a]
// concat :: [String] -> String
const concat = xs =>
0 < xs.length ? (() => {
const unit = 'string' !== typeof xs[0] ? (
[]
) : '';
return unit.concat.apply(unit, xs);
})() : [];
// cons :: a -> [a] -> [a]
const cons = (x, xs) =>
Array.isArray(xs) ? (
[x].concat(xs)
) : (x + xs);
// map :: (a -> b) -> [a] -> [b]
const map = (f, xs) => xs.map(f);
// 'The mapAccumL function behaves like a combination of map and foldl;
// it applies a function to each element of a list, passing an accumulating
// parameter from left to right, and returning a final value of this
// accumulator together with the new list.' (See Hoogle)
// mapAccumL :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
const mapAccumL = (f, acc, xs) =>
xs.reduce((a, x, i) => {
const pair = f(a[0], x, i);
return Tuple(pair[0], a[1].concat(pair[1]));
}, Tuple(acc, []));
// Node :: a -> [Tree a] -> Tree a
const Node = (v, xs) => ({
type: 'Node',
root: v, // any type of value (consistent across tree)
nest: xs || []
});
// showJSON :: a -> String
const showJSON = x => JSON.stringify(x, null, 2);
// Tuple (,) :: a -> b -> (a, b)
const Tuple = (a, b) => ({
type: 'Tuple',
'0': a,
'1': b,
length: 2
});
// unlines :: [String] -> String
const unlines = xs => xs.join('\n');
// words :: String -> [String]
const words = s => s.split(/\s+/);
// unwords :: [String] -> String
const unwords = xs => xs.join(' ');
// zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
const zipWith = (f, xs, ys) =>
Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => f(xs[i], ys[i], i));
// MAIN ---
return main();
})();
Incidentally the definition of which row(s) to export is left to the GUI in the snippet above – you would also need to define and encode whatever you happen to need and mean in relation to ‘finding’)
If you want a more applescripty alternative, this might do. Things to be aware of:
- It is set to use the clipboard to find the target row, but this can be modified
- You can add features like unhoisting of the original file if you like
- If the resulting OPML file is open and the script is run again, it will create an additional file (adding another ‘_ext’)
- I add the ‘_ext’ to the filename to prevent the export from ever overwriting the original (should they be in the same location and have the same document format)
- It should work regardless of whether you currently have other rows hoisted
- It should grab any notes that are in the affected rows
tell application "OmniOutliner"
--Set up filesave
set deskPath to path to desktop folder as string
set fileN to name of document 1 as string
set DeskSave to deskPath & fileN & "_ext"
--Pick a row, uses the clipboard but this can be modified
--set hoistRow to row named "Match.07" of document 1
set hoistRow to row named (the clipboard) of document 1
--Hoist that row, then expand
hoist hoistRow
expandAll hoistRow
--Export as opml, will overwrite extant file
close access (open for access file (DeskSave & ".opml"))
export document 1 to file (DeskSave & ".opml") as "org.opml.opml"
end tell
Thanks @draft8, that looks pretty comprehensive. I guess I’ll have to refresh my JS knowledge for JXA and a find function. I only needed the topic text, and will have the row to export named something like “Outline to Export” to make it easy to find/select.
@mockman, thanks for the response, but I need to be able to run it through Omni Automation on the iOS app as well.
Tell us more about how the use case works - you want a prompt for a string and then the matching line(s) are found and exported ?
Can you break it down and tell us what all the steps are ?
Just want to:
-
go to a row, whose name is assigned to a variable in the script (will always be the same name)
-
export that row’s children to a new OPML file (filename preset), overwrite if file exists
-
maybe set the row’s style after export, to indicate it has been processed
that’s it.
Finding a row by topic match (and deriving OPML for its sub-tree) is straightforward enough on both iOS and macOS (see omniJS code below),
and writing that OPML to a file is straightforward on macOS.
What I am not sure about however (best to email Omni support) is whether we can yet write text files (ie OPML files) from OO on iOS.
// main :: IO ()
(() => {
// Rob Trew (c) 2018
// main :: () -> IO String
const main = () => {
const
rowName = 'some topic or other',
mbRow = find(
x => rowName === x.topic, // search condition (cld be adjusted)
document.outline.rootItem.descendants
);
return bindLR(
mbRow.Nothing ? (
Left('Row topic not found: ' + rowName)
) : Right(mbRow.Just),
row => opmlFromTrees(
rowName,
x => x.root,
[fmapPureOO(x => x.topic)(row)]
)
);
};
// OMNI-OUTLINE ---------------------------------------
// fmapPureOO :: (OOItem -> a) -> OOItem -> Tree OOItem
const fmapPureOO = f => item => {
const go = x => Node(f(x),
x.hasChildren ? (
x.children.map(go)
) : []);
return go(item);
};
// OPML -----------------------------------------------
// opmlFromTrees :: String -> (Tree -> String) -> [Tree] ->
// Optional String -> OPML String
const opmlFromTrees = (strTitle, fnText, xs) => {
const
// ents :: [(Regex, String)]
ents = zipWith.apply(null,
cons(
(x, y) => [new RegExp(x, 'g'), '&' + y + ';'],
map(words, ['& \' " < >', 'amp apos quot lt gt'])
)
),
// entCoded :: a -> String
entCoded = v => ents.reduce(
(a, [x, y]) => a.replace(x, y),
v.toString()
),
// Nest -> Comma-delimited row indices of all parents in tree
// expands :: [textNest] -> String
expands = xs => {
const indexAndMax = (n, xs) =>
mapAccumL((m, node) =>
node.nest.length > 0 ? (() => {
const sub = indexAndMax(m + 1, node.nest);
return [sub[0], cons(m, concat(sub[1]))];
})() : [m + 1, []], n, xs);
return concat(indexAndMax(0, xs)[1]).join(',');
};
// go :: String -> Dict -> String
const go = indent => node =>
indent + '<outline ' + unwords(map(
([k, v]) => k + '="' + entCoded(v) + '"',
cons(['text', fnText(node) || ''], node.kvs || [])
)) + (node.nest.length > 0 ? (
'>\n' +
unlines(map(go(indent + ' '), node.nest)) +
'\n' +
indent + '</outline>'
) : '/>');
// OPML serialization -----------------------------
return unlines(concat([
[
'<?xml version=\"1.0\" encoding=\"utf-8\"?>',
'<opml version=\"2.0\">',
' <head>',
' <title>' + (strTitle || '') + '</title>',
' <expansionState>' + expands(xs) +
'</expansionState>',
' </head>',
' <body>'
],
map(go(' '), xs), [
' </body>',
'</opml>'
]
]));
};
// GENERIC --------------------------------------------
// Just :: a -> Just a
const Just = x => ({
type: 'Maybe',
Nothing: false,
Just: x
});
// Nothing :: () -> Nothing
const Nothing = () => ({
type: 'Maybe',
Nothing: true,
});
// 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
});
// bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
const bindLR = (m, mf) =>
undefined !== m.Left ? (
m
) : mf(m.Right);
// concat :: [[a]] -> [a]
// concat :: [String] -> String
const concat = xs =>
0 < xs.length ? (() => {
const unit = 'string' !== typeof xs[0] ? (
[]
) : '';
return unit.concat.apply(unit, xs);
})() : [];
// find :: (a -> Bool) -> [a] -> Maybe a
const find = (p, xs) => {
for (let i = 0, lng = xs.length; i < lng; i++) {
let x = xs[i];
if (p(x)) return Just(x);
}
return Nothing();
};
// cons :: a -> [a] -> [a]
const cons = (x, xs) =>
Array.isArray(xs) ? (
[x].concat(xs)
) : (x + xs);
// map :: (a -> b) -> [a] -> [b]
const map = (f, xs) => xs.map(f);
// 'The mapAccumL function behaves like a combination of map and foldl;
// it applies a function to each element of a list, passing an accumulating
// parameter from left to right, and returning a final value of this
// accumulator together with the new list.' (See Hoogle)
// mapAccumL :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
const mapAccumL = (f, acc, xs) =>
xs.reduce((a, x, i) => {
const pair = f(a[0], x, i);
return Tuple(pair[0], a[1].concat(pair[1]));
}, Tuple(acc, []));
// Node :: a -> [Tree a] -> Tree a
const Node = (v, xs) => ({
type: 'Node',
root: v, // any type of value (consistent across tree)
nest: xs || []
});
// showJSON :: a -> String
const showJSON = x => JSON.stringify(x, null, 2);
// unlines :: [String] -> String
const unlines = xs => xs.join('\n');
// words :: String -> [String]
const words = s => s.split(/\s+/);
// unwords :: [String] -> String
const unwords = xs => xs.join(' ');
// zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
const zipWith = (f, xs, ys) =>
Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => f(xs[i], ys[i], i));
// MAIN ---
return main();
})();
Another approach might be to automate creation of a temporary document based on a copy of the specified subtree, and export from that to the named file path.
(As far as I can see the API to the built-in export facility works only at whole document level)
You could write to Drafts, or Editorial. To name but two. But from there?
Perhaps the moment to zoom back and ask @OmniRupe what happens next to the OPML – where and how it will be used ?
(Automator :: Get Clipboard -> Save File
might be one route)
Or perhaps into iThoughts or MindNode.
Sorry about the delay in responding, been quite busy (love it :) ).
I like this approach, is there a simple method to create an OmniOutliner document from a subtree?