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
- Whether or not to use Keyboard Maestro
- Name of target project
- Names of any tags to add to the top-level moved items
- Option of moving material to END or START of target project
- 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