How to select nodes with no children?


#1

Is there an equivalent of //[not()]?

I realize I can do an except once I have a full set, but what I want to do is select the first task of all leaf tasks under any given project as a next action.

That means I need to be able to do this before slicing within the xpath, so set operations don’t work–I need an exclusion within the xpath.

This is so I can pull next actions from within subtasks as sequential things while keeping subprojects as things I can do in parallel. It’s essentially the project//task not @done[0] example from the docs, except the last axis should be “{tasks without children} not @done[0].”


TaskPaper 3.4 Plans, More search and less toolbar
#2

Perhaps (all nodes) except (parents of anything) ?

//* except //*/parent::*

#3

Responded too quickly :slight_smile:

Is the context that you want to do it purely as an itemPath expression, without JavaScript ?

(for example, flagging the first non-complete item in each range of sibling leaves as @next)

function run() {

    // TASKPAPER CONTEXT
    function TaskPaperContext(editor, options) {
        var outline = editor.outline;

        // HOUSEKEEPING Clear @next tags from items already @done
        outline.evaluateItemPath('//@next and @done')
            .forEach(function (item) {
                return item.removeAttribute('data-next');;
            });

        // And now tag the first incomplete sibling,
        // in each sibling leaf range, as @next

        return Object.keys(
            outline.evaluateItemPath(
                '(//(not @done) except //*/parent::*)'
            )
            .reduce(function (a, item) {
                var idParent = item.parent.id;

                return (
                    a[idParent] = a[idParent] || 1,
                    a
                );
            }, {})
        )

        // First child of each lowest level parent
        .map(function (id) {
            var lstMatch = outline.evaluateItemPath(
                '//@id=' + id + '/child::(not @done)[0]'
            )
            return lstMatch.length ? lstMatch[0] : undefined;
        })


        // We now have a list of firstborn.
        // Here are their strings, for example:
        // .map(function (x) {
        //     return x.bodyString;
        // })

        // flagging them as next
        .forEach(function (item) {
            if (item) item.setAttribute('data-next', '');
        })
    }


    // JAVASCRIPT FOR AUTOMATION CONTEXT
    var ds = Application('com.hogbaysoftware.TaskPaper3')
        .documents;

    if (ds.length) {
        return ds[0].evaluate({
            script: TaskPaperContext.toString(),
            withOptions: {}
        });
    }
}


#4

Set operations should work together with slicing. You just need to use parentheses. So maybe something like to find first item with no children.

(//* except //*/parent::*)[0]

#5

Tho I guess that gives the first first-born of the whole tree,
rather than the first-born of each lowest-level parent ?

Update:

I’ve edited the draft above to a simple version of:

  • flag the first incomplete leaf (in each sibling range) as @next

#6

Yeah, that’s basically the issue. Jesse’s is like the final example in the Query docs where if you put the whole expression in parens you get one next action instead of one per project. I want one per parent project.

To be clearer, if I have:

Project Foo:
	- Task 1
	SubProject A:
		- Task 1
			- Subtask a
			- Subtask b
		- Task 2
			- Subtask a
			- Subtask b
	SubProject B:
		- Task 1
			- Subtask a
			- Subtask b
		- Task 2
			- Subtask a
			- Subtask b

…then I want it to pick Foo-1, Foo-A-1-a, and Foo-B-1-a. In other words, the first childless task found under each project. These would be my next actions assuming any subproject can be done in parallel with its siblings, but subtasks cannot. I use this setup a lot, coming from OmniFocus.

In Xpath I’d do this with a node predicate of no children i.e., //*[not(*)], but the bracket constructs to test for attributes/parent/child/etc aren’t part of FT/TP as far as I can tell. We have attribute tests broken out into the = syntax, but no way to test for parentage.

Instead, to select nodes based on parentage you have to do something like *//*/child::*/parent::* which effectively gives you all nodes with children by dipping down and back up.

But that technique doesn’t work when you’re trying to select something without children at all, and I’m guessing this is a clear gap in the query syntax.

Complexpoint’s script will work, but being able to select a leaf node is important enough in xpath selectors that I hope we might find a built-in solution here too for the same reasons. It’s also how I would select Projects with no next actions under them for review, and a few other things like that.

Edit: fixed example tabbing, I hope.


#7

So perhaps

//*/child::(not @done)[0] except //*/parent::*

?


#8

Think that’ll give you back Foo-A-2-a and Foo-B-2-a too, but I’ll try them.


#9

function run() {

    // TASKPAPER CONTEXT
    function TaskPaperContext(editor, options) {
        var outline = editor.outline;

        return outline.evaluateItemPath(
                '//*/child::(not @done)[0] except //*/parent::*'
            )
            .map(function (x) {
                return x.bodyString;
            })

    }


    // JAVASCRIPT FOR AUTOMATION CONTEXT
    var ds = Application('com.hogbaysoftware.TaskPaper3')
        .documents;

    if (ds.length) {
        return ds[0].evaluate({
            script: TaskPaperContext.toString(),
            withOptions: {}
        });
    }
}

#10

Yeah–looks like it did pick up the two Subtask 2-a nodes. You really do need to be able to flatten all the childless tasks under a project to make this work as part of a selector.

Anyway, not stumping for “make my scheme work, pls” so much as that the node selection syntax is somewhat incomplete by not letting you select on not having children of a given type (or at all). There’s a lot of queries in which that’s useful, and outside of pre-processing in a script I don’t think there’s actually a workaround.

The flagging as @next from a script works, I guess, but I really liked the idea of just being able to query for next actions and mark them for @today. I didn’t want to have to mess around with an extra tag. Jesse’s doc example was so close to that for me.


#11

I guess another approach is to start with a flat list of projects, and apply the query to each:

function run() {

    // TASKPAPER CONTEXT
    function TaskPaperContext(editor, options) {

        // concatMap :: (a -> [b]) -> [a] -> [b]
        function concatMap(f, xs) {
            return [].concat.apply([], xs.map(f));
        }


        var outline = editor.outline;

        return concatMap(
            function (item) {
                return outline
                    .evaluateItemPath(
                        '(descendant::(not @done) except //*/parent::*)[0]',
                        item
                    )
                    .map(function (x) {
                        return x.bodyString;
                    });
            }, outline
            .evaluateItemPath('//project')
        );


    }


    // JAVASCRIPT FOR AUTOMATION CONTEXT
    var ds = Application('com.hogbaysoftware.TaskPaper3')
        .documents;

    if (ds.length) {
        return ds[0]
            .evaluate({
                script: TaskPaperContext.toString(),
                withOptions: {}
            });
    }
}

#12

Thanks for your thoughts and feedback.

I guess this can be solved by just adding a new built in item attribute? For example I can add “@leaf” which will only be present for items without children. If that’s the case does “leaf” seem like a good attribute name to everyone?


#13

Another alternative would be to add a some sort of “count” attribute value. Child count or descendent count? Would be able to solve this problem and maybe be useful in other cases too.


#14

Count sounds good – more flexibly useful

Is child count less expensive than descendant count ?

(The latter is quickly derived when you need it)


#15

I like count as an attribute better than @leaf, so long as all the usual < > type operations work on it.

One downside in making this an absolute attribute vs. a query predicate like Xpath is there’ll be no way to express something like “node with no subnodes that have the @done attribute”, since the “no subnodes” part isn’t dynamic. I think that’s why Xpath lets you put what amounts to a descendant subquery in the node selection brackets.

One possible compromise here is to make it absolute but automatically exclude @done from the count, since that’s probably the one attribute nearly everybody would want to filter. That’s starting to get pretty esoteric as behavior goes, but maybe it’s the right esoteric?


#16

My XPath skills are rusty and incomplete. Can you show me what the “node with no subnodes that have the @done attribute” query would look like in XPath?


#17

Ok… I think I get what you mean now. You mean another option would be to add support for some Xpath functions such as not and count? That would be the most generic and powerful solution, at the expense of adding a new construct to TaskPaper’s query language. I’m going to think about this for a bit, but think I might go in that direction.


#18

Sorry for the late reply, but that’s what I meant, yes. I’m only really as good at Xpath as Selenium expertise demands, but afaict it amounts to a lookahead query on the subtree (or lookbehind on the parent path) of the candidate node in question.

Where it differentiates from stuff we’ve talked about before is its ability to prune a selection traversal at a given node based on related nodes’ characteristics.


#19

I’m implementing this now for the next release. Here’s what I think the query will look like to show:

  1. For each project
  2. First descendent with no children
//project//count(.*) = 0[0]

Does that syntax look reasonable?


Weekly review
#20

Sorry for the extremely late reply (again). This looks perfectly reasonable!