Navigate to most recently completed task (OmniJS Plug-In)

I was inspired by Rosemary’s Complete and Await Reply sample action but find that I am too excited to click the tasks complete button and forget to run her action. Instead, I wanted to create a plugin that would navigate me to the parent of the most recently completed task where I can then manually create the follow-up task. I ran into a couple challenges that maybe other have found better ways to overcome.

Retrieve tasks of perspectives
I have a perspective that shows completed tasks. The most recently completed task is at the top of this list so I tried to retrieve it programatically. With the exception of the inbox, I don’t see a way to retrieve the list of tasks in a perspective without first navigating the window to the perspective.

Modifying the UI unnecessary is distasteful so I instead did a filter() over all tasks in the database. I assume that the query engine used in the perspective is more efficient than JavaScript filter() so it would have been nice to expose it to the automation.

Retrieving parent tasks
Navigating to a task with the omnifocus:///task/ conveniently highlights the task in the Project view. Since some of my projects are large and use task groups, I wanted to highlight the remaining parent of the completed task. I could not figure out a way to directly retrieve the parent of a task and had to resort to an inefficient recursive search on the project. Is there a better way to find a parent task?

URL to completed inbox actions
The OmniFocus:///task/ link for a completed task in the inbox appears to not update the UI.

Completed Plugin
The resulting solution I found is below. Do you have a better way to do this?

/*{
    "author": "Mark Patterson",
    "targets": ["omnifocus"],
    "type": "action",
    "identifier": "local.patterson.mark.omnifocus.last_completed",
    "version": "0.1",
    "description": "A plug-in that opens the project of the last completed action",
    "label": "View project of last completed action",
    "mediumLabel": "View project of last completed action",
    "paletteLabel": "View project of last completed action",
}*/
(() => {
    var action = new PlugIn.Action(function(selection) {
        
        // Find the most recently completed task
        var last_task = flattenedTasks.reduce((a,b)=>a.completionDate > b.completionDate ? a : b) ;
        if (!last_task)
            return ;
        else if (last_task.project && last_task.project.id.primaryKey == last_task.id.primaryKey) {
            // Project root task
            var url = "omnifocus:///task/" + last_task.id.primaryKey ;
            URL.fromString(url).call(reply=>{}) ;
            return ;
        }
        
        // Find all parents of the task with a recursive search
        function find_parents_recursive(objs) {
            for (var i = 0 ; i < objs.length ; i++) {
                if (objs[i] == last_task) {
                    return [objs[i]] ;
                } else if (objs[i].hasChildren) {
                    var p = find_parents_recursive(objs[i].children) ;
                    if (p) {
                        p.push(objs[i]) ;
                        return p ;
                    }
                }
            }
            return null ;
        }
        var root ;
        if (!last_task.containingProject) // inInbox doesn't work for task groups
            root = inbox ;
        else
            root = [last_task.containingProject] ;
        var parents = find_parents_recursive(root) ;
        
        // Choose the lowest remaining parent
        var target = last_task ;
        for (var i = 0 ; i < parents.length ; i++) {
            if (!parents[i].completed) {
                target = parents[i] ;
                break ;
            }
        }
        
        // Navigate to the task parent
        var url ;
        if (target.completed && !target.containingProject) {
            // Omnifocus doesn't automatically display for completed tasks in inbox
            url = "omnifocus:///inbox" ;
        } else {
            url = "omnifocus:///task/" + target.id.primaryKey ;
        }
        URL.fromString(url).call(reply=>{})

    });
        
    return action;
})();

As you say, filtering completed tasks…

flattenedTasks.filter(x => x.completed)

And then, perhaps sorting by completionDate

xs.sort(
    (x, y) => x.completionDate >= y.completionDate ? (
        -1
    ) : x.completionDate <= y.completionDate ? (
        1
    ) : 0
)

Accessing the first element of that list using bracket notation [0].

Full code (Mac):

(() => {
    'use strict';

    // OMNI JS CODE ---------------------------------------
    const omniJSContext = () => {
        // main :: IO ()
        const main = () => {
        	const
                xs = flattenedTasks.filter(x => x.completed);
                
            return 0 === xs.length ? (
                'No completed tasks in the database.'
            ) : `Name of last completed task is: "${
                xs.sort(
                    (x, y) => x.completionDate >= y.completionDate ? (
                        -1
                    ) : x.completionDate <= y.completionDate ? (
                        1
                    ) : 0
                ).map(x => x.name)
            }"`
        };

        // MAIN -----------------------------------------
        return main()
    };


    // OmniJS Context Evaluation ------------------------------------------------
    return 0 < Application('OmniFocus').documents.length ? (
        Application('OmniFocus').evaluateJavascript(
            `(${omniJSContext})()`
        )
    ) : 'No documents open in OmniFocus.'
})();

Perhaps implementing an ancestors function ?

// ancestors :: OFItem -> [OFItem]
const ancestors = item => {
    const proj = item.containingProject;
    return compose(
        filterTree(x => elem(item)(
            x.flattenedTasks
        )),
        fmapPureOF(identity)
    )(proj)
};

// FUNCTIONS --
// fmapPureOF :: (OFItem -> a) -> OFItem -> Tree a
const fmapPureOF = f => item => {
    // OMNIJS Interface
    const go = x => {
        const v = (x instanceof Project) ? (
            x.task
        ) : x
        return Node(f(x))(
            v.hasChildren ? (
                v.children.map(go)
            ) : []
        );
    }
    return go(item);
};

When you have a list of ancestors, you can retrieve the last item of that list (in case it is non-empty). That would be its parent task.

Can we please rename this thread to “plugin to navigate to…” ?

Might sound pedantic but twice now I’ve got completely the wrong end of the stick as to what this (valuable) thread is about.

Thanks.

1 Like

I don’t think I have permissions to rename a topic.

1 Like

I hope you don’t mind, @Cleobis. I just renamed the topic.

1 Like

Thank you for taking the time to look at this Gabriel (@unlocked2412). It has been years since I learned JavaScript and I see many things in your code (like const and better use of wrapper functions) I need to brush up on.

For finding the most recently closed action, I expect reduce() to be more efficient than sort() (O(n) for reduce vs O(n log(n))) for sort). On my database it is a difference of 0.05 s vs 0.5 s. This is somewhat moot as the real performance hit is having to run the operation in JavaScript rather than being able to tap into the Perspectives engine.

I have been looking at your ancestors() implementation. I am relieved that there isn’t a simple built-in way that I was missing. I confess that I can’t figure out how to run it. Did you meant this as pseudo-code or are you using additional libraries? I don’t see how filterTree(), Node(), compose(), or identity are declared. I have been doing all my development in pure OmniAutomation plugins. I don’t have any experience with JXA but also don’t think the dependencies are resolved there either. I do have something that more or less works so I’m not asking for you to rewrite it.

It has been edifying to see how the problem can be tackled from a different perspective.

1 Like

Thank you! Now it’s much clearer what this thread is about.

1 Like

Sorry. You’re right. I will add complete definitions and look at your suggestions in a few hours.

I enjoyed looking at what you did, @Cleobis. You’re welcome.

Some of the functions are used can be found in this wonderful library:

I added every definition and tidied ancestors function.

Full code (Mac):

(() => {
    'use strict';

    // OMNI JS CODE ---------------------------------------
    const omniJSContext = () => {
        // main :: IO ()
        const main = () => {
            const
                xs = filter(x => x.completed)(
                    flattenedTasks
                ),
                lrAncestor = bindLR(
                    0 === xs.length ? (
                        Left('No completed tasks in the database.')
                    ) : Right(xs)
                )(
                    compose(
                        Right,
                        last,
                        ancestors,
                        last,
                        sortOn(x => x.completionDate)
                    )
                )
            return isLeft(lrAncestor) ? (
                lrAncestor.Left
            ) : (() => {
                const
                    ancestor = lrAncestor.Right,
                    k = toLower(
                        ancestor.constructor.name
                    ),
                    strID = ancestor.id.primaryKey;
                return URL.fromString(
                    `omnifocus:///task/${strID}`
                ).open()
            })()
        };


        // FUNCTIONS --
        // JS Prelude --------------------------------------------------
        // 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 =>
            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);

        // comparing :: (a -> b) -> (a -> a -> Ordering)
        const comparing = f =>
            x => y => {
                const
                    a = f(x),
                    b = f(y);
                return a < b ? -1 : (a > b ? 1 : 0);
            };

        // 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
            );

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

        // concatMap :: (a -> [b]) -> [a] -> [b]
        const concatMap = f =>
            // Where (a -> [b]) returns an Array, this 
            // is equivalent to .flatMap, which should be
            // used by default.
            // but if (a -> [b]) returns String rather than [Char], 
            // the monoid unit is '' in place of [], and a 
            // concatenated string is returned.
            xs => {
                const ys = list(xs).map(f);
                return 0 < ys.length ? (
                    ys.some(y => 'string' !== typeof y) ? (
                        []
                    ) : ''
                ).concat(...ys) : ys;
            };

        // cons :: a -> [a] -> [a]
        const cons = x =>
            // A list constructed from the item x,
            // followed by the existing list xs.
            xs => Array.isArray(xs) ? (
                [x].concat(xs)
            ) : 'GeneratorFunction' !== xs
            .constructor.constructor.name ? (
                x + xs
            ) : ( // cons(x)(Generator)
                function* () {
                    yield x;
                    let nxt = xs.next();
                    while (!nxt.done) {
                        yield nxt.value;
                        nxt = xs.next();
                    }
                }
            )();

        // elem :: Eq a => a -> [a] -> Bool
        const elem = x =>
            // True if xs contains an instance of x.
            xs => {
                const t = xs.constructor.name;
                return 'Array' !== t ? (
                    xs['Set' !== t ? 'includes' : 'has'](x)
                ) : xs.some(eq(x));
            };

        // eq (==) :: Eq a => a -> a -> Bool
        const eq = a =>
            // True when a and b are equivalent in the terms
            // defined below for their shared data type.
            b => {
                const t = typeof a;
                return t !== typeof b ? (
                    false
                ) : 'object' !== t ? (
                    'function' !== t ? (
                        a === b
                    ) : a.toString() === b.toString()
                ) : (() => {
                    const kvs = Object.entries(a);
                    return kvs.length !== Object.keys(b).length ? (
                        false
                    ) : kvs.every(([k, v]) => eq(v)(b[k]));
                })();
            };

        // fanArrow (&&&) :: (a -> b) -> (a -> c) -> (a -> (b, c))
        const fanArrow = f =>
            // A function from x to a tuple of (f(x), g(x))
            // ((,) . f <*> g)
            g => x => Tuple(f(x))(
                g(x)
            );

        // filter :: (a -> Bool) -> [a] -> [a]
        const filter = p =>
            // The elements of xs which match
            // the predicate p.
            xs => [...xs].filter(p);

        // filterTree (a -> Bool) -> Tree a -> [a]
        const filterTree = p =>
            // List of all root values in the tree
            // which match the predicate p.
            foldTree(x => xs => concat(
                p(x) ? [
                    [x], ...xs
                ] : xs
            ));

        // 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(tree.root)(
                tree.nest.map(go)
            );
            return go;
        };

        // fst :: (a, b) -> a
        const fst = tpl =>
            // First member of a pair.
            tpl[0];

        // identity :: a -> a
        const identity = x =>
            // The identity function. (`id`, in Haskell)
            x;

        // isLeft :: Either a b -> Bool
        const isLeft = lr =>
            ('Either' === lr.type) && (undefined !== lr.Left);

        // keys :: Dict -> [String]
        const keys = Object.keys;

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

        // length :: [a] -> Int
        const length = xs =>
            // Returns Infinity over objects without finite
            // length. This enables zip and zipWith to choose
            // the shorter argument when one is non-finite,
            // like cycle, repeat etc
            'GeneratorFunction' !== xs.constructor.constructor.name ? (
                xs.length
            ) : Infinity;

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

        // 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);

        // nest :: Tree a -> [a]
        const nest = tree => {
            // Allowing for lazy (on-demand) evaluation.
            // If the nest turns out to be a function –
            // rather than a list – that function is applied
            // here to the root, and returns a list.
            const xs = tree.nest;
            return 'function' !== typeof xs ? (
                xs
            ) : xs(root(x));
        };

        // root :: Tree a -> a
        const root = tree => tree.root;

        // snd :: (a, b) -> b
        const snd = tpl =>
            // Second member of a pair.
            tpl[1];

        // sort :: Ord a => [a] -> [a]
        const sort = xs => list(xs).slice()
            .sort((a, b) => a < b ? -1 : (a > b ? 1 : 0));

        // sortOn :: Ord b => (a -> b) -> [a] -> [a]
        const sortOn = f =>
            // Equivalent to sortBy(comparing(f)), but with f(x)
            // evaluated only once for each x in xs.
            // ('Schwartzian' decorate-sort-undecorate).
            xs => list(xs).map(
                fanArrow(f)(identity)
            )
            .sort(uncurry(comparing(fst)))
            .map(snd);

        // toLower :: String -> String
        const toLower = s =>
            // Lower-case version of string.
            s.toLocaleLowerCase();

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

        // OmniFocus OmniJS --------------------------------------------
        // ancestors :: OFItem -> [OFItem]
        const ancestors = item => {
            const proj = item.containingProject;
            return compose(
                concatMap(filterTree(
                    x => elem(item)(
                        x.flattenedTasks
                    )
                )),
                map(fmapPureOF(x => x))
            )(
                null === proj ? (
                    inbox
                ) : proj.children
            )
        };

        // fmapPureOF :: (OF Item -> a) -> OF Item -> Tree a
        const fmapPureOF = f => item => {
            const go = x => Node(f(x))(
                x.children.map(go)
            );
            return go(item);
        };

        // MAIN -----------------------------------------
        return main()
    };


    // OmniJS Context Evaluation ------------------------------------------------
    return 0 < Application('OmniFocus').documents.length ? (
        Application('OmniFocus').evaluateJavascript(
            `(${omniJSContext})()`
        )
    ) : 'No documents open in OmniFocus.'
})();

The version I posted above shows completed tasks in the inbox. Could you check ?

That’s interesting. Could you check how does it perform ? How are you timing the functions ?

This is an excellent tip! Thank you for the updated ancestors code.

It appears to only be an issue with the preference to show the inbox in the projects perspective and depends on which perspective you start in. I will investigate.

The below action will time them. I am using Date.now() as a millisecond clock. Run it at least twice due to caching. Interestingly Omnifocus on my iPad runs this operation faster than my Mac.

/*{
	"type": "action",
	"targets": ["omnifocus"],
	"author": "M P",
	"identifier": "local.patterson.mark.omnifocus.test_time",
	"version": "1.0",
	"description": "Action Description",
	"label": "test time",
	"shortLabel": "test time"
}*/
(() => {
	var action = new PlugIn.Action(function(selection, sender){
		// action code
		// selection options: tasks, projects, folders, tags, allObjects
		
		// query with sort
		let ts = Date.now()
		const last_task_s = flattenedTasks.sort(
		    (x, y) => x.completionDate >= y.completionDate ? (
		        -1
		    ) : x.completionDate <= y.completionDate ? (
		        1
		    ) : 0
		)[0] ;
        ts = Date.now() - ts
		
		// query with reduce
		let tr = Date.now()
		const last_task_r = flattenedTasks.reduce((a,b)=>a.completionDate > b.completionDate ? a : b) ;
        tr = Date.now() - tr
		
		new Alert("Timing results","Reduce: " + tr + "\nSort: " + ts).show();
	});
	return action;
})();
1 Like

It might be worth looking at maximumBy and minimumBy (in the JS Prelude repository), which correspond to your fold / reduce there.

e.g. something like:

maximumBy(
    comparing(x => x.completionDate)
)(flattenedTasks)

and @unlocked2412 has pointed out to me that comparing can be expensive in this context (repeating date requests over the automation API), so perhaps you could also try:

maximumOn(x => x.completionDate)(
    flattenedTasks
)

where one implementation might be:

// maximumOn :: (Ord b) => (a -> b) -> [a] -> a
const maximumOn = f =>
    // The item in xs for which f 
    // returns the highest value.
    xs => 0 < xs.length ? (() => {
        const h = xs[0];
        return xs.slice(1).reduce(
            (tpl, x) => {
                const v = f(x);
                return v > tpl[1] ? [
                    x, v
                ] : tpl;
            },
            [h, f(h)]
        )[0];
    })() : undefined;

// minimumOn :: (Ord b) => (a -> b) -> [a] -> a
const minimumOn = f =>
    // The item in xs for which f 
    // returns the highest value.
    xs => 0 < xs.length ? (() => {
        const h = xs[0];
        return xs.slice(1).reduce(
            (tpl, x) => {
                const v = f(x);
                return v < tpl[1] ? [
                    x, v
                ] : tpl;
            },
            [h, f(h)]
        )[0];
    })() : undefined;

(I’ve just pushed drafts of maximumOn and minimumOn to the JS Prelude on GitHub)

1 Like

Incidentally, for when you do need sorts (for the positions of many elements rather than just one) you may find that the pattern of sort in your timed example above will be rather slower (duplicated data-fetches across the API) than the sortOn below, which uses a decorate-sort-undecorate pattern to fetch each date (or other derived value) only once.

// sortOn :: Ord b => (a -> b) -> [a] -> [a]
const sortOn = f =>
    // Equivalent to sortBy(comparing(f)), but with f(x)
    // evaluated only once for each x in xs.
    // ('Schwartzian' decorate-sort-undecorate).
    xs => list(xs).map(
        fanArrow(f)(identity)
    )
    .sort(uncurry(comparing(fst)))
    .map(snd);

Essentially, if applied (for example) to flattenedTasks and completionDates, it:

  • Creates list of (Task, Date) tuples (Each date fetched from the API just once)
  • Sorts that tuple list by its Date values
  • Maps from the ordered tuples back to an ordered [Task] list.
1 Like

This is a subtle but excellent point.

1 Like

Enjoy the omniJS API !