Toggling Multiple Tags with Single Command

I’m sure this must have been done somewhere, but have searched here and on the Keyboard Maestro forums, for instance, and have been unable to quite get this to work. I’m decently versed in programming logics–and have good familiarity with Python, Ruby, AppleScript, etc.–but am a complete dolt when it comes to JavaScript. The language just doesn’t make intuitive sense to me for whatever reason, which I attribute entirely to my failings.

Anyway, put most simply, what I’d like to be able to do is toggle tags, but not just toggle one tag on and off (e.g. run script and add @write if not present on line; remove @write from line if present). Instead, I’d like (for example) to have @write added to line if not present on line, change @write to @edit if run when @write is already tagged to line, and then remove the tag altogether if @edit is already present on line. So, the toggleing would be: off --> @write --> @edit --> off.

I’ve attempted to modify the toggling script I typically use for single tags, but haven’t yet been successful in reliably doing what I want. Any suggestions?

Thanks to everyone in advance for their help!

I think this script does what you want:

function TaskPaperContextScript(editor, options) {
    let outline = editor.outline
    let selection = editor.selection
	
    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
			if (item.hasAttribute("data-write")) {
				item.removeAttribute("data-write")			
				item.setAttribute("data-edit", "")			
			} else if (item.hasAttribute("data-edit")) {
				item.removeAttribute("data-edit")						
			} else {
				item.setAttribute("data-write", "")			
			}
        });
	})
	
	editor.moveSelectionToItems(selection)
}

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

This is fantastic, Jesse, and many thanks for your help! One quick follow-up question: is there any way to do the same thing, but for the project containing the currently selected item? For example, changing the status (toggling a status tag that is attached to the entire project) of the project that my cursor is currently inside of?

@jessegrosjean — I love your script! Thank you!

Here is a minor variation that toggles between the following tags:

  • important
  • next
  • today
  • incubate
  • waiting
  • declined

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

    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
			if (item.hasAttribute("data-important")) {
				item.removeAttribute("data-important")		
				item.setAttribute("data-next", "")
			} else if (item.hasAttribute("data-next")) {
				item.removeAttribute("data-next")
				item.setAttribute("data-today", "")
			} else if (item.hasAttribute("data-today")) {
				item.removeAttribute("data-today")
				item.setAttribute("data-incubate", "")
			} else if (item.hasAttribute("data-incubate")) {
				item.removeAttribute("data-incubate")
				item.setAttribute("data-waiting", "")
			} else if (item.hasAttribute("data-waiting")) {
				item.removeAttribute("data-waiting")
				item.setAttribute("data-declined", "")
			} else if (item.hasAttribute("data-declined")) {
				item.removeAttribute("data-declined")
			} else {
				item.setAttribute("data-important", "")
			}
        });
	})

	editor.moveSelectionToItems(selection)
}

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

Instead of processing each selected item you could instead try:

editor.selection.start.parent

That will get you to the parent item of the selected item. Or if you want to get more sophisticated you could search up through ancestors to find project.

and if you wanted to edit the cycle of tags a little more easily, you could experiment with variants like this:

/* eslint-disable max-lines-per-function */
(() => {
    "use strict";

    // Ver 0.02
    // A bit of tidying

    // - TOGGLING SELECTED ITEMS THROUGH A CYCLE OF TAGS -

    // main :: IO ()
    const main = () =>
        Application("TaskPaper").documents.at(0)
        .evaluate({
            script: `${TaskPaperContextScript}`,
            withOptions: {
                tagCycle: [
                    "important",
                    "next",
                    "today",
                    "incubate",
                    "waiting",
                    "declined"
                ]
            }
        });

    // -------------------- TASKPAPER --------------------

    const TaskPaperContextScript = (editor, options) => {

        const tpMain = () => {
            const
                outline = editor.outline,
                selection = editor.selection,
                tagCycle = options.tagCycle;

            outline.groupUndoAndChanges(() =>
                selection.selectedItems.forEach(
                    item => bimap(
                        k => k && item.removeAttribute(
                            `data-${k}`
                        )
                    )(
                        k => k && item.setAttribute(
                            `data-${k}`, ""
                        )
                    )(
                        foundAndNext(tagCycle)(item)
                    )
                )
            );

            editor.moveSelectionToItems(selection);
        };

        // foundAndNext :: [String] ->
        // Item -> (String, String)
        const foundAndNext = tagNames =>
            // A Tuple of the first found tag in the cycle,
            // if any, and the next tag in the cycle, if any.
            item => {
                const
                    iLast = tagNames.length - 1,
                    mbi = tagNames.findIndex(
                        k => item.hasAttribute(`data-${k}`)
                    );

                return 0 <= iLast ? (
                    -1 !== mbi ? (
                        iLast !== mbi ? [
                            tagNames[mbi],
                            tagNames[1 + mbi]
                        ] : [tagNames[iLast], ""]
                    ) : ["", tagNames[0]]
                ) : ["", ""];
            };


        // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
        const bimap = f =>
            // Tuple instance of bimap.
            // A tuple of the application of f and g to the
            // first and second values respectively.
            g => ab => [f(ab[0]), g(ab[1])];


        // TaskPaper context main function called.
        return tpMain();
    };

    // Script main function called.
    return main();
})();
2 Likes

Thank you @complexpoint — very informative and inspirational!

1 Like

In case anyone else needs it, I hacked together an example that cycles through tag values, rather than tags themselves. I have very little experience with scripting so if anyone else has a better way to achieve this please let me know :slight_smile:

A GTD status cycle that cycles through next action, later action, waiting action and someday action. If the tag @actionStatus doesn’t exist it’ll create one and default to the start of the cycle (next action).

function TaskPaperContextScript(editor, options) {
    let outline = editor.outline
    let selection = editor.selection
	
    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
		if (item.getAttribute("data-actionStatus") === "next action"){
			item.setAttribute("data-actionStatus", "later action")
        } else if (item.getAttribute("data-actionStatus") === "later action"){
			item.setAttribute("data-actionStatus", "waiting action")
        } else if (item.getAttribute("data-actionStatus") === "waiting action"){
			item.setAttribute("data-actionStatus", "someday action")
		} else {
			item.setAttribute("data-actionStatus", "next action")
		}});
	})
	
	editor.moveSelectionToItems(selection)
}

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

Added to wiki

Not sure if anyone can help here - In the above script that I hacked together tags are automatically added to the end of a task line, would anyone know how to change the behavior so that the tag is addede to the beginning of the line instead?

Tags are just parsed out of the plain text item content. The existing TaskPaper API for add/remove tags is in the end just manipulating that plain text. To add a tag to start of line you could do this:

function TaskPaperContext(editor, options) {
	let startItem = editor.selection.startItem
	startItem.bodyString = "@mytag " + startItem.bodyString
}

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

Thanks - is there a way to add the tag between the and the task content? With the sample that you provided the tag is added before the

EDIT: figured it out :slight_smile:

1 Like

An updated version of this script that now includes an emoji for each status of the task:

function TaskPaperContextScript(editor, options) {
    let outline = editor.outline
    let selection = editor.selection
	
    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
		
		if (item.getAttribute("data-as") === "now"){
			item.setAttribute("data-as", "next")
			item.bodyContentString = "🟠 " + item.bodyContentString.slice(3)
			
        } else if (item.getAttribute("data-as") === "next"){
			item.setAttribute("data-as", "later")
			item.bodyContentString = "🟡 " + item.bodyContentString.slice(3)
			
        } else if (item.getAttribute("data-as") === "later"){
			item.setAttribute("data-as", "waiting")
			item.bodyContentString = "🟤 " + item.bodyContentString.slice(3)
			
		} else if (item.getAttribute("data-as") === "waiting"){
			item.setAttribute("data-as", "someday")
			item.bodyContentString = "🔵 " + item.bodyContentString.slice(3)


		} else if (item.getAttribute("data-as") === "someday"){
			item.setAttribute("data-as", "now")
			item.bodyContentString = "🔴 " + item.bodyContentString.slice(3)

		} else if (item.getAttribute("data-as") == null){
			item.bodyContentString = "🔴 " + item.bodyContentString + " | "
			item.setAttribute("data-as", "now")

			
		}});
	})
	
	editor.moveSelectionToItems(selection)
}

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

I’m not sure if anyone here could help me. I have 2x very similar scripts based on the one above. 1x I use for adding @as - action statuses and another for @ps - project statuses.

For some reason if I run the script once on the @as script and then undo (cmd-Z) then Taskpaper crashes. However if I do the same with the @ps script there isn’t an issue (i.e. no crashing).

@as script (crashes)
function TaskPaperContextScript(editor, options) {
    let outline = editor.outline
    let selection = editor.selection
	
    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
		
		if (item.getAttribute("data-as") === "now"){
			item.setAttribute("data-as", "next")
			item.bodyContentString = "🟠 " + item.bodyContentString.slice(3)
			
        } else if (item.getAttribute("data-as") === "next"){
			item.setAttribute("data-as", "later")
			item.bodyContentString = "🟡 " + item.bodyContentString.slice(3)
			
        } else if (item.getAttribute("data-as") === "later"){
			item.setAttribute("data-as", "waiting")
			item.bodyContentString = "🟤 " + item.bodyContentString.slice(3)
			
		} else if (item.getAttribute("data-as") === "waiting"){
			item.setAttribute("data-as", "someday")
			item.bodyContentString = "🔵 " + item.bodyContentString.slice(3)

		} else if (item.getAttribute("data-as") === "someday"){
			item.setAttribute("data-as", "now")
			item.bodyContentString = "🔴 " + item.bodyContentString.slice(3)

		} else if (item.getAttribute("data-as") == null){
			item.bodyContentString = "🔴 " + item.bodyContentString + " | "
			item.setAttribute("data-as", "now")

			
		}});
	})
	
	editor.moveSelectionToItems(selection)
}

Application("TaskPaper").documents[0].evaluate({
    script: TaskPaperContextScript.toString()
});
@ps script (doesn't crash)
function TaskPaperContextScript(editor, options) {
    let outline = editor.outline
    let selection = editor.selection
	
    outline.groupUndoAndChanges(() => {
        editor.selection.selectedItems.forEach((item) => {
		if (item.getAttribute("data-ps") === "urgent"){
			item.setAttribute("data-ps", "perpetual")
		} else if (item.getAttribute("data-ps") === "perpetual"){
			item.setAttribute("data-ps", "current")
		} else if (item.getAttribute("data-ps") === "current"){
			item.setAttribute("data-ps", "following")
        } else if (item.getAttribute("data-ps") === "following"){
			item.setAttribute("data-ps", "future")
        } else if (item.getAttribute("data-ps") === "future"){
			item.setAttribute("data-ps", "someday")
		} else {
			item.setAttribute("data-ps", "urgent")
		}});
	})
	
	editor.moveSelectionToItems(selection)
}

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

I’m not sure how the script can be involved as it has already completed running when I try and undo the changes made by the script.

The last of those conditional clauses is the one that I would look at first – it shows signs of editing at a different time or by a different hand. ( Using == where in practice, like the other clauses, we should always use the faster and more type-safe ===. == performs type conversions which can have slippery and confusing consequences)

Not quite sure what the role of the "|" pipe character is, but I’m also wondering what happens with that clause when the selection includes empty lines.

(first step might be to simply remove that final clause, and see if the script still gets the TaskPaper model into a fragile place with your test data)

Good luck !

Having tidied it slightly with ESlint (just to make it easier for me to read, and to get the benefits of the more informative “use strict”; error messages), and having also pruned out some redundant logic and a number of redundant data-fetches, I get the edit below, with which I haven’t yet been able to reproduce your crash.

Do you have some simple sample data with which it’s reproducible ?

Expand disclosure triangle to view ESLint-tidied Source
(() => {
    "use strict";

    const TaskPaperContextScript = editor => {
        const outline = editor.outline;
        const selection = editor.selection;

        outline.groupUndoAndChanges(() => {
            editor.selection.selectedItems.forEach((item) => {
                const
                    asValue = item.getAttribute("data-as"),
                    bcs = item.bodyContentString,
                    content = Boolean(bcs.trim()) ? (
                        bcs.slice(3)
                    ) : "";

                if (asValue === "now") {
                    item.setAttribute("data-as", "next");
                    item.bodyContentString = `🟠 ${content}`;

                } else if (asValue === "next") {
                    item.setAttribute("data-as", "later");
                    item.bodyContentString = `🟡 ${content}`;

                } else if (asValue === "later") {
                    item.setAttribute("data-as", "waiting");
                    item.bodyContentString = `🟤 ${content}`;

                } else if (asValue === "waiting") {
                    item.setAttribute("data-as", "someday");
                    item.bodyContentString = `🔵 ${content}`;

                } else if (Boolean(content)) {
                    item.bodyContentString = `🔴 ${content}`;
                    item.setAttribute("data-as", "now");
                }
            });
        });

        editor.moveSelectionToItems(selection);
    };

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

Incidentally, It might not be too difficult to imagine contexts in which that slice(3) might be discarding initial characters which turned out not be billiard ball + space after all.

Probably sensible to include a prior check – I suppose a start-of-line regex might be one approach. It would need the /u switch to cope with Emoji characters.

(() => {
    "use strict";

    const rgx = /^[🟠🟡🟤🔵🔴] /u;

    return rgx.test("🟡 alpha");
})();

Thanks for the hint. I removed the last conditional and it seems to not crash, so like you suggested the error seems to be there.

The pipe character is just a visual separator for me so that the status tag is separated from the task itself e.g.

- 🟡 Task | @as(later)

So the idea here was that if there is in @as tag, add the pipe character to the task and then the tag.

Thanks again for helping me out with yet another script! To be honest I’m not even a noob when it comes to JSA or AppleScript scripting.

I gave your script a try and the crash wasn’t reproducible, but it started crashing when I changed the last clause to the following:

} else if (Boolean(content)) {
                    item.bodyContentString = `🔴 ${bcs} | `;
                    item.setAttribute("data-as", "now");
}

The last clause is meant to catch tasks that don’t have an @as tag at all, and because they don’t have that tag, I assume that they also don’t have the pipe character or the icon / emoji. That’s also why I omit the slice in the last clause.

I tried also removing the pipe character and it also crashes. It’s only when I remove the space character and pipe does it not crash i.e.

} else if (Boolean(content)) {
                    item.bodyContentString = `🔴 ${bcs}`;
                    item.setAttribute("data-as", "now");
}

I tried yet another option and found that if the value passed to item.bodyContentString ends in a space character that’s when the crash happens. So the following works:

item.bodyContentString = `🔴 ${bcs} |`;

Lastly, I tried this on my original (way less elegant script) and found that if I removed the last space character from it, it too didn’t crash i.e. changing " | " to " |"

} else if (item.getAttribute("data-as") == null){
			item.bodyContentString = "🔴 " + item.bodyContentString + " |"
			item.setAttribute("data-as", "now")

Interesting enough in all the cases that I don’t add the space character and in turn Taskpaper doesn’t crash, a space character is added by Taskpaper itself :slight_smile:

FYI as a test all I use is a new Taskpaper file with the following:

- Task

The last clause is meant to catch tasks that don’t have an @as tag at all,

There’s no need to catch them – if a line lacks {now, next, later, waiting} then its simply now

(and I think you may be inadvertently creating a tangle for cases where the line is blank – especially with the type-converting == which you have left in there in lieu of the safer === which I mentioned)