Action

TASK TREE

Posted by @jsamlarose, Last update 3 months ago

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
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.