How to "bulk tag"

I’ve got a lengthy outline in which every top-level (or parent) item goes several levels of indentation deep. I’ve created a column called Tags into which I type short identifying tags next to every top-level item. I’d like to then have a saved filter that will show me only those items matching certain tags … AND all their descendant or children items.

Of course, I can’t do that. A saved filter can show me parent items for context, but not children items. So the workaround is to manually tag every child item - as many levels deep as necessary - the same as the parents. Okay, it’s kludgy, but it will work. And it’s massively labor-intensive.

Is there any way I can select a top-level item and assign the same tag to all its children items in one easy step, no matter how many levels of indentation there are? In other words, select a top-level item and “bulk tag” all the descendant items?

I’m assuming this is an Apple Script or OmniAutomation issue, but as I’m not conversant in such things, I thought I’d ask here. Can this be done?

If what you really need is:

  • to restrict the view to subtrees whose root nodes have some particular tag,
  • and some kind of scripting to achieve this,

then my guess is that:

  • row filters may not be what you need anyway,
  • and a script which applies a focus (as in View > Focus) to nodes which match your criteria might yield a more efficient flow of work.

(no need for bulk tagging)

I think you should be able to experiment with this omniJS plugin:

focus-by-tag.omnijs (1.6 KB)

if you:

  • download the .omnijs file linked above
  • drag it into the OO5 Pro Automation > Plugins window (image below)
  • and once it has appeared in that window:
    • Ctrl-click the toolbar, choosing Customize Toolbar,
    • and drag the focus-by-tag button for the plugin (near the bottom of the available buttons) into the toolbar.

Screenshot 2020-09-06 at 16.31.48


This rough draft makes various assumptions, and could benefit from some generalizations and additional checks, but the aim is that when you run it, and enter a tag string, it should focus down on the top level items that have that value in your Tag column, leaving their subtrees visible.


JS Source

/*{
    "type": "action",
    "name": "Focus by Tag column value",
    "author": "Rob Trew",
    "version": "0.01",
    "description": "Focus on items with a given value in the tag column."
}*/
(() => Object.assign(
    new PlugIn.Action(_ => {

        // main :: IO ()
        const main = () => [
                new Form.Field.String(
                    'tagValue',
                    'Tag value:'
                )
            ]
            .reduce(
                (frm, fld) => (
                    frm.addField(fld),
                    frm
                ),
                new Form()
            )
            .show(
                'Value to focus on:',
                'OK'
            )
            .then(result => {
                const
                    tag = result.values.tagValue,
                    outline = document.outline,
                    tagCol = outline.columns.byTitle(
                        'Tag'
                    ),
                    matchingRoots = outline.rootItem
                    .children.filter(item => {
                        const
                            v = item.valueForColumn(
                                tagCol
                            );
                        return v && (tag === v.string);
                    });
                document.editors[0]
                    .focusedItems = matchingRoots;
                console.log(
                    matchingRoots.map(x => x.topic)
                    .join('\n')
                );
            });

        // --------------- GENERIC FUNCTIONS ---------------
        // https://github.com/RobTrew/prelude-jxa

        // showLog :: a -> IO ()
        const showLog = (...args) =>
            console.log(
                args
                .map(JSON.stringify)
                .join(' -> ')
            );

        // MAIN ---
        return main();
    }), {
        validate: _ => true
    }
))();
1 Like

Hi Draft8,

Thank you so much for this. It almost does everything I want.

First, even though I dragged it into the Automation/Plug-Ins window, it does not show up as a choice in “Customize Toolbar”. I have to invoke it by opening the Automation Menu in my toolbar, where “focus-by-tag” appears as the first choice.

Second: I mistakenly wrote that I wanted to do this for top-level items, but actually I need it for any level item. This plug-in works only for top-level items with a certain tag. I actually need this to be able to work on any level item.

Third: I also need to see parent as well as descendant items for the tagged row. A Saved Filter will give me the parent items but not the children. This plug-in gives me children but not parents (logical, since top-level items don’t have parents). But I need a plug-in that will let me tag any item and then show me the parent and child rows for that item.

Sorry I didn’t get it right the first time. Can this script be adapted to do look for a tag in any row (no matter the level) and then give me parents and children?

If so, it’s a game-changer for me.

Again, thank you so much for your help. I really appreciate it.

You’re sure it’s not just visually elusive ?

but actually I need it for any level item

That should be manageable, though I may not be able to look at it again now until next weekend. (Others are very welcome to jump in, of course :-)

I also need to see parent as well as descendant items for the tagged row.

That sounds harder, I think – the OmniOutliner Focus view is subtree-only.

Incidentally if this kind of filtering and focusing is a central part of what you are doing, it might be sensible to experiment with TaskPaper 3, which provides rather more control through the user interface, without the need for scripting, and includes an outline-aware search language.

https://www.taskpaper.com/guide/using-taskpaper/fold-focus-and-filter.html

On second thoughts, try this one, which aims to:

  • find tag matches at all levels
  • give focus to the ancestors of any matches, revealing the sub-tree

tag-match-ancestors.omnijs.zip (1.8 KB)

JS Source
/*{
    "type": "action",
    "name": "Focus on ancestors of tagged items.",
    "author": "Rob Trew",
    "version": "0.02",
    "description": "Focus on ancestors of tagged items."
}*/
(() => Object.assign(
    new PlugIn.Action(_ => {

        // main :: IO ()
        const main = () =>
            // List of fields,
            [
                new Form.Field.String(
                    'tagValue',
                    'Tag value:'
                )
            ]
            // consolidated into a form,
            .reduce(
                (frm, fld) => (
                    frm.addField(fld),
                    frm
                ),
                new Form()
            )
            // and displayed.
            .show(
                'Value to focus on:',
                'OK'
            )
            // With processing of any captured values.
            .then(result => {
                const
                    tag = result.values.tagValue,
                    outline = document.outline,
                    tagCol = outline.columns.byTitle(
                        'Tag'
                    ),
                    taggedRows = outline.rootItem
                    .descendants.filter(item => {
                        const
                            v = item.valueForColumn(
                                tagCol
                            );
                        return v && (tag === v.string);
                    });

                // Effect :: focus on ancestors of matches.
                document.editors[0]
                    .focusedItems = commonAncestors(
                        taggedRows
                    ).flatMap(
                        x => last(x.ancestors)
                    );
            });

        // --------------- GENERIC FUNCTIONS ---------------
        // https://github.com/RobTrew/prelude-jxa

        // commonAncestors :: [OOItem] -> [OOItem]
        const commonAncestors = items => {
            // Only items which do not descend
            // from other items in the list.
            const
                dct = items.reduce(
                    (a, x) => (a[x.identifier] = true, a), {}
                );
            return items.filter(
                x => !until(
                    y => (null === y) || dct[y.identifier]
                )(v => v.parent)(x.parent)
            );
        };

        // last :: [a] -> a
        const last = xs => (
            // The last item of a list.
            ys => 0 < ys.length ? (
                ys.slice(-1)[0]
            ) : undefined
        )(list(xs));

        // list :: StringOrArrayLike b => b -> [a]
        const list = xs =>
            // xs itself, if it is an Array,
            // or an Array derived from xs.
            Array.isArray(xs) ? (
                xs
            ) : Array.from(xs || []);

        // showLog :: a -> IO ()
        const showLog = (...args) =>
            console.log(
                args
                .map(JSON.stringify)
                .join(' -> ')
            );

        // until :: (a -> Bool) -> (a -> a) -> a -> a
        const until = p => f => x => {
            let v = x;
            while (!p(v)) v = f(v);
            return v;
        };

        // MAIN ---
        return main();
    }), {
        validate: _ => true
    }))();
1 Like

Thank you Draft … I’ve re-booted and now the plug-in shows up and sits happily on my toolbar.

Thanks, also, if you (or anyone else) is able to adapt the script to look for tags at any level, not just top-level. The other tweak I’d need - since I sometimes have multiple tags on one line - is for the tag criterion to be “contains” instead of “is”.

As for Focus view being sub-tree only … oy. I’m desperate to find a way to see parents and children of tagged or filtered items. Ecco Pro did it, and it was the most useful app I’ve ever had. OmniOutliner comes tantalizingly close, but always seems to come up short here. Sigh.

Hmm … I downloaded it, added it to the Plug-Ins, put it up on the toolbar … and it doesn’t seem to do anything.

Is there some special syntax I need to use for a tag, or some special location for it?

I really like TaskPaper, but it’s just too vanilla for my needs. Among other things, I can’t imbed images - which I often need to do. But the ability to filter for a tag and see child and parent items is enviable.

Various possibilities, assuming that there were no installation glitches, and that nothing is being reported under Automation > Show Console.

  • It could be that your files are large, and that the OO engine can’t respond within reasonable time, with large outlines, to queries which entail tracking back right up the ancestral chain. (Try with very small files, as a check).
  • It could be that this model (focus on the top level ancestors of any tag matches) simply doesn’t fit the shape of your data.

If, for example, the whole outline had a single ancestral root, there would be no difference between:

  • focusing on the ancestor of a match
  • no focus at all

i.e. in the balance between over focus (no ancestors or descendants) and under focus (all ancestors and descendants), this definition of what you are after may just not work with your data.

Ah, I see what it does. I did have one ancestral root, so I added a few others. Running this script simply focusses on the ancestral root, but it shows EVERTHING in that root.

I do want to see a tagged line’s ancestors, but if I’ve tagged a 5th level item, I don’t want to see any other 5th level items. Just the one I’ve tagged and its ancestors. The way a Saved Filter does.

So here’s my latest thinking on this: if I tag, say, a 5th level item, and create a Saved Filter for that tag, it gives me that item and its ancestors just as I want, but it does not give me its descendants. However, if I tag every descendant line with the same tag, then they all show up. Of course, that’s very labor intensive. So the simplest thing for me right now would be to find a way - probably a script that I have no idea how to write - that would allow me to select an item and have a Tag entered into the Tag column for that line and all its descendants. Or maybe if I select all the descendant lines, a script could add the Tag to the Tag column for those lines. Then a Saved Filter will give me everything I need.

Do you think such a script is possible?

Thanks again for help. Much appreciated.

Last time through that iteration, you expressed a need to see the parent of tagged items:

I also need to see parent as well as descendant items for the tagged row.

Are you sure that your latest definition won’t still leave you wanting to see the parents of the highest level items that have tags ?

Narrowing the definition (and scope of focus) a bit: here’s a version which shows:

  • The tagged item
  • its immediate parent (rather than full ancestry)
  • its sub-tree

tag-match-parents.omnijs.zip (1.9 KB)

Notice however, that OmniOutliner does get slow for this kind of thing on scales of 1000 lines or more.

(If performance has a practical impact, you may need to balance it against things like the ability to embed images directly. TaskPaper 3 is a lot faster and more flexible for this kind of thing, but images would have to be included in the form of clickable file:// links)

JS Source
/*{
    "type": "action",
    "name": "Focus on parents of tagged items.",
    "author": "Rob Trew",
    "version": "0.03",
    "description": "Focus on parents of tagged items."
}*/
(() => Object.assign(
    new PlugIn.Action(_ => {

        // main :: IO ()
        const main = () =>
            // List of fields,
            [

                new Form.Field.String(
                    'tagValue',
                    'Tag value:'
                )
            ]
            // consolidated into a form,
            .reduce(
                (frm, fld) => (
                    frm.addField(fld),
                    frm
                ),
                new Form()
            )
            // and displayed.
            .show(
                'Value to focus on:',
                'OK'
            )
            // With processing of any captured values.
            .then(result => {
                const
                    tag = result.values.tagValue,
                    outline = document.outline,
                    tagCol = outline.columns.byTitle(
                        'Tag'
                    ),
                    taggedRows = outline.rootItem
                    .descendants.filter(item => {
                        const
                            v = item.valueForColumn(
                                tagCol
                            );
                        return v && (tag === v.string);
                    }),
                    parents = (
                        commonAncestors(taggedRows)
                        .map(x => x.parent)
                    );
                
                // Effect :: focus on parents of matches.
                document.editors[0].focusedItems = parents;
            });

        // --------------- GENERIC FUNCTIONS ---------------
        // https://github.com/RobTrew/prelude-jxa

        // commonAncestors :: [OOItem] -> [OOItem]
        const commonAncestors = items => {
            // Only items which do not descend
            // from other items in the list.
            const
                dct = items.reduce(
                    (a, x) => (a[x.identifier] = true, a), {}
                );
            return items.filter(
                x => !until(
                    y => (null === y) || dct[y.identifier]
                )(v => v.parent)(x.parent)
            );
        };

        // concat :: [[a]] -> [a]
        // concat :: [String] -> String
        const concat = xs => (
            ys => 0 < ys.length ? (
                ys.every(Array.isArray) ? (
                    []
                ) : ''
            ).concat(...ys) : ys
        )(list(xs));

        // last :: [a] -> a
        const last = xs => (
            // The last item of a list.
            ys => 0 < ys.length ? (
                ys.slice(-1)[0]
            ) : undefined
        )(list(xs));

        // list :: StringOrArrayLike b => b -> [a]
        const list = xs =>
            // xs itself, if it is an Array,
            // or an Array derived from xs.
            Array.isArray(xs) ? (
                xs
            ) : Array.from(xs || []);

        // showLog :: a -> IO ()
        const showLog = (...args) =>
            console.log(
                args
                .map(JSON.stringify)
                .join(' -> ')
            );

        // until :: (a -> Bool) -> (a -> a) -> a -> a
        const until = p =>
            // Iterative application of f to x 
            // until the resulting value matches p.
            f => x => {
                let v = x;
                while (!p(v)) v = f(v);
                return v;
            };

        // MAIN ---
        return main();
    }), {
        validate: _ => true
    }))();

Please forgive me if I didn’t explain that properly. I do want to see a tagged item’s ancestors, all the way up to the first-level item, but only those items in a direct line upward. What I don’t want to see is all that tagged item’s siblings and cousins. In other words, if I have a tree with multiple 3rd level items and I tag one of them, I want to see only that third-level item, its parent, and its grandparent. I don’t want to see any of the other 3rd level items. This script showed me the top-level item of the tagged item, and everything under it.

Your warning about OO being slow doing this sort of thing on lengthy outlines is an important point, which is why I’m leaning more towards my original request: a script that would allow me to more quickly and efficiently tag descendants when I tag an item. If I had that, I could simply use Saved Filters, which does work very quickly on lengthy outlines.

Again, many thanks for your generous help. It’s Labor Day Weekend, and you do deserve a day off.

You could experiment with this:

tag-subtree.omnijs.zip (1.7 KB)

JS Source
/*{
    "type": "action",
    "name": "Tag all descendants",
    "author": "Rob Trew",
    "version": "0.04",
    "description": "Apply tag to descendants of selected items."
}*/
(() => Object.assign(
    new PlugIn.Action(selection => {
        'use strict';

        // main :: IO ()
        const main = () =>
            // List of fields,
            [
                new Form.Field.String(
                    'colName',
                    'Column name:'
                ),
                new Form.Field.String(
                    'colValue',
                    'Value:'
                )
            ]
            // consolidated into a form,
            .reduce(
                (frm, fld) => (
                    frm.addField(fld),
                    frm
                ),
                new Form()
            )
            // and displayed.
            .show(
                'Tag column name, and tag value:',
                'OK'
            )
            // With processing of any captured values.
            .then(result => {
                const
                    outline = document.outline,
                    tagCol = outline.columns.byTitle(
                        result.values.colName
                    ),
                    cellValue = result.values.colValue,
                    selectedItems = commonAncestors(
                        document.editors[0]
                        .selection.items
                    ),
                    tagged = x => x.setValueForColumn(
                        cellValue, tagCol
                    );

                const
                    dteStart = new Date(),
                    tagCount = selectedItems.reduce(
                        (a, seln) => {
                            const xs = seln.descendants;
                            return (
                                tagged(seln),
                                xs.forEach(tagged),
                                1 + a + xs.length
                            );
                        },
                        0
                    );
                new Alert(
                    'Subtree tagging',
                    tagCount.toString() + (
                        ` '${cellValue}' tags applied in `
                    ) + `${(new Date() - dteStart) / 1000} seconds.`
                ).show();
            });

        // --------------- GENERIC FUNCTIONS ---------------
        // https://github.com/RobTrew/prelude-jxa

        // commonAncestors :: [OOItem] -> [OOItem]
        const commonAncestors = items => {
            // Only items which do not descend
            // from other items in the list.
            const
                dct = items.reduce(
                    (a, x) => (a[x.identifier] = true, a), {}
                );
            return items.filter(
                x => !until(
                    y => (null === y) || dct[y.identifier]
                )(v => v.parent)(x.parent)
            );
        };

        // until :: (a -> Bool) -> (a -> a) -> a -> a
        const until = p =>
            // Iterative application of f to x 
            // until the resulting value matches p.
            f => x => {
                let v = x;
                while (!p(v)) v = f(v);
                return v;
            };

        // MAIN ---
        return main();
    }), {
        validate: selection => 0 < selection.items.length
    }))();

That works better.

I wish I knew scripting, as I’d love to edit this so I didn’t have to enter the name of the column each time (it’s always the same) … and I’d love to have it not overwrite a tag that already exists in the Tag column. But with those two caveats I do find this occasionally useful. So thank you!

Good ! It doesn’t take long to learn :-)

(The key trick is to clearly define the pattern that you want to create)

Latest iteration in the definition of what seems to be envisaged :-)

(See Rich Hickey on Hammock-driven development for an excellent explanation of how to do all this a little faster and more productively: https://www.youtube.com/watch?v=f84n5oFoZBc)

fill-tags-in-subtree.omnijs.zip (1.9 KB)

/*{
    "type": "action",
    "name": "Fill blank Tag cells for all descendants",
    "author": "Rob Trew",
    "version": "0.06",
    "description": "Fill blank Tag cells for all descendants"
}*/
(() => Object.assign(
    new PlugIn.Action(selection => {
        'use strict';

        // main :: IO ()
        const main = () =>
            // List of fields,
            [
                new Form.Field.String(
                    'colValue',
                    'Value:'
                )
            ]
            // consolidated into a form,
            .reduce(
                (frm, fld) => (
                    frm.addField(fld),
                    frm
                ),
                new Form()
            )
            // and displayed.
            .show(
                'Value for empty tag cells:',
                'OK'
            )
            // With processing of any captured values.
            .then(result => {
                const
                    dteStart = new Date(),
                    outline = document.outline,
                    tagCol = outline.columns.byTitle(
                        'Tag'
                    ),
                    cellValue = result.values.colValue,
                    selectedItems = commonAncestors(
                        document.editors[0]
                        .selection.items
                    ),
                    n = selectedItems.flatMap(
                        seln => [seln].concat(
                            seln.descendants
                        ).flatMap(x => {
                            const v = x.valueForColumn(tagCol);
                            return (null === v) || (
                                '' === v.string
                            ) ? [(
                                x.setValueForColumn(
                                    cellValue,
                                    tagCol
                                ),
                                1
                            )] : [];
                        })
                    ).length;

                new Alert(
                    'Subtree tagging',
                    `${n} '${cellValue}' tag${1 < n ? 's' : ''} ` + (
                        `applied in ${(new Date() - dteStart) / 1000} seconds.`
                    )
                ).show();
            });

        // --------------- GENERIC FUNCTIONS ---------------
        // https://github.com/RobTrew/prelude-jxa

        // commonAncestors :: [OOItem] -> [OOItem]
        const commonAncestors = items => {
            // Only items which do not descend
            // from other items in the list.
            const
                dct = items.reduce(
                    (a, x) => (a[x.identifier] = true, a), {}
                );
            return items.filter(
                x => !until(
                    y => (null === y) || dct[y.identifier]
                )(v => v.parent)(x.parent)
            );
        };

        // showLog :: a -> IO ()
        const showLog = (...args) =>
            console.log(
                args
                .map(JSON.stringify)
                .join(' -> ')
            );

        // until :: (a -> Bool) -> (a -> a) -> a -> a
        const until = p =>
            // Iterative application of f to x 
            // until the resulting value matches p.
            f => x => {
                let v = x;
                while (!p(v)) v = f(v);
                return v;
            };

        // unwords :: [String] -> String
        const unwords = xs =>
            // A space-separated string derived
            // from a list of words.
            xs.join(' ');

        // MAIN ---
        return main();
    }), {
        validate: selection => 0 < selection.items.length
    }))();

Incidentally, reading and writing a cell are both physical processes which require a little time,
so the version which checks for incumbent values, rather than going straight ahead to write/overwrite,
will be noticeably slower at the scale of hundreds or thousands of lines.

Since fill-tags-in-subtree fills in Tags only in blank cells, it doesn’t work for me.

Again, the two changes I’d love to see made to tag-subtree are 1) add to, and not replace, an existing tag (as some cells will have multiple tags) and 2) automatically enter the last Column name in the form to save time in entering the same Column name over and over.

Thanks, as always for your help.