Project name affixed with count of todos?

This is a question which I was asked by a user on the Keyboard Maestro forum, but perhaps this is a helpful place to look at it.

Imagine we have an outline which:

  • represents a project that we are working on,
  • and contains a top level child with a name like Todos

How could we add (and later update) a numeric affix to the project name, showing the number of items in the Todos subfolder ?

Expand disclosure triangle to view dummy project outline
Project (10)
	Status
	Todos
		Alpha
		Beta
		one
		two
		asdf
		sdf
		fds
		four
		Zeta
		Delta
	Done
	Background

Doing this in the JavaScript (rather than AppleScript) flavour of Apple’s osascript scripting interface, (i.e. with the Script Editor.app language selector at top-left set to JavaScript for testing purposes),

first we need a reference to the top level row of the project whose name will be decorated with a todo count.

The simplest case might just be to select the project, before running the script.

// Target project – top level row – for example a selected line.
const
    firstSelectedRow = doc.rows.where({
        "selected": true
    }).at(0);

but maybe nothing is selected ? We can test for that.

firstSelectedRow.exists()

will return either true or false.

If it does exist, then let’s call it row.

Now, does it actually contain a child row called “Todos” (or whatever name we are giving to our container of items to work through) ?

If the childName that interests us is “Todos”, we can see if a reference to such a child actually exists for the row we are looking at.

First we define a reference to any such child of the project:

const child = row.rows.byName(childName);

and then we test whether the value of its existence is true or false

child.exists() ?

If it does exist, we can directly obtain the number of items it contains:

child.rows.length

Now, are we adding a numeric affix to the grandparent project name ?

Or are we updating an existing numeric affix ?

Let’s just plan to take the stem before any existing (number) affix, and then append a fresh affix to that pruned stem.

The text of a row can be obtained like this:

const text = row.name();

and we can apply a JavaScript regular expression (regex representation of the numeric affix pattern) to the string, and see whether we get the special value:

  • null (no match, a Boolean false value) or,
  • a list (JS Array, a Boolean true value) containing a match.

If there’s no match, we take the whole string,
if there’s a match, we take a slice from the start, ignoring the last n characters, where n is the length of the matching affix.

// withOutNumericSuffix :: Bike Row -> String
const withOutNumericSuffix = row => {
    const
        text = row.name(),
        match = (/\(\d+\)\s*$/u).exec(text);

    return Boolean(match) ? (
        text.slice(0, -match[0].length).trimEnd()
    ) : text;
};

When we want to add a fresh numeric affix, we can define it like this (which includes discarding any existing affix):

// withNumericSuffix :: Int -> Bike Row -> String
const withNumericSuffix = n =>
    row => `${withOutNumericSuffix(row)} (${n})`;

and we can combine these elements into a draft script, testable in Script Editor.app which assumes:

  • that we have a Bike document open
  • and the the cursor is selecting a project row, which
  • contains a “Todos” row,
  • which in turn contains zero or more items

The full draft script (make sure you scroll down to copy all of it), might look like this:

Expand disclosure triangle to view JS Source
(() => {
    "use strict";

    // Rob Trew @2022

    // MAIN :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return doc.exists() ? (() => {
            // Target project – for example the first selected line.
            const
                firstSelectedRow = doc.rows.where({
                    "selected": true
                }).at(0);

            return firstSelectedRow.exists() ? (
                either(
                    alert("Affix todo count to project")
                )(
                    projectString => projectString
                )(
                    suffixedWithCountOfGrandChildrenLR(
                        "Todos"
                    )(firstSelectedRow)
                )
            ) : `Nothing selected in ${doc.name()}`;
        })() : "No documents open in Bike";
    };

    // ---------- SUFFIXED WITH COUNT OF TODOS -----------

    // suffixedWithCountOfGrandChildrenLR :: String ->
    // Bike Row -> Either IO String IO String
    const suffixedWithCountOfGrandChildrenLR = childName =>
        row => {
            const child = row.rows.byName(childName);

            return child.exists() ? (() => {
                const
                    suffixedName = withNumericSuffix(
                        child.rows.length
                    )(row);

                return (
                    row.name = suffixedName,
                    Right(suffixedName)
                );
            })() : Left(
                [
                    `Child not found under row: '${row.name()}'`,
                    `\t'${childName}'`
                ]
                .join("\n\n")
            );
        };


    // withNumericSuffix :: Int -> Bike Row -> String
    const withNumericSuffix = n =>
        row => `${withOutNumericSuffix(row)} (${n})`;


    // withOutNumericSuffix :: Bike Row -> String
    const withOutNumericSuffix = row => {
        const
            text = row.name(),
            match = (/\(\d+\)\s*$/u).exec(text);

        return Boolean(match) ? (
            text.slice(0, -match[0].length).trimEnd()
        ) : text;
    };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


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

Part II


The main limitation of the draft script above is that we have to select a project to update its task count suffix.

What, however, if we have several such projects in a Bike outline, and want to create (or update) task count suffixes of all of them ?


Expand disclosure triangle to view outline with several projects
Project A (4)
	Status
	Todos
		one
		two
		three
		four
	Done
	Background

Project B (3)
	Status
	Todos
		Alpha
		gamma
		delta
	Done
	Background

Project C (0)
	Status
	Todos
	Done
	Background

One approach, assuming that the taskList containers in each project are always called "Todos":

const taskListName = "Todos";

is to obtain a reference to all rows in the document which have the given name:

doc.rows.where({
    name: taskListName
})

and from that reference derive a reference to the containing parents of those taskList rows (i.e. a reference to all the project rows):

doc.rows.where({
    name: taskListName
})
.containerRow

If we evaluate that reference to an actual list of values, by following it with the JS function evaluation parentheses ()

doc.rows.where({
    name: taskListName
})
.containerRow()

we can then .map our existing suffixedWithCountOfGrandChildrenLR over each item in that list, to produce either a Right result (computed value), or a Left message (explaining why something could not be computed).

const [problems, oks] = partitionEithers(
    doc.rows.where({
        name: taskListName
    })
    .containerRow()
    .map(
        suffixedWithCountOfGrandChildrenLR(
            taskListName
        )
    )
);

and these elements are enough for us to be able to draft a script which updates task counts for all projects in the front document, without requiring any of them to be selected:

Expand disclosure triangle to view JS Source
(() => {
    "use strict";

    // Rob Trew @2022

    // The names of all projects containing a row with a
    // given name (e.g. "Todos") updated with a suffix
    // giving the number of items contained by that row.

    // ---------------------- OPTION ----------------------
    const taskListName = "Todos";

    // ----- ALL PROJECT TASK COUNT SUFFIXES UPDATED -----

    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return doc.exists ? (() => {
            const [problems, oks] = partitionEithers(
                doc.rows.where({
                    name: taskListName
                })
                .containerRow()
                .map(
                    suffixedWithCountOfGrandChildrenLR(
                        taskListName
                    )
                )
            );

            return Boolean(problems.length) ? (
                alert("Update todo count suffixes")(
                    problems.join("\n")
                )
            ) : oks.join("\n");
        })() : "No document open in Bike.";
    };

    // ---------- SUFFIXED WITH COUNT OF TODOS -----------

    // suffixedWithCountOfGrandChildrenLR :: String ->
    // Bike Row -> Either IO String IO String
    const suffixedWithCountOfGrandChildrenLR = childName =>
        row => {
            const child = row.rows.byName(childName);

            return child.exists() ? (() => {
                const
                    suffixedName = withNumericSuffix(
                        child.rows.length
                    )(row);

                return (
                    row.name = suffixedName,
                    Right(suffixedName)
                );
            })() : Left(
                [
                    `Child not found under row: '${row.name()}'`,
                    `\t'${childName}'`
                ]
                .join("\n\n")
            );
        };


    // withNumericSuffix :: Int -> Bike Row -> String
    const withNumericSuffix = n =>
        row => `${withOutNumericSuffix(row)} (${n})`;


    // withOutNumericSuffix :: Bike Row -> String
    const withOutNumericSuffix = row => {
        const
            text = row.name(),
            match = (/\(\d+\)\s*$/u).exec(text);

        return Boolean(match) ? (
            text.slice(0, -match[0].length).trimEnd()
        ) : text;
    };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // first :: (a -> b) -> ((a, c) -> (b, c))
    const first = f =>
        // A simple function lifted to one which applies
        // to a tuple, transforming only its first item.
        ([x, y]) => [f(x), y];


    // second :: (a -> b) -> ((c, a) -> (c, b))
    const second = f =>
        // A function over a simple value lifted
        // to a function over a tuple.
        // f (a, b) -> (a, f(b))
        ([x, y]) => [x, f(y)];


    // partitionEithers :: [Either a b] -> ([a],[b])
    const partitionEithers = xs =>
        xs.reduce(
            (a, x) => (
                "Left" in x ? (
                    first(ys => [...ys, x.Left])
                ) : second(ys => [...ys, x.Right])
            )(a),
            [
                [],
                []
            ]
        );

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

And finally,

we could avoid counting any empty lines in the task list by rewriting from the simple:

child.rows.length

to the more conditional:

child.rows.where({
    _not: [{
        name: ""
    }]
}).length

in the main function (suffixedWithCountOfGrandChildrenLR)


Expand disclosure triangle to view JS Source
(() => {
    "use strict";

    // Rob Trew @2022
    // Ver 0.04

    // The names of all projects containing a row with a
    // given name (e.g. "Todos") updated with a suffix
    // giving the number of items contained by that row.

    // ---------------------- OPTION ----------------------
    const taskListName = "Todos";

    // ----- ALL PROJECT TASK COUNT SUFFIXES UPDATED -----

    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return doc.exists ? (() => {
            const
                docID = doc.id(),
                [problems, oks] = partitionEithers(
                    doc.rows.where({
                        name: taskListName
                    })
                    .containerRow()
                    .flatMap(
                        // Excluding any todoLists which have no
                        // parent other than the document.
                        x => docID !== x.id() ? [
                            suffixedWithCountOfGrandChildrenLR(
                                taskListName
                            )(x)
                        ] : []
                    )
                );

            return Boolean(problems.length) ? (
                alert("Update todo count suffixes")(
                    problems.join("\n")
                )
            ) : oks.join("\n");
        })() : "No document open in Bike.";
    };

    // ---------- SUFFIXED WITH COUNT OF TODOS -----------

    // suffixedWithCountOfGrandChildrenLR :: String ->
    // Bike Row -> Either IO String IO String
    const suffixedWithCountOfGrandChildrenLR = childName =>
        row => {
            const child = row.rows.byName(childName);

            return child.exists() ? (() => {
                const
                    suffixedName = withNumericSuffix(
                        child.rows.where({
                            _not: [{
                                name: ""
                            }]
                        }).length
                    )(row);

                return (
                    row.name = suffixedName,
                    Right(suffixedName)
                );
            })() : Left(
                [
                    `Child not found under row: '${row.name()}'`,
                    `\t'${childName}'`
                ]
                .join("\n\n")
            );
        };


    // withNumericSuffix :: Int -> Bike Row -> String
    const withNumericSuffix = n =>
        row => `${withOutNumericSuffix(row)} (${n})`;


    // withOutNumericSuffix :: Bike Row -> String
    const withOutNumericSuffix = row => {
        const
            text = row.name(),
            match = (/\(\d+\)\s*$/u).exec(text);

        return Boolean(match) ? (
            text.slice(0, -match[0].length).trimEnd()
        ) : text;
    };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // first :: (a -> b) -> ((a, c) -> (b, c))
    const first = f =>
        // A simple function lifted to one which applies
        // to a tuple, transforming only its first item.
        ([x, y]) => [f(x), y];


    // second :: (a -> b) -> ((c, a) -> (c, b))
    const second = f =>
        // A function over a simple value lifted
        // to a function over a tuple.
        // f (a, b) -> (a, f(b))
        ([x, y]) => [x, f(y)];


    // partitionEithers :: [Either a b] -> ([a],[b])
    const partitionEithers = xs =>
        xs.reduce(
            (a, x) => (
                "Left" in x ? (
                    first(ys => [...ys, x.Left])
                ) : second(ys => [...ys, x.Right])
            )(a),
            [
                [],
                []
            ]
        );


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