Script: move a @now or @next tag on to the next item

An update - the version that I am currently using. (Requires TaskPaper 3 and macOS Sierra onwards)

Again, this script just:

  • moves a @now tag on the the next available item
  • optionally flags the previous @now item as @done(timestamped)
  • optionally updates a TextBar display of the current @now item and its context (parent, and any grandparent)
  • Optionally issues a macOS notification and sound. (One sound for a new available item, another sound for a completed outline)

Next available item means, for example, the next outline leaf (childless item) which is not @done

The movement through the outline is ‘bottom up’, or ‘preorder’ (children before parents). The @now tag moves to parent items only after all of their descendants are @done (so that the parent itself can be marked @done, if that is what is wanted)

( Items of all types are visited in the same way – projects, tasks and notes – I seem to be working these days with very few bullets and project colons – mostly just unadorned outlines )

This is a JavaScript for Automation script, for installation and use see:

// Move @now or @next tag to next available item in file

// Ver 0.16
// Requires TaskPaper 3, macOS Sierra onwards

// Skips @done items
// If necessary, unfolds to make newly tagged item visible
// If the option markAsDone = true: (see foot of script )
//  1. Marks the current item as @done(yyyy-mm-dd HH:mm)
//     before moving the @now/@next tag
//  2. If the parent project is now completed, marks that as @done too

// Author: Rob Trew (c) 2018 License: MIT

(options => {
    // OPTIONS (see foot of script)
    //   tagName : 'now',
    //   markAsDone : true, // when tag moves on
    //   useTextBar : true, // [TextBar](http://www.richsomerfield.com/apps/)
    //   notify : true // mac OS notification and sound on tag move
    'use strict';

    ObjC.import('AppKit');

    // TASKPAPER CONTEXT -----------------------------------------------------

    // taskPaperContext :: TP Editor -> Dict -> a
    const taskPaperContext = (editor, options) => {

        // GENERICS FOR TASKPAPER CONTEXT -------------------------------------

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

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

        // append (++) :: [a] -> [a] -> [a]
        // append (++) :: String -> String -> String
        const append = (xs, ys) => xs.concat(ys);

        // bindEither (>>=) :: Either a -> (a -> Either b) -> Either b
        const bindEither = (m, mf) =>
            m.Right !== undefined ? (
                mf(m.Right)
            ) : m;

        // concatMap :: (a -> [b]) -> [a] -> [b]
        const concatMap = (f, xs) =>
            xs.length > 0 ? [].concat.apply([], xs.map(f)) : [];

        // dropWhile :: (a -> Bool) -> [a] -> [a]
        const dropWhile = (p, xs) => {
            let i = 0;
            for (let lng = xs.length;
                (i < lng) && p(xs[i]); i++) {}
            return xs.slice(i);
        };

        // either :: (a -> c) -> (b -> c) -> Either a b -> c
        const either = (lf, rf, e) =>
            isLeft(e) ? (
                lf(e.Left)
            ) : isRight(e) ? (
                rf(e.Right)
            ) : undefined;

        // filter :: (a -> Bool) -> [a] -> [a]
        const filter = (f, xs) => xs.filter(f);

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

        // isRight :: Either a b -> Bool
        const isRight = lr =>
            lr.type === 'Either' && lr.Right !== undefined;

        // tail :: [a] -> [a]
        const tail = xs => xs.length > 0 ? xs.slice(1) : [];

        // TP ----------------------------------------------------------------
        const outline = editor.outline;

        // Bottom up traversal, ending with the supplied root item itself
        // postOrderTraversal :: TP3Item -> [TP3Item]
        const postOrderTraversal = item =>
            item.hasChildren ? (
                append(
                    concatMap(
                        postOrderTraversal,
                        filter(
                            x => x.bodyContentString.length > 0,
                            item.children
                        )
                    ), [item]
                )
            ) : [item];

        // topAncestor :: TP3Item -> TP3Item
        const topAncestor = item =>
            outline.evaluateItemPath(
                'ancestor-or-self::(@id!=Birch)[0]',
                item
            )[0];

        // First leaf descendant without @done tag
        // greenLeafEither :: TP3Item -> Either String TP3Item
        const greenLeafEither = item => {
            const
                xs = outline.evaluateItemPath(
                    'descendant::((count(.*)=0) and (not @done))',
                    item
                );
            return xs.length > 0 ? (
                Right(xs[0])
            ) : Left('Outline @done');
        };

        // nextItemEither :: TP3Item -> Either String TP3Item
        const nextItemEither = item =>
            either(
                _ => {
                    const
                        strID = item.id,
                        xs = filter(
                            x =>
                            !x.hasAttribute('data-done') &&
                            x.bodyContentString.length > 0,
                            tail(
                                dropWhile(
                                    x => strID !== x.id,
                                    postOrderTraversal(
                                        topAncestor(item)
                                    )
                                )
                            )
                        );
                    return xs.length > 0 ? (
                        Right(xs[0])
                    ) : Left('Finished');
                },
                Right,
                greenLeafEither(item)
            );

        // MAIN --------------------------------------------------------------
        const
            strTag = options.tagName || 'now',
            strAttrib = 'data-' + strTag,
            lstTagged = outline.evaluateItemPath('//@' + strTag),
            lrTagged = lstTagged.length > 0 ? (
                Right(lstTagged[0])
            ) : Left('No @' + strTag + ' tag found.');

        // EFFECTS ON ANY ITEM THAT HAD THE TAG  -----------------------------
        return ( // result :: Either String String
            isRight(lrTagged) && (
                x => (
                    x.removeAttribute(strAttrib),
                    options.markAsDone && x.setAttribute(
                        'data-done', moment()
                        .format('YYYY-MM-DD HH:mm')
                    )
                )
            )(lrTagged.Right),

            // EFFECTS ON ANY ITEM THAT NOW GETS THE TAG ---------------------
            bindEither(
                either(
                    _ => greenLeafEither(
                        editor.selection.startItem
                    ),
                    nextItemEither,
                    lrTagged
                ),
                item => (
                    // Effect ------------------------------------------------
                    editor.forceDisplayed(item, true),
                    item.setAttribute(strAttrib, ''),

                    // Value -------------------------------------------------
                    Right(
                        item.parent.bodyContentString +
                        ' ⟶ ' + item.bodyContentString
                    )
                )
            )
        );
    };

    // GENERICS FOR JXA CONTEXT  ---------------------------------------------

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

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

    // bindEither (>>=) :: Either a -> (a -> Either b) -> Either b
    const bindEither = (m, mf) =>
        m.Right !== undefined ? (
            mf(m.Right)
        ) : m;

    // Simpler 2 argument only version of curry
    // curry :: ((a, b) -> c) -> a -> b -> c
    const curry = f => a => b => f(a, b);

    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = (lf, rf, e) =>
        e.type === 'Either' ? (
            e.Left !== undefined ? (
                lf(e.Left)
            ) : rf(e.Right)
        ) : undefined;

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

    // isRight :: Either a b -> Bool
    const isRight = lr =>
        lr.type === 'Either' && lr.Right !== undefined;


    // JXA FUNCTIONS ---------------------------------------------------------

    // appIsInstalled :: String -> Bool
    const appIsInstalled = strBundleId =>
        ObjC.unwrap(
            $.NSWorkspace.sharedWorkspace
            .URLForApplicationWithBundleIdentifier(
                strBundleId
            )
            .fileSystemRepresentation
        ) !== undefined;

    // standardAdditions :: () -> Library Object
    const standardAdditions = () =>
        Object.assign(
            Application.currentApplication(), {
                includeStandardAdditions: true
            }
        );

    // focusChange :: String -> String -> IO ()
    const focusChange = curry((strSoundName, strTitle) =>
        standardAdditions()
        .displayNotification(
            'TaskPaper notes', {
                withTitle: strTitle,
                subtitle: '@' + options.tagName,
                soundName: strSoundName
            }
        ));

    // MAIN -----------------------------------------------------------------
    const
        ds = Application('TaskPaper')
        .documents,
        lrMoved = bindEither(
            ds.length > 0 ? (
                Right(ds.at(0))
            ) : Left('No TaskPaper documents open'),
            d => d.evaluate({
                script: taskPaperContext.toString(),
                withOptions: options
            })
        );

    // If you are using [TextBar](http://www.richsomerfield.com/apps/)
    // to display the active TaskPaper task on the OS X menu bar, as described in:

    // http://support.hogbaysoftware.com/t/script-displaying-the-active-task-in-the-os-x-menu-bar/1290

    // then uncommenting the following 4 lines
    // will trigger an immediate refresh of the TextBar display.

    // -----------------------------------------------------------------------
    // COMMENT OUT 12 LINES (TO NEXT LINE OF DASHES) IF NOT USING TEXTBAR

    if (
        options.useTextBar &&
        appIsInstalled('com.RichSomerfield.TextBar')
    ) {
        try {
            Application('TextBar')
                .refreshall()
        } catch (_) {
            console.log('TextBar not running')
        }
    }

    // -----------------------------------------------------------------------

    // NOTIFICATION ----------------------------------------------------------
    options.notify && either(
        focusChange('Glass'),
        focusChange('Pop'),
        lrMoved
    );

    // VALUE RETURNED --------------------------------------------------------
    return lrMoved;

    // OPTIONS - EDIT BELOW --------------------------------------------------
})({
    tagName: 'now',
    markAsDone: true, // add @done(dateTime) when tag moves on
    useTextBar: true, // [TextBar](http://www.richsomerfield.com/apps/)
    notify: true // mac OS notification and sound on tag move
});

For a Keyboard Maestro version, and a sample TextBar shell script, see:

3 Likes