Move item to Project/Heading in OmniOutliner

Is there any way of moving tasks/lines in OmniOutliner to a specific heading like this in “TaskPaper” (see image)

Where project = Heading

This would help a lot not having to drill down in the outline and find the heading where you want to move the new task you just created at the top of the document.

Screenshot 2020-12-14 at 23.45.43

TaskPaper is probably a faster and more flexible instrument for reorganising outlines, but here, anyway, is a draft plug for OO.

It lets you move any selected rows (with their descendants) to the chosen heading row.
By default, the menu of destinations includes all rows at the top 3 levels of indentation.

You can adjust this, in the JS source of the plugin, by editing the line:

            // ----------------- OPTION ------------------
            const numberOfLevelsToShow = 3;

moveToHeading.omnijs.zip (2.1 KB)

JS Source
/*{
    "author": "Rob Trew",
    "targets": ["omnioutliner"],
    "type": "action",
    "identifier": "com.robtrew.moveToHeader",
    "version": "0.3",
    "description": "A plug-in that...",
    "label": "moveToHeader",
    "mediumLabel": "moveToHeader",
    "paletteLabel": "moveToHeader",
}*/
// Rob Trew @2020
(() => Object.assign(
    new PlugIn.Action(selection => {
        const main = () => {
            // ----------------- OPTION ------------------
            const numberOfLevelsToShow = 3;

            // ----- SELECTED ROWS TO CHOSEN HEADER ------
            const selectedRows = selection.items;
            return 0 < selectedRows.length ? (() => {
                const
                    outline = selection.outline,
                    editor = selection.editor,
                    headings = outline.rootItem
                    .children.flatMap(
                        ooNLevels(numberOfLevelsToShow)
                    );
                return (
                    ooMenuChoice(
                        row => '    '.repeat(
                            row.level - 1
                        ) + row.topic
                    )('Destination')('Heading')(headings)(
                        movedToHeading(editor)(outline)(
                            selectedRows
                        )
                    )
                );
            })() : 'No rows selected in OmniOutliner';
        };

        // ---------------- OMNIOUTLINER -----------------

        // ooNLevels :: Int -> Item -> [Item]
        const ooNLevels = nLevels =>
            // Contenation of the items in
            // the top N levels of the tree formed by 
            // the given row and all its descendants.
            row => {
                const go = n =>
                    x => 0 < n ? (
                        [x].concat(
                            x.children.flatMap(
                                go(n - 1)
                            )
                        )
                    ) : [];
                return go(nLevels)(row);
            };

        // movedToHeading :: Editor -> Outline ->
        // [Item] -> Dictionary -> IO ()
        const movedToHeading = editor => outline =>
            rows => dialogResult => {
                const heading = dialogResult.values.choice;
                console.log(
                    // As long as the target heading
                    // is not part of the selection or 
                    // any descendants of the selection:
                    !rows.flatMap(
                        row => [row.identifier].concat(
                            row.descendants.map(
                                x => x.identifier
                            )
                        )
                    )
                    .includes(
                        heading.identifier
                    ) ? (
                        'Moved to:\n' + heading.topic +
                        ':\n\t- ' + (
                            outline.moveItems(
                                rows,
                                heading.beginning
                            ),
                            editor.nodeForObject(
                                heading
                            ).expand(true),
                            rows
                            .map(x => x.topic)
                            .join('\n\t- ')
                        )
                    ) : (
                        new Alert(
                            'Move to header',
                            [
                                'Circular :: a header ',
                                "can't be an ancestor ",
                                'or descendant of itself.',
                                '\n\n\t' + heading.topic
                            ].join('')
                        )
                    ).show()
                );
            };

        // -------------------- MENU ---------------------

        // ooMenuChoice :: (a -> String) -> String -> 
        // String -> [a] -> Promise ()
        const ooMenuChoice = labelFromItem =>
            title => prompt => xs => {
                const menu = xs.map(labelFromItem);
                return userDialog([
                    new Form.Field.Option(
                        'choice',
                        prompt,
                        xs,
                        menu,
                        xs[0],
                        menu[0]
                    )
                ])(title);
            };


        // userDialog :: [Form.Field] ->
        // String -> values -> () -> Promise
        const userDialog = fields =>
            // General constructor for omniJS dialogs,
            // where f is a continuation function 
            // in which a dialog result
            // is bound to the function argument.
            prompt => f => fields.reduce(
                (form, field) => (
                    form.addField(field),
                    form
                ), new Form()
            )
            .show(prompt, 'OK')
            .then(f);

        // ------------------- GENERIC -------------------

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

        return showLog('main', main());
    }), {
        validate: selection => true
    }))();
1 Like

Installing an .omnijs action:

  • Save the source code in a file with the extension .omnijs
  • in the Omni app, open Automation > Configure...
  • drag the .omnijs file onto the window which appears.

Using the action:

Two options:

  1. The action, once installed, appears by name in the apps Automation menu
  2. You can optionally Ctrl-click the (app or Console window) toolbar and then
    • choose Customize Toolbar,
    • visually hunt for a green icon with the name of the action,
    • drag it onto the toolbar, where it can be clicked whenever you need it.
1 Like

Thank you for you´re quick and detailed response :)

I tried this plugin, but it looks like this way will quickly grow out of hand as the outline grows (see image) so I think I will stick to TaskPaper for now.

But still thank you very much :)

This is very helpful for me. Thanks!

By mistake I chose to move an item to itself and had to force quit.

Can you build in a safeguard against that?

(Also, would much appreciate suggestion on an action to “flatten” outline here.)

Understood :-)

( You could experiment with setting numberOfLevelsToShow to 2 (or even 1), for a sparser target listing, but that won’t, of course be helpful in all workflows )

(It depends a bit on how your project nodes are defined or identified)

Good catch – thanks for testing it.

Two possible approaches come to mind, both using the .identifier properties of the chosen target header and selected rows:

  • Exclude all selected rows (and their descendants) from the target header listing
  • or include them, to allow for a full map of the document at the given level, but warn and desist if an inadvertent attempt is made to insert a row under itself.


I’ve updated the plugin above to version 3, using the approach of showing all headings at the specified levels, but not allowing a recursive move, and showing an explanatory message if one is accidentally attempted.

If you can send a small anonymised test file to me here by direct message, I’ll take a look.

I haven’t looked closely at what the raw API offers, but if there’s nothing direct, then we may just need to:

  1. capture the grouped outline in a generic tree structure,
  2. capture level 2 and discard level 1

for the generic tree structure:

// 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 || []
    });

// pureTreeOO :: OOItem  -> Tree OOItem
const pureTreeOO = item => {
    const go = x =>
        Node(x)(x.hasChildren ? x.children.map(go) : []);
    return go(item);
};

and to split the tree horizontally into levels:

// levels :: Tree a -> [[a]]
const levels = tree =>
    // A list of lists, grouping the 
    // root values of each level 
    // of the tree.
    cons([tree.root])(
        tree.nest
        .map(levels)
        .reduce(
            uncurry(zipWithLong(append)),
            []
        )
    );


// append (<>) :: [a] -> [a] -> [a]
const append = xs =>
    // Two lists joined into one.
    ys => xs.concat(ys);


// uncurry :: (a -> b -> c) -> ((a, b) -> c)
const uncurry = f =>
    // A function over a pair, derived
    // from a curried function.
    function() {
        const
            args = arguments,
            xy = Boolean(args.length % 2) ? (
                args[0]
            ) : args;
        return f(xy[0])(xy[1]);
    };


// zipWithLong :: (a -> a -> a) -> [a] -> [a] -> [a]
const zipWithLong = f => {
    // A list with the length of the *longer* of 
    // xs and ys, defined by zipping with a
    // custom function, rather than with the
    // default tuple constructor.
    // Any unpaired values, where list lengths differ,
    // are simply appended.
    const go = xs =>
        ys => 0 < xs.length ? (
            0 < ys.length ? (
                [f(xs[0])(ys[0])].concat(
                    go(xs.slice(1))(ys.slice(1))
                )
            ) : xs
        ) : ys
    return go;
};

( https://github.com/RobTrew/prelude-jxa )

Or perhaps, in terms of Item.descendants, just a new document constructed from something like:

rootItem.descendants.filter(x => 2 === x.level)

That’s gives me a great start with some ideas. Thanks! For a test document I’ve just been using the one for ACME Customers downloadable in the Organize Items section at https://omni-automation.com/omnioutliner/outline.html. It has the kind of multicolumn structure I use for this kind of simple reorganization (similar to the Categories feature in Numbers, though that one can easily go down several levels). Hope to keep the same document rather than generate a new one.

Not sure if it is relevant, but have you experimented with Outline.ungroup ?

In the console, this seems to work:

ungroup(rootItem.children)

(Though it leaves stranded – childless – group labels. Which could be cleared up.)

I was originally thinking Outline.ungroup must be the answer. But I don’t have a coding background, just a rudimentary knowledge of JavaScript and a little more experience with AppleScript, and am slow at visualizing how to read a listing in the API (I think Outline.ungroup has neither documentation nor any examples I could find on omni-automation.com site to study) and then actually get the thing to work in a script.

I quite understand. The existing documentation does assume more concepts than it supplies, and the site is rather wordy and dense – a bit over-rich in forests of detail, and rather hazier and weaker on clear overview and fundamental concepts.

I never like to suggest code which includes deletion, but here is a basic sketch of one approach to:

  • ungrouping,
  • and then selecting the now parentless group labels.

(in place of selecting them, you could of course, with dummy data, experiment with deleting them, but do take care and make backups if you take that road …)

JS Source
/*{
    "author": "Author Name",
    "targets": ["omnioutliner"],
    "type": "action",
    "identifier": "com.mycompany.ungroup",
    "version": "0.1",
    "description": "A plug-in that...",
    "label": "unGroup",
    "mediumLabel": "unGroup",
    "paletteLabel": "unGroup",
}*/
(() => Object.assign(
    new PlugIn.Action(selection => {
        const main = () => {
            const
                editor = selection.editor, 
                topLevelRows = rootItem.children;
            // If all top level items have children
            // capture the top level identifiers
            // ungroup
            // and select the (now) childless orginal top level items
            // possibly for deletion.
            return topLevelRows.every(
                x => x.hasChildren
            ) ? (
                    selection.outline.ungroup(topLevelRows),
                    topLevelRows.map(
                        x => (
                            // In Editor, updated.
                            // Could be deleted ...
                            editor.nodeForObject(x).isSelected = true,

                            // In Console, appended to log.
                            x.topic
                        )
                    ).join(', ')
             ) : 'May not be grouped by column.';
        };

        return console.log(main());
    }), {
        validate: selection => true
    }
))();
1 Like

Thank you very much for this!

1 Like

Thanks for making this great automation! I have some questions about it.

How to keep the cursor / selection at the current (Doing) line? When moving lines from Doing to Done I want to keep the cursor or selected row at Doing. Is this possible?

Right now all the rows under Done will expand after moving. In other documents I have a lot of collapsed lines under Done. How to prevent this from happening?

collapsed

Others are probably better placed to advise.

(I’m not making use of OmniOutliner at the moment)

Do you know which members on this forum can probably help with this?

I can’t speak for how others are placed, but perhaps you could offer it to anyone here who posts omniJS plugins.

The best way to price is simply to:

  1. make a serious private assessment of how much it would be worth to you, over its life-cycle, in time redirected to more productive activity, and then
  2. offer half of that to the contractor, to allow margin for both win/win and a bit of negotiating flexibility.

If half of its real value to you is too little to be worth the time it would take others to produce, then that may be a helpful indicator that you can probably do without it, in practice.

Thanks for your suggestion! I’ll think about that.

Why did you stop using OmniOutliner? Is there a chance that you’ll be using this Move Script with OmniOutliner in the future?

I generally prefer less modal outliners with fewer widgets – TaskPaper in particular – but occasionally used OmniOutliner in contexts where I needed unique identifiers for nodes in the outline.

As it happens I’m getting that now from a working prototype of another light-weight outliner (not released, at this point).

I don’t think I’ll personally be making much use of OmniOutliner, though I wish it well.