Action
TASK TREE
TLDR:
Task list / outliner that displays all open tasks (“- [ ] ”) from across your active drafts (i.e. those not archived) with folded subordinate/child items. When loaded, you’ll be presented with a list of drafts containing your tasks. Tap the arrow bullets to toggle folding. Tap/select an item to load the selected draft/task. Search bar allows you to filter to specific sets of tasks.
DETAIL:
I’ve got lots of tasks hiding in different drafts. I can probably just forget about the ones that aren’t being signposted by screaming emails (or screaming people…) but I’m making a concerted effort to get better at managing things, particularly in Drafts. I’ve bounced between Todoist and Reminders (via GoodTask) for task management, but I’ve never been comfortable managing “projects” in either of them, with all the ad hoc dependencies and links that might arise, not to mention related notes. So this is the beginning of me trying to account for tasks in Drafts.
All this really does so far is list and locate tasks in a way that hopefully makes them a little easier to navigate. There’s a search filter that’ll help zero in on things, and if you have your own system of inline tags, that might be particularly helpful. If you’ve indented notes under tasks, those should come through as items in the list, but folded away until you need them.
For keyboard warriors: you can arrow up and down the list, space to toggle a folded/unfolded item. Pressing enter or tapping on a draft title will open the draft. Doing the same on a task will open the draft and select that task.
I’m thinking there might be more that can be done to make this more useful; I’m going to live with this version for a while and see whether it sticks, and what improvements come to mind.
Note: this isn’t really intended as a replacement for Todoist or GoodTask (both fine apps), but should cover some functionality I never quite figured out in a way that made sense to me. Perhaps it’ll also be useful to someone else, hence the share.
I made this for me, really, so there are a couple of things in there that you may wish to adjust. The styles are based on my preferences (dark mode for life), and the main query excludes drafts tagged “workoutlogs”, which shouldn’t make a difference to you if you want to use this— if you have any familiarity with Drafts queries, you can adjust that part to filter out any drafts you don’t want to return results from.
First version. Comments and suggestions welcome. Hobbyist coder. Insert regular disclaimers.
Steps
-
script
// Query inbox drafts, excluding those tagged "workoutlog" let drafts = Draft.query("", "inbox").filter(d => !d.hasTag("workoutlog")); // Array to hold list items grouped by draft let groupedItems = []; // Function to process lines and their indented sub-lines function processLines(draft) { let lines = draft.content.split("\n"); let items = []; let currentItem = null; let subLines = []; let currentIndentLevel = 0; function getIndentLevel(line) { let match = line.match(/^(\t*)/); return match ? match[1].length : 0; } for (let line of lines) { let indentLevel = getIndentLevel(line); let trimmedLine = line.trim(); if (trimmedLine.startsWith('- [ ]')) { if (currentItem) { items.push({ task: currentItem, subTasks: subLines, hasSubtasks: subLines.length > 0 }); } currentItem = trimmedLine; subLines = []; currentIndentLevel = indentLevel; } else { if (indentLevel > currentIndentLevel) { subLines.push(line); } else { if (currentItem) { items.push({ task: currentItem, subTasks: subLines, hasSubtasks: subLines.length > 0 }); } currentItem = null; subLines = []; currentIndentLevel = 0; } } } if (currentItem) { items.push({ task: currentItem, subTasks: subLines, hasSubtasks: subLines.length > 0 }); } // Suppress "#" symbols in draft title let cleanTitle = draft.title.replace(/#/g, ""); if (items.length > 0) { groupedItems.push({ title: cleanTitle, // Use the cleaned title here tasks: items }); } } // Process each draft in the inbox for (let d of drafts) { processLines(d); } // Count the total tasks let taskCount = groupedItems.reduce((count, group) => count + group.tasks.length, 0); // Create the HTML for the preview let html = `<!DOCTYPE html> <html> <head> <title>Task List</title> <style> body { font-family: "Avenir Next", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; padding: 1em; background-color: #1e1e1e; color: #e0e0e0; } ul { list-style-type: none; padding-left: 20px; } li { padding: 0.25em 0; position: relative; font-size: 0.9em; } li::before { content: attr(data-bullet); position: absolute; left: -20px; color: #808080; /* Grey color for bullets */ } .h1, .h2, .h3, .h4, .h5, .h6 { font-weight: bold; } .h1 { color: #ff6600; } .h2 { color: #ff9900; } .h3 { color: #ffcc00; } .h4 { color: #99cc33; } .h5 { color: #66cc99; } .h6 { color: #9999cc; } .toggle { cursor: pointer; margin-right: 5px; } .children { display: none; } .expanded > .children { display: block; } code { background-color: #2d2d2d; padding: 2px 4px; border-radius: 3px; } .default-text { color: #e0e0e0; font-weight: normal; } #searchBar { width: 100%; padding: 10px; margin-bottom: 1em; font-size: 1em; border-radius: 5px; border: 1px solid #888; background-color: #2d2d2d; color: #e0e0e0; } #searchBar:focus { outline: none; border-color: #ff9900; } .selected { background-color: #3a3a3a; } </style> <script> function toggleSubTasks(id) { var subtasks = document.getElementById(id); var arrow = document.getElementById('arrow-' + id); if (subtasks.style.display === 'none') { subtasks.style.display = 'block'; arrow.textContent = '▼'; } else { subtasks.style.display = 'none'; arrow.textContent = '▶'; } } function toggleTaskList(id) { var taskList = document.getElementById(id); var arrow = document.getElementById('list-arrow-' + id); if (taskList.style.display === 'none') { taskList.style.display = 'block'; arrow.textContent = '▼'; } else { taskList.style.display = 'none'; arrow.textContent = '▶'; } } function filterTasks() { var searchValue = document.getElementById("searchBar").value.toLowerCase(); var lists = document.querySelectorAll("ul.task-list"); lists.forEach(function(list) { var tasks = list.querySelectorAll("li"); var foundInGroup = false; // Check the draft title (previous sibling of the list) var draftTitle = list.previousElementSibling.textContent.toLowerCase(); // If search value is empty, show all drafts and tasks if (searchValue === "") { list.previousElementSibling.style.display = ""; tasks.forEach(function(task) { task.style.display = ""; // Show all tasks }); return; // Skip to the next group } // If the draft title matches, show the entire group if (draftTitle.includes(searchValue)) { list.previousElementSibling.style.display = ""; foundInGroup = true; } else { // Check each task for matches tasks.forEach(function(task) { var taskText = task.textContent.toLowerCase(); var subTasks = task.querySelectorAll("li.default-text"); var taskMatch = false; if (taskText.includes(searchValue)) { task.style.display = ""; taskMatch = true; } else if (Array.from(subTasks).some(sub => sub.textContent.toLowerCase().includes(searchValue))) { task.style.display = ""; taskMatch = true; } else { task.style.display = "none"; } // If any task matches, indicate that the group has found something if (taskMatch) { foundInGroup = true; } }); } // Hide the entire group if no matching tasks or title if (foundInGroup) { list.previousElementSibling.style.display = ""; } else { list.previousElementSibling.style.display = "none"; } }); } function sendDraftTitle(element, isTitle = false) { let titleText, taskText; if (isTitle) { // If it's a title, get the text directly from the clicked element titleText = element.textContent.trim(); taskText = ""; } else { // If it's a task, get the title from the closest ancestor with class 'draft-title' titleText = element.closest('ul').previousElementSibling.querySelector('.draft-title').textContent.trim(); // Get the task text, excluding any nested ul elements taskText = Array.from(element.childNodes) .filter(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && node.tagName !== 'UL')) .map(node => node.textContent.trim()) .join(' ') .trim(); } // Send the title text and task text to the next action step Drafts.send("selectedOutput", {"selectedTitle": titleText, "selectedTask": taskText}); // Continue to the next action step Drafts.continue(); } let selectedIndex = -1; let selectableItems = []; function initializeKeyboardNavigation() { const searchBar = document.getElementById('searchBar'); searchBar.focus(); updateSelectableItems(); document.addEventListener('keydown', handleKeyPress); } function updateSelectableItems() { selectableItems = Array.from(document.querySelectorAll('.draft-title, li[onclick]')) .filter(item => { if (item.classList.contains('draft-title')) { return true; // Draft titles are always visible } // Check if the item or its parent ul is hidden return item.offsetParent !== null && item.closest('ul').style.display !== 'none'; }); } function handleKeyPress(event) { const searchBar = document.getElementById('searchBar'); if (event.target === searchBar) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); searchBar.blur(); // Remove focus from search bar navigateList(event.key === 'ArrowDown' ? 1 : -1); } } else { switch(event.key) { case 'ArrowDown': case 'ArrowUp': event.preventDefault(); navigateList(event.key === 'ArrowDown' ? 1 : -1); break; case ' ': event.preventDefault(); toggleSelectedItem(); break; case 'Enter': event.preventDefault(); selectCurrentItem(); break; case '/': event.preventDefault(); searchBar.focus(); // Return focus to search bar when '/' is pressed break; } } } function navigateList(direction) { updateSelectableItems(); // Update the list of visible items selectedIndex += direction; if (selectedIndex < 0) selectedIndex = selectableItems.length - 1; if (selectedIndex >= selectableItems.length) selectedIndex = 0; selectableItems.forEach(item => item.classList.remove('selected')); selectableItems[selectedIndex].classList.add('selected'); selectableItems[selectedIndex].scrollIntoView({block: 'nearest', behavior: 'smooth'}); // Set focus to the body to ensure keyboard events are captured document.body.focus(); } function toggleSelectedItem() { if (selectedIndex === -1) return; const selectedItem = selectableItems[selectedIndex]; if (selectedItem.classList.contains('draft-title')) { const toggleSpan = selectedItem.previousElementSibling; if (toggleSpan && toggleSpan.classList.contains('toggle')) { toggleSpan.click(); } } else { const toggleSpan = selectedItem.querySelector('.toggle'); if (toggleSpan) { toggleSpan.click(); } } updateSelectableItems(); // Update after toggling } function selectCurrentItem() { if (selectedIndex === -1) return; const selectedItem = selectableItems[selectedIndex]; selectedItem.click(); } // Update visible items when toggling task lists or subtasks function toggleTaskList(id) { var taskList = document.getElementById(id); var arrow = document.getElementById('list-arrow-' + id); if (taskList.style.display === 'none') { taskList.style.display = 'block'; arrow.textContent = '▼'; } else { taskList.style.display = 'none'; arrow.textContent = '▶'; } updateSelectableItems(); } function toggleSubTasks(id) { var subtasks = document.getElementById(id); var arrow = document.getElementById('arrow-' + id); if (subtasks.style.display === 'none') { subtasks.style.display = 'block'; arrow.textContent = '▼'; } else { subtasks.style.display = 'none'; arrow.textContent = '▶'; } updateSelectableItems(); } window.onload = initializeKeyboardNavigation; </script> </head> <body tabindex="-1"> <!-- Add tabindex to make body focusable --> <h2 class="h1">Task List</h2> <p>Total Tasks: ${taskCount}</p> <input type="text" id="searchBar" placeholder="Search tasks..." onkeyup="filterTasks()" /> ${groupedItems.map((group, groupIndex) => { let groupId = `group-${groupIndex}`; return ` <div class="h2"> <span id="list-arrow-${groupId}" class="toggle" onclick="toggleTaskList('${groupId}')">▶</span> <span class="draft-title" onclick="sendDraftTitle(this, true)">${group.title.replace(/#/g, '')}</span> </div> <ul id="${groupId}" class="task-list" style="display:none;"> ${group.tasks.map((task, index) => { let taskId = `task-${group.title}-${index}`; return `<li data-bullet="${task.subTasks.length > 0 ? '▶' : '•'}" onclick="sendDraftTitle(this)"> <span id="arrow-${taskId}" class="toggle" ${task.subTasks.length > 0 ? `onclick="event.stopPropagation(); toggleSubTasks('${taskId}')"` : ''}></span> ${task.task} ${task.subTasks.length > 0 ? `<ul id="${taskId}" class="children" style="display:none;">${task.subTasks.map(sub => `<li class="default-text" data-bullet="•">${sub}</li>`).join("\n")}</ul>` : ''} </li>`; }).join("\n")} </ul>`; }).join("\n")} </body> </html>`; // Show HTML preview HTMLPreview.create().show(html);
-
script
let sO = context.previewValues["selectedOutput"]; // Check if selectedOutput is defined if (!sO) { // Exit the script if HTML preview was canceled // alert("No selection made. Exiting..."); } else { // Continue with the rest of the script // DEBUG STUFF // alert(sO.selectedTitle); // alert(sO.selectedTask); // Search for the draft with the specified title let drafts2 = Draft.queryByTitle(sO.selectedTitle); if (drafts2.length > 0) { // Load the first draft that matches the title into the editor let targetDraft = drafts2[0]; editor.load(targetDraft); editor.activate(); // Select the specific task text if a task was clicked if (sO.selectedTask) { // Find the position of the selected task text in the draft content let taskText = sO.selectedTask; let content = editor.getText(); let startIndex = content.indexOf(taskText); // Check if the task text is found in the content if (startIndex !== -1) { // Calculate the end index of the selected text let endIndex = startIndex + taskText.length; editor.setSelectedRange(startIndex, endIndex); } else { // Handle case where the task text is not found in the draft content alert("The selected task text was not found in the draft."); } } } else { // Handle case where no draft was found alert("No draft found with the title: " + sO.selectedTitle); } }
Options
-
After Success Default Notification Info Log Level Info