Find row by title and export

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
1 Like

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?

1 Like

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)

1 Like

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?