Prepend task(s) with parent project

Given this outline:

- Root-level task
Project:
	- Task
		Task notes
	- Task:
		- Sub-task
	Sub-project:
		Project notes
		- Task in Sub-project

The script below will produce, if the whole outline is selected:

- Root-level task
Project:
	- Project: Task
		Task notes
	- Project: Task:
		- Sub-task
	Sub-project:
		Project notes
		- Sub-project: Task in Sub-project

Why do this?

Briefly, so that tasks can be moved into a daily project (e.g. 2021-07-08: @today) and shuffled into the order in which one wants to tackle them without losing the context of the projects they belonged to.

The obvious drawback is that when viewed in the context of their original projects, the prepended project names are redundant and waste some line width; but in a heavily nested outline, it may be easier to understand where tasks belong from the repeated project names than from indentation.

In more detail:

TP3’s default tags suggest this workflow: triage tasks by assigning @today or @due(YYYY-MM-DD) to them, and see what needs doing with a search (e.g. @search(@today union @due <[d] next week). This maintains each task in its context, and more fine-grained prioritisation is possible with tags like @priority(1) or @today(am). But a heavily indented list is difficult to read, and priority tags won’t do if one wants to set tasks in a sequence.

I often want a flat, easily scanned list of everything I need to do today, in the order in which I intend to do them.

My previous attempt at a solution was to append @project(name) tags to tasks as they were moved to the daily project. The project names could be made more prominent by styling the tagvalue of project tags. But this is still much harder to scan:

- Simple task @project(Project 2)
- Difficult task which requires a much longer description @project(Project 1)

Than this:

- Project 2: Simple task
- Project 1: Difficult task which requires a much longer description

Because it’s the project names which provide the tasks with context, it makes more sense for them to be aligned for easy scanning and read before the tasks themselves.

I try to keep my outlines to no more than two tiers (projects and sub-projects), and have found that the direct parent project is generally sufficient or more succinct in providing context to a task than a concatenation of all ancestors ("Project: Sub-project: Sub-sub-project: ").

JXA Script

I still haven’t learned any JavaScript since I posted my last solution, so this could surely be more elegant:

function TaskPaperContextScript(editor, options) {
	let outline = editor.outline
	let selection = editor.selection

	// If only a single item is selected, save the cursor location for later
	var single, loc;
	if (selection.selectedItems.length == 1 && selection.selectedItems[0].getAttribute('data-type') == 'task') {
		single = true;
		loc = selection.location;
	}
	
	outline.groupUndoAndChanges(() => {

		selection.selectedItems.forEach((item) => {

			if (item.getAttribute('data-type') == 'task') {

				// Proceed only if the immediate parent of the item is not a task
				const ancestors = outline.evaluateItemPath('ancestor::*', item);
				if (ancestors[ancestors.length - 1].getAttribute('data-type') !== 'task') {

					const ancestorProjects = outline.evaluateItemPath('ancestor::@type=project', item);

					if (ancestorProjects.length > 0) {

						const parent = ancestorProjects[ancestorProjects.length - 1].bodyContentString;
						const body = item.bodyString;

						// Check if the task is already prepended with the parent project's name
						// https://stackoverflow.com/a/2712896
						const rePrefix = new RegExp("^\\s*[-*]\\s" + parent + ":\\s");
						const rePrefixMatch = body.match(rePrefix);

						// Proceed only if the task is not already prepended
						if (rePrefixMatch == null) {

							// If only a single task is selected, offset the cursor location by the length of the project name
							if (single) {
								loc = loc + parent.length + 2;
							}

							item.replaceBodyRange(2,0,parent + ': ');
						}
					}
				}
			}
		});
	})

	if (single) {
		editor.moveSelectionToRange(loc)
	} else {
		editor.moveSelectionToItems(selection)
	}
}

Application("TaskPaper").documents[0].evaluate({
	script: TaskPaperContextScript.toString()
});

Suggested Usage

I use three Keyboard Maestro macros to invoke the script.

The first macro is manually triggered by ‘hot keys’ and does nothing besides invoke the script. I use this to prepend project names to an existing outline.

The second macro is triggered whenever I hit Return in TP3, and simulates another hit of the Return key before invoking the script:

If I don’t want to prepend the next task with the project (e.g. I intend to indent it as a sub-task of the selected task), I hit Shift + Return.

The third macro is triggered whenever I hit + Return in TP3, and simulates the same keystroke, then a backspace to delete the pro forma text (“New Task”) before invoking the script:

Unfortunately, in the case of the latter two macros, it takes a visible fraction of a second for the script to execute. This could be avoided if the whole behaviour of each macro was encapsulated in its JXA script.

Sometimes I use TaskPaper as an outliner rather than a task manager, in which case I’ve found it useful to toggle these macros off and back on again with a fourth macro.

JXA Script with Additional Conditionals

This is the version of the script I’m actually using, which does not prepend project names to tasks in projects @no-prefix. It will also prepend project names to sub-tasks of tasks if and only if the parent task is tagged with @texts.

I include this here in case it helps anyone who wants to customise the generic script above to suit the idiosyncrasies of their own workflow:

function TaskPaperContextScript(editor, options) {
	let outline = editor.outline
	let selection = editor.selection

	// Check if only a single task is selected
	var single, loc;
	if (selection.selectedItems.length == 1 && selection.selectedItems[0].getAttribute('data-type') == 'task') {
		single = true;
		loc = selection.location;
	}
	
	outline.groupUndoAndChanges(() => {

		selection.selectedItems.forEach((item) => {

			if (item.getAttribute('data-type') == 'task') {

				// Proceed only if the immediate parent of the item is not a task, or is a task tagged @texts
				const ancestors = outline.evaluateItemPath('ancestor::*', item);
				let parent = ancestors[ancestors.length - 1];
				if (parent.getAttribute('data-type') !== 'task' || parent.hasAttribute('data-texts')) {

					const ancestorProjects = outline.evaluateItemPath('ancestor::@type=project', item);

					if (ancestorProjects.length > 0) {

						const parentProject = ancestorProjects[ancestorProjects.length - 1];
						const parentName = parentProject.bodyContentString;
						const body = item.bodyString;

						// Proceed only if the parent project is not tagged @no-prefix
						if (!parentProject.hasAttribute('data-no-prefix')) {

							// Check if the task is already prepended with the parent project's name
							// https://stackoverflow.com/a/2712896
							const rePrefix = new RegExp("^\\s*[-*]\\s" + parentName + ":\\s");
							const rePrefixMatch = body.match(rePrefix);

							// Proceed only if the task is not already prepended
							if (rePrefixMatch == null) {

								// If only a single task is selected, save the cursor location and offset it by the length of the project name
								if (single) {
									loc = loc + parentName.length + 2;
								}

								item.replaceBodyRange(2,0,parentName + ': ');
							}
						}
					}
				}
			}
		});
	})

	if (single) {
		editor.moveSelectionToRange(loc)
	} else {
		editor.moveSelectionToItems(selection)
	}
}

Application("TaskPaper").documents[0].evaluate({
	script: TaskPaperContextScript.toString()
});
1 Like