Triage :: moving things quickly, with their descendants

Here is a TaskPaper script originally written for @kvonkries, who has kindly suggested that I share it here.

The key function is to move selected tasks, with their descendants, to the top or bottom of a specified target project.

The script has various options, which can be set either by

  • editing the source,
  • or by specifying values in Keyboard Maestro variables.

(the latter may work well in contexts where differently tailored copies – for use, for example with different keystrokes and target projects – are needed)

The target project can be specified:

  • by directly setting an option in a particular copy of the script
    • via a Keyboard Maestro variable
    • or by editing an option string near the top of the script
  • or by choosing interactively from a menu of the projects in a document.

Here is the JavaScript source,

JavaScript source of triage script
(() => {
    'use strict';

    // Rob Trew @2020
    // for @kvonkries

    // Ver 0.8

    // - Selected TaskPaper subtrees moved to named project.
    // - targetIsTOPofProject :: choice of destination.
    // - Target project name optionally chosen from menu.
    // - Target projects restricted to top level
    //      (path changed from //projectName to /projectName)
    //     +path elaborated to /beginswith ...
    // - if the value of tagName is not an empty string, 
    //   a tag of that name will be added to the top line
    //   of each moved subtree.
    // - `tagName` Option replaced by `tagNames`, 
    //   allowing for a string specifying multiple
    //   tag names, delimited by spaces and/or commas.
    //   2020-11-07 ver 0.8

    // ------------------- MIT LICENSE -------------------

    // Copyright 2020 Rob Trew

    // Permission is hereby granted, free of charge, 
    // to any person obtaining a copy of this software and 
    // associated documentation files (the "Software"), 
    // to deal in the Software without restriction, 
    // including without limitation the rights to use, 
    // copy, modify, merge, publish, distribute, 
    // sublicense, and/or sell copies of the Software, 
    // and to permit persons to whom the Software is 
    // furnished to do so, subject to the following 
    // conditions:

    // The above copyright notice and this permission 
    // notice shall be included in all copies or 
    // substantial portions of the Software.

    // THE SOFTWARE IS PROVIDED "AS IS", 
    // WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
    // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 
    // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE 
    // AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 
    // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 
    // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 
    // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT 
    // OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 
    // OR OTHER DEALINGS IN THE SOFTWARE.


    // --------------------- OPTIONS ---------------------

    // KEYBOARD MAESTRO:
    const boolUseKeyboardMaestro = false;

    const
        kmVar = boolUseKeyboardMaestro ? (
            Application('Keyboard Maestro Engine')
            .getvariable
        ) : _ => '';


    // TARGET PROJECT:

    // Edit the name of `targetProjectName` as required.
    const targetProjectName = 'Followup' || (
        kmVar('tpTargetProjectName')
    );

    // Or to obtain from a Keyboard Maestro variable:
    // const targetProjectName = (
    //     Application('Keyboard Maestro Engine')
    //     .getvariable('targetProjectName')
    // );

    // TAGS FOR ROOT ITEM OF MOVED TREES

    // Multiple tag names can be separated by any 
    // non-alphabetic characters.

    // tagNames :: String
    // Multiple tag names can be specified,
    // with or without leading '@'
    // and separated by spaces and/or commas.
    // e.g.  'ea foo bar baz'

    const tagNames = '' || kmVar('tpTagNames');


    // SHOW A MENU FOR SELECTING TARGET PROJECTS ?

    const showMenuOfProjects = false || (
        kmVar('tpShowMenuOfProjects')
    );

    // A menu of:
    // 
    //  - The names of all projects in the active document, 
    //  - plus any default `targetProjectName` given above, 
    //    (if no project of that name is seen in the document).

    // The item initially hilighted in the menu is:
    // 
    //   - Either the most recently selected project
    //     (if the menu has been used in the last 3 days)
    //   - Or any default `targetProjectName` given above
    //     (if there is no MRU memory from the last 3 days)
    //   - Or, in the absence both of memory and a given default
    //     simply the first item in the (AZ-sorted) menu.


    // DESTINATION IS *START* OR *END* OF PROJECT:

    // If the value of this edited from true to false,
    // then subtrees will be appended to the END 
    // of the target project.
    const targetIsTOPofProject = true || (
        kmVar('tpTargetIsTOPofProject')
    );

    // main :: IO
    const main = () => {
        const
            app = Application('TaskPaper'),
            ds = app.documents,
            defaultProjectName = targetProjectName.trim(),
            settingsMessage = 'No target project named.\n\n' + (
                'You can either set:\n\n' + (
                    '\tshowMenuOfProjects = true;\n\n'
                ) + 'or specify a target project ' + (
                    'at the top of the script, e.g. \n\n' + (
                        '\ttargetProjectName = "Followup";'
                    )
                )
            );
        return either(
            // Message explaining why any particular move
            // can not be completed, 
            // (e.g. task is already in target project)
            // unless the cause was just cancellation 
            // of a menu. 
            x => x.startsWith('User cancelled') ? (
                x
            ) : alert('Moving tasks with their subtasks')(x)
        )(
            // Value for IO context, e.g. notification
            // of the move.
            x => x
        )(
            bindLR(
                0 < ds.length ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open.')
            )(d => bindLR(
                showMenuOfProjects ? (
                    chosenTargetProjectLR(d)
                ) : (
                    0 < defaultProjectName.length ? (
                        Right(defaultProjectName)
                    ) : Left(settingsMessage)
                )
            )(
                projectName => (
                    app.activate(),
                    d.evaluate({
                        script: `${tp3ContextLR}`,
                        withOptions: {
                            targetProjectName: projectName,
                            // Tokenized list of tag names
                            // from the tagNames option string.
                            tagNames: tagNames.split(/\W+/)
                                .filter(
                                    token => 0 < token.length
                                ),
                            toTOPofProject: (
                                targetIsTOPofProject
                            )
                        }
                    })
                )
            ))
        );
    };

    // -------------- MENU OF PROJECT NAMES --------------

    // chosenTargetProjectLR :: TP3 Document -> 
    // Either String String
    const chosenTargetProjectLR = tp3Document =>
        // Either the name of a project chosen from
        // a menu, or an explanatory message.
        bindLR(
            tp3Document.evaluate({
                script: tp3ProjectNames.toString(),
                withOptions: {}
            })
        )(
            documentProjects => {
                const
                    mruMemoryName = 'targetProject',
                    fpTemp = combine(
                        getTemporaryDirectory()
                    )(mruMemoryName);
                const
                    defaultProject = targetProjectName.trim(),
                    menuProjects = documentProjects.includes(
                        defaultProject
                    ) ? documentProjects : [
                        defaultProject
                    ].concat(documentProjects),
                    mruProjectName = rememberedProjectName(
                        fpTemp
                    );
                return bindLR(
                    showMenuLR(false)(
                        'Move selected subtree'
                    )(
                        'To:'
                    )(
                        menuProjects
                    )(
                        Boolean(mruProjectName) ? (
                            mruProjectName
                        ) : menuProjects[0]
                    )
                )(
                    choices => (
                        writeFile(fpTemp)(choices[0]),
                        readFileLR(fpTemp)
                    )
                );
            }
        );

    // rememberedProjectName :: () -> IO String
    const rememberedProjectName = fpMRU =>
        either(x => '')(x => x)(
            readFileLR(fpMRU)
        );

    // showMenuLR :: Bool -> String -> String -> 
    // [String] -> String -> Either String [String]
    const showMenuLR = blnMult =>
        // An optionally multi-choice menu, with 
        // a given title and prompt string.
        // Listing the strings in xs, with 
        // the the string `selected` pre-selected
        // if found in xs.
        title => prompt => xs =>
        selected => 0 < xs.length ? (() => {
            const sa = Object.assign(
                Application('System Events'), {
                    includeStandardAdditions: true
                });
            sa.activate();
            const v = sa.chooseFromList(xs, {
                withTitle: title,
                withPrompt: prompt,
                defaultItems: xs.includes(selected) ? (
                    [selected]
                ) : [xs[0]],
                okButtonName: 'OK',
                cancelButtonName: 'Cancel',
                multipleSelectionsAllowed: blnMult,
                emptySelectionAllowed: false
            });
            return Array.isArray(v) ? (
                Right(v)
            ) : Left('User cancelled ' + title + ' menu.');
        })() : Left(title + ': No items to choose from.');


    // tp3ProjectNames :: TP Editor -> Dict -> 
    const tp3ProjectNames = (editor, options) => {
        const main = () => {
            const projectNames = tpTopLevelProjectNames(editor);
            return 0 < projectNames.length ? (
                Right(projectNames)
            ) : Left(
                'No projects found in front TaskPaper document.'
            );
        };

        // -------------- PROJECT LIST ---------------

        // tpTopLevelProjectNames :: TP Editor -> 
        // [String]
        const tpTopLevelProjectNames = editor =>
            // Names of all projects in 
            // the active document. 
            // Sorted AZ by name.
            editor.outline.evaluateItemPath(
                '/@type=project'
            )
            .map(project => project.bodyContentString)
            .sort();


        // -------- GENERICS FOR TP3 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
        });

        return main();
    };

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

    // tp3Context :: TP Editor -> Dict -> 
    // Either IO String IO String
    const tp3ContextLR = (editor, options) => {
        const tp3Main = () => {
            const
                selectionRoots = editor.selection
                .selectedItemsCommonAncestors;
            return 0 < selectionRoots.length ? (
                tasksMovedToNamedProjectAndTaggedLR(
                    editor.outline
                )(
                    options.targetProjectName
                )(
                    options.tagNames
                )(
                    selectionRoots
                )
            ) : Left('No tasks selected.');
        };

        // ------ TASK TREES MOVED TO NAMED PROJECT ------


        // tasksMovedToNamedProjectAndTaggedLR :: TP Outline ->
        // [String] -> [TP Item] -> Either String IO String
        const tasksMovedToNamedProjectAndTaggedLR = outline =>
            // Either an explanatory message or a 
            // report of the number of tasks moved.
            projectName => tagNames => parentTasks => {
                const
                    project = topLevelprojectFoundOrCreated(
                        outline
                    )(projectName),
                    tags = tagNames.flatMap(tagName => {
                        const
                            s = tagName.trim(),
                            k = s.startsWith('@') ? (
                                s.slice(1)
                            ) : s;
                        return 0 < k.length ? (
                            [k]
                        ) : [];
                    });

                return project && (
                    'Item' === project.constructor.name
                ) ? (() => {
                    const
                        projectID = project.id,
                        ineligibles = parentTasks.filter(
                            task => [task].concat(
                                task.ancestors).some(
                                a => projectID === a.id
                            )
                        ),
                        n = ineligibles.length;
                    return 0 < n ? (
                        // Message listing selected tasks
                        // which already descend from the 
                        // target project.
                        Left(
                            `${n} selected ${plural('item')(n)} ` + (
                                `${vplural('is')(n)} already in ` + (
                                    `${projectName}:\n\t- `
                                ) + (
                                    ineligibles.map(
                                        x => x.bodyContentString
                                    ).join('\n\t- ')
                                )
                            )
                        )
                    ) : (() => {
                        const
                            selectedForest = parentTasks
                            .map(fmapPureTP(x => x));

                        // Mutations grouped on Undo stack,
                        // for easier ⌘Z reversal.
                        outline.groupUndoAndChanges(() => {
                            selectedForest.forEach(x => {
                                // - SELECTED SUBTREES CLONED -
                                const subTree = x.root.clone(true);

                                // TAGGED, IF TAGS ARE SPECIFIED,
                                tags.reduce(
                                    (item, strTag) => (
                                        item.setAttribute(
                                            `data-${strTag}`,
                                            ''
                                        ),
                                        item
                                    ),
                                    subTree
                                );

                                //  AND INSERTED AT TOP OF PROJECT
                                if (options.toTOPofProject) {
                                    project.insertChildrenBefore(
                                        subTree,
                                        project.firstChild
                                    );
                                } else {
                                    //  OR APPENDED TO PROJECT
                                    project.appendChildren(
                                        subTree
                                    );
                                };
                            });


                            // ----- ORIGINAL COPIES OF ------
                            // ------ SUBTREES CLEARED -------
                            // We have to do this bottom-up, 
                            // from leaves toward root, as
                            // TP deletions leave any children
                            // intact, attaching them to the 
                            // nearest available parent.
                            selectedForest.map(foldTree(
                                x => _ => {
                                    x.removeFromParent();
                                    return [];
                                }
                            ));
                        });

                        // --------- SUMMARY STRING ----------
                        return Right((() => {
                            const
                                total = sum(selectedForest.map(
                                    foldTree(
                                        _ => xs => 1 + sum(xs)
                                    )
                                )),
                                cargo = plural('task')(total);
                            return `${total} ${cargo} ` + (
                                `moved to ${projectName}.`
                            ) + '\n\t⌘Z to Undo.';
                        })());
                    })();
                })() : Left(
                    'Project not found or created: ' + (
                        projectName
                    )
                );
            };

        // ------------- TASKPAPER FUNCTIONS -------------

        // fmapPureTP :: (TPItem -> a) -> TPItem -> Tree a
        const fmapPureTP = f => {
            const go = x => Node(f(x))(
                x.hasChildren ? (
                    x.children.map(go)
                ) : []
            );
            return go;
        };

        // topLevelprojectFoundOrCreated :: TP3Outline -> 
        // String -> Bool -> TP3Item
        const topLevelprojectFoundOrCreated = outline =>
            // A reference to a TaskPaper Project item,
            // either found in the outline, or created
            // at the top of it.
            projectName => {
                const
                    k = projectName.trim(),
                    matches = outline.evaluateItemPath(
                        '/beginswith ' + k + ' and @type=project'
                    ),
                    blnFound = 0 < matches.length,
                    project = blnFound ? (
                        matches[0]
                    ) : outline.createItem(k + ':');
                return (
                    blnFound || outline.insertItemsBefore(
                        project,
                        outline.root.firstChild
                    ),
                    project
                );
            };


        // ----- GENERIC FUNCTIONS FOR TP3 JSCONTEXT -----
        // https://github.com/RobTrew/prelude-jxa

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


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


        // plural :: String -> Int -> String
        const plural = k =>
            // Singular or plural inflection
            // of a given EN noun.
            n => 1 !== n ? (
                `${k}s`
            ) : k;


        // vplural :: String -> Int -> String
        const vplural = k =>
            // Singular or plural EN inflection
            // of a given EN verb.
            n => 1 !== n ? (
                k !== 'is' ? (
                    k
                ) : 'are'
            ) : k !== 'is' ? (
                `${k}s`
            ) : 'is';


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


        // sum :: [Num] -> Num
        const sum = xs =>
            // The numeric sum of all values in xs.
            xs.reduce((a, x) => a + x, 0);

        return tp3Main();
    };


    // ----------------------- 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',
                withIcon: sa.pathToResource(
                    'TaskPaper.icns', {
                        inBundle: 'Applications/TaskPaper.app'
                    }
                )
            }),
            s
        );
    };


    // ------- GENERIC FUNCTIONS FOR JXA JSCONTEXT -------
    // https://github.com/RobTrew/prelude-jxa

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


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


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


    // combine (</>) :: FilePath -> FilePath -> FilePath
    const combine = fp =>
        // Two paths combined with a path separator. 
        // Just the second path if that starts 
        // with a path separator.
        fp1 => Boolean(fp) && Boolean(fp1) ? (
            '/' === fp1.slice(0, 1) ? (
                fp1
            ) : '/' === fp.slice(-1) ? (
                fp + fp1
            ) : fp + '/' + fp1
        ) : fp + fp1;


    // 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 => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;


    // getTemporaryDirectory :: IO FilePath
    const getTemporaryDirectory = () =>
        ObjC.unwrap($.NSTemporaryDirectory());


    // readFileLR :: FilePath -> Either String IO String
    const readFileLR = fp => {
        // Either a message or the contents of any
        // text file at the given filepath.
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );
        return ns.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.unwrap(ns));
    };


    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp => s =>
        $.NSString.alloc.initWithUTF8String(s)
        .writeToFileAtomicallyEncodingError(
            $(fp)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );


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

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

(see TaskPaper User Guide – Using Scripts)

followed by a zipped Keyboard Maestro macro,

Move selected task(s) (with subtrees) to named project.kmmacros.zip (16.9 KB)

and a more detailed summary of the options:

Script – moving selected items, with their subtrees, to a named project

Adjusting the options

  1. Whether or not to use Keyboard Maestro
  2. Name of target project
  3. Names of any tags to add to the top-level moved items
  4. Option of moving material to END or START of target project
  5. Option of selecting target project from an interactive menu

If you open the .scpt file in Script Editor (with the language tab at top left set to JavaScript) you will see that near the top there is a section entitled OPTIONS.

The four options sections are:

    // KEYBOARD MAESTRO:
 	// Edit from false to true if using inside Keyboard Maestro
    const boolUseKeyboardMaestro = false;
    // TARGET PROJECT:

    // Edit the name of `targetProjectName` as required.
    const targetProjectName = 'Followup';

    // TAGS FOR ROOT ITEM OF MOVED TREES
    
    // Multiple tag names can be separated by any 
    // non-alphabetic characters.

    // tagNames :: String
    // Multiple tag names can be specified,
    // with or without leading '@'
    // and separated by spaces and/or commas.
    // e.g.  'foo bar baz'
    
    const tagNames = '';

    // DESTINATION IS *START* OR *END* OF PROJECT:

    // If the value of this edited from true to false,
    // then subtrees will be appended to the END 
    // of the target project.
    const targetIsTOPofProject = true;

In the cases of target project name and tag name, you can list tag names, with or without preceding @, preserving:

  • the surrounding single quotes,
  • and the final semi-colon.

In the case of the option to send material to the end of a named project, rather than the top of it, you can edit the word true to false, leaving it free of any quotes. (true is a built-in JavaScript keyword).

So either:

const targetIsTOPofProject = true;

or:

const targetIsTOPofProject = false;

    // SHOW A MENU FOR SELECTING TARGET PROJECTS ?

    const showMenuOfProjects = false || (
        kmVar('tpShowMenuOfProjects')
    );

    // A menu of:
    // 
    //  - The names of all projects in the active document, 
    //  - plus any default `targetProjectName` given above, 
    //    (if no project of that name is seen in the document).

    // The item initially hilighted in the menu is:
    // 
    //   - Either the most recently selected project
    //     (if the menu has been used in the last 3 days)
    //   - Or any default `targetProjectName` given above
    //     (if there is no MRU memory from the last 3 days)
    //   - Or, in the absence both of memory and a given default
    //     simply the first item in the (AZ-sorted) menu.

Once you are happy with any new option values, use Save As, in Script Editor, to create a new version with a different name (preserving the .scpt extension)

@complexpoint 2020

3 Likes

@complexpoint @kvonkries Thanks to you both for sharing this!