Action
Block level filter :MGCL
UPDATES
over 1 year ago
Update to fix cases in which a search result is found in a draft that doesn’t have a title.
WHY THIS?
Searching in Drafts is pretty great already— whether filtering the drafts list or using the quick search function. However, I felt I needed a bit more context than Drafts provides per search result. I also wanted to go straight to wherever my selected search result was placed in the draft, rather than having to scroll to find it. So I made this, and I’ve continued tweaking it since. I don’t just think of it as a search anymore; it’s a view, often providing me with a more complete overview of how the thing I’m searching for exists in my notes. Searching for a person, for example, gives me a full rundown of all the notes I’ve taken for that person. Likewise for places, projects, exercises, supplements, hardware… anything I might make a note of in Drafts.
2024-02-28: REFACTORED + CHILD NODES + ALIAS ARRAYS
This version features a complete rewrite of the logic, some work with aliases and now returns lines that are indented under any line that contains your search string (i.e. “child nodes”). I really need to get better at noting changes…
ALIASES: If your search string is contained in an “alias array”, the filter will now return results for all of the other items in that array. Example use case: if you track projects in Drafts, and have referred to the same project with different names over time, rather than running a global replace and updating the project name in all of your drafts, you can simply add an alias array to the project’s draft. The script looks for the following syntax:
aliases: [project 1, project 2]
Note the lower case “a” on aliases. Haven’t set this up to ignore case, so “Aliases:” won’t work. Another example: I have a keyboard that doesn’t have a canonical name, so I’ve referred to it a number of different ways over time. In the draft for that keyboard I can now include an alias array to make sure any search for any of terms I’ve used to refer to that keyboard will turn up all related references:
aliases: [Corne Mini, Corne Nano, 5x3 Corne, Corne 5x3]
PREVIOUS NOTES/UPDATES:
(Early 2024?: There was a version around here that made use of “continuation characters” to include subordinate lines of context in search material…)
PRE-2024: An incomplete list of changes:
- The settings prompt has been refined.
- The results menu now includes an option to paste the text of a result into the current draft— useful for duplicating log entries.
- The action now offers the option to output the results list to a menu OR a new draft— useful for working through a list of results without needing to constantly reload the menu.
ORIGINAL DESCRIPTION:
Depends on Matt Gemmell’s MGCheckListPrompt action. This action won’t work if you don’t have that installed (with apologies to Matt for my own less-than-elegant hackery around his brilliant work…;)
The action queries your drafts for the search term you specify, or linked/unlinked references to the current draft. Also…
- any text selected when the action is invoked will be used as the search term (no prompt)
- if no text is selected, and the cursor is in a line with a wiki-linked phrase, that wiki-linked phrase is offered as an option for the search— if there’s more than one, each wiki-link will be offered as an option
Responses to your search query are loaded into an interface (the Matt Gemmell Checklist Prompt), displaying the entirety of each line/paragraph (aka “block”) that the search term is found in for context.
Selecting an option from the list should load the appropriate draft and select/highlight the correct instance of that search term in the loaded draft.
NOTES:
- The action currently has one setting for easy adjustment: if you’d prefer that the list shows the full text of a draft if the term you’re searching for is found in the title of that draft, set the variable in the first action step to be true. Otherwise, leave it false.
- The search that this action performs is an “exact phrase” search, meaning that if you search for a phrase consisting of more than one word, you’ll only see results containing exactly that phrase, rather than results that contain each of the words in that phrase. To borrow an example from Drafts’ guide on search and filtering: “red parachute” will find a draft with the sentence “She used a red parachute when skydiving”, but not the draft “She used a red and blue parachute…” None of the other advanced query options detailed in that guide are enabled here.
Steps
-
script
// PREFERENCES // META: Do you want to set preferences here or on the form itself? Doing it here keeps the form tidy and allows for a wider range of options in search scope. Doing it on the form allows you to change things on the fly. Quotation marks required. // Options: "here", "form". var preferences = "form" // The next setting is used to suppress results from Drafts with a specified tag. Currently works best if you focus on one tag only, although you can enter a list of tags separated by commas if you choose... var ignoreResultsTaggedWith = "filter output" // SET YOUR CONTINUATION CHARACTER HERE var contChar = "↳" // Don't edit this next line... ;) // if (preferences == "here") { // If you've selected "form", any adjustments you make below will be ignored, except when starting a search from a wikilink or from selected text... var outputMode = "menu (select)" // CONTEXT DISPLAY: How would you prefer the list to behave when the term you're searching for is found in the title of a draft? If it would be useful to see the whole draft in this instance, set this variable to true. Otherwise, if you have lots of long drafts, you may wish to keep this set as false, in which case the first non-empty line of the draft is returned. No quotation marks required. // Options: true, false var fullDraftsAsContext = false // SCOPE OF SEARCH: Where do you want your search to be run? Quotation marks required! // Options: "inbox", "archive", "flagged", "trash","all". var searchScope = "inbox" // SORT ORDER: How do you want your search results sorted? Quotation marks required! // Options: "modified", "created" var modOrCreated = "created" // (Don't change this...) var mocProp = modOrCreated + "At" // }
-
includeAction
name MGCheckListPrompt Library
-
script
let searchStr = ""; let queries = ""; const getSelectedText = editor.getSelectedText(); const draftContent = draft.content; if (getSelectedText.length > 1) { searchStr = getSelectedText; } else if (draftContent.startsWith("fromWikiLink:")) { searchStr = draftContent.replace("fromWikiLink:", ""); if (searchStr.includes("_")) { const queries = [searchStr, searchStr.replaceAll("_", " ").replace("#", "")]; } } else { const selectedLineRange = editor.getSelectedLineRange(); const selectedText = editor.getTextInRange(selectedLineRange[0], selectedLineRange[1]); const prompt = Prompt.create(); prompt.title = "Filter"; prompt.addLabel("label", "Search for:", { "textSize": "headline" }); prompt.addTextField("searchTerm", "", "", { "wantsFocus": true }); prompt.addLabel("label", "OR filter by:", { "textSize": "headline" }); const linkRefs = ["(Linked references for this draft)", "(Unlinked references for this draft)"]; const buildArr = [...new Set(selectedText.match(/\B#(\d*[A-Za-z_]+\w*)\b(?!;)/g))]; if (/\[\[[\#|\w|\s|@|\.|\?|»|\:|\(|\)|\/|\||\-|\+"]+\]\]/.test(selectedText)) { buildArr.push(...new Set(selectedText.match(/\[\[[\#|\w|\s|@|\.|\?|»|\:|\(|\)|\/|\||\-|\+"]+\]\]/g))); } const options = [...new Set(buildArr.concat(linkRefs))]; prompt.addSelect("linkSel", "", options, [], false); if (preferences == "form") { prompt.addLabel("label", "Set sort order & search scope:", { "textSize": "headline" }); prompt.addSegmentedControl("modOrCreated", "", ["modified", "created"], "created"); prompt.addSegmentedControl("searchScope", "", ["all", "inbox"], "inbox"); prompt.addButton("new draft with results"); prompt.addButton("menu (insert)"); prompt.addButton("menu (select)"); const didSelect = prompt.show(); if (["menu (select)", "menu (insert)", "new draft with results"].includes(prompt.buttonPressed)) { searchScope = prompt.fieldValues["searchScope"]; modOrCreated = prompt.fieldValues["modOrCreated"]; mocProp = modOrCreated + "At"; outputMode = prompt.buttonPressed; } if (prompt.fieldValues["searchTerm"].length > 0) { searchStr = prompt.fieldValues["searchTerm"]; } else if (["[[s:", "[[@:"].some(prefix => prompt.fieldValues["linkSel"].toString().startsWith(prefix))) { searchStr = prompt.fieldValues["linkSel"].toString().replace("[[s:", "").replace("[[@:", "").replace("]]", ""); } else if (prompt.fieldValues["linkSel"].toString().includes("(Linked references for this draft)")) { searchStr = prompt.fieldValues["linkSel"].toString(); const queries = [ "[[" + draft.displayTitle + "]]", "[[# " + draft.displayTitle + "]]", ]; } else if (prompt.fieldValues["linkSel"].toString().includes("(Unlinked references for this draft)")) { searchStr = draft.displayTitle; } else if (/\B#(\d*[A-Za-z0-9-_]+\w*)\b(?!;)/.test(prompt.fieldValues["linkSel"].toString())) { searchStr = prompt.fieldValues["linkSel"].toString(); } else if (prompt.fieldValues["linkSel"].length > 0) { searchStr = prompt.fieldValues["linkSel"].toString(); searchStr = searchStr.split('[[').pop().split(']]')[0].replace("# ", ""); const queries = [ "[[" + searchStr + "]]", "[[# " + searchStr + "]]", ]; } else { context.cancel(); } } } searchStr === "" ? context.cancel() : searchStr;
-
script
// FUNCTIONS // escape characters for regex function escapeRegex(string) { return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } // ESCAPE HTML https://stackoverflow.com/questions/6234773/can-i-escape-html-special-chars-in-javascript function escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // relative time formatting: https://stackoverflow.com/a/37802747 function timeDifference(previous) { var prefix = "(" + modOrCreated + " " var suffix = ")" var output = "" var current = new Date() var msPerMinute = 60 * 1000; var msPerHour = msPerMinute * 60; var msPerDay = msPerHour * 24; var msPerMonth = msPerDay * 30; var msPerYear = msPerDay * 365; var elapsed = current - previous; if (elapsed < msPerMinute) { if (Math.round(elapsed/1000)==1) { output = 'a second ago'; } else { output = Math.round(elapsed/1000) + ' seconds ago'; } } else if (elapsed < msPerHour) { if (Math.round(elapsed/msPerMinute)==1) { output = 'a minute ago'; } else { output = Math.round(elapsed/msPerMinute) + ' minutes ago'; } } else if (elapsed < msPerDay ) { if (Math.round(elapsed/msPerHour)==1) { output = 'an hour ago'; } else { output = Math.round(elapsed/msPerHour) + ' hours ago'; } } else if (elapsed < msPerMonth) { if (Math.round(elapsed/msPerDay)==1) { output = 'yesterday'; } else { output = '~' + Math.round(elapsed/msPerDay) + ' days ago'; } } else if (elapsed < msPerYear) { if (Math.round(elapsed/msPerMonth)==1) { output = 'last month'; } else { output = '~' + Math.round(elapsed/msPerMonth) + ' months ago'; } } else { if (Math.round(elapsed/msPerYear)==1) { output = 'last year'; } else { output = '~' + Math.round(elapsed/msPerYear) + ' years ago'; } } return prefix + output + suffix } // get indexes for headers function getAllIndexes(arr, val) { var indexes = [], i; for(i = 0; i < arr.length; i++) // if (arr[i].includes(val)) if (val.test(arr[i])) indexes.push(i); return indexes; } // find next smallest index in header array: https://stackoverflow.com/a/47498151 function getClosestValue(myArray, myValue){ //optional var i = 0; while(myArray[++i] < myValue); return myArray[--i]; }
-
script
// DISPLAY RESULTS AND NAVIGATE TO LOCATIONS WITHIN SELECTED DRAFTS const backlinks = []; let css_class = {}; let myTitle = ""; let myInfo = ""; let projectDraft = "" const processHits = (hits, drafts, q, queries, searchStr, contChar) => { const result = []; let shouldInclude = false; for (let line of drafts[i].lines) { if (line.includes(searchStr)) { shouldInclude = true; } if (shouldInclude) { let descr = line; if (line.includes(contChar)) { descr = "\n" + line; } const separator = drafts[i].tags.join() == "" ? "" : " ... "; const regex = /(\B#(\d*[A-Za-z_]+\w*)\b(?!;))/g; const inlineTags = regex.test(drafts[i].content) ? drafts[i].content.match(regex).filter((v, i, a) => a.indexOf(v) === i).join(", ") + " ... " : ""; if (result.length > 0) { myTitle = "..."; myInfo = ""; } else { css_class = editor.getText().split("\n")[0] === drafts[i].title ? { item: "mark" } : {}; myInfo = inlineTags + drafts[i].tags.join(", ") + separator + timeDifference(drafts[i][mocProp]); } const index = drafts[i].lines.indexOf(line); let myHeader = getClosestValue(getAllIndexes(drafts[i].lines, /^#+ /), index); myHeader = typeof myHeader === "undefined" ? 0 : drafts[i].lines[myHeader].replaceAll("#", "").trim() + " ↵ "; result.push({ title: myTitle, description: descr.replace(/\t/g, ""), info: myHeader + myInfo, metadata: escapeHtml(drafts[i].content), uuid: drafts[i].uuid, permalink: drafts[i].permalink, queryString: queries[q], }); } if (line.includes(contChar)) { shouldInclude = false; } } return result; }; const processDrafts = (drafts, searchStr, fullDraftsAsContext) => { let i = 0; const result = []; while (drafts[i]) { const headerIdx = getAllIndexes(drafts[i].lines, /^#+ /); const hits = drafts[i].lines.filter((element) => element.toString().toUpperCase().includes(searchStr.toString().toUpperCase()) ); if (fullDraftsAsContext) { result.push(...processHits(hits, drafts, 0, [], searchStr)); } else { let n = 0; while (hits[n]) { const hitIndex = drafts[i].lines.indexOf(hits[n]); const descr = [hits[n]]; for (let lineIndex = hitIndex + 1; lineIndex < drafts[i].lines.length; lineIndex++) { const line = drafts[i].lines[lineIndex]; const tabsInHit = (hits[n].match(/\t/g) || []).length; const tabsInLine = (line.match(/\t/g) || []).length; if (tabsInLine > tabsInHit) { descr.push(line); } else { break; } } const separator = drafts[i].tags.join() == "" ? "" : " ... "; const regex = /(\B#(\d*[A-Za-z_]+\w*)\b(?!;))/g; const inlineTags = regex.test(drafts[i].content) ? drafts[i].content.match(regex).filter((v, i, a) => a.indexOf(v) === i).join(", ") + " ... " : ""; if (n > 0) { myTitle = "..."; myInfo = ""; } else { css_class = editor.getText().split("\n")[0] === drafts[i].title ? { item: "mark" } : {}; myTitle = drafts[i].title; myInfo = "**" + timeDifference(drafts[i][mocProp]) + "** " + inlineTags + drafts[i].tags.join(", "); } const index = drafts[i].lines.findIndex((line) => line === hits[n]); let myHeader = getClosestValue(headerIdx, index); myHeader = typeof myHeader === "undefined" ? 0 : drafts[i].lines[myHeader].replaceAll("#", "").trim() + " ↵ "; result.push({ title: myTitle, description: descr.join("\n").replace(/\t/g, " "), info: myHeader + myInfo, metadata: hits[n], uuid: drafts[i].uuid, css_classes: css_class, }); n++; } } i++; } return result; }; // Define a set to store unique UUIDs const uniqueUUIDs = new Set(); // Modify the main logic to handle the new search requirement if (queries) { const queriesDD = [...new Set(queries)]; const searchStr = '"' + queriesDD.join('" OR "') + '"'; const drafts = Draft.query(searchStr, searchScope, [], [ignoreResultsTaggedWith], modOrCreated, true); backlinks.push(...processDrafts(drafts, searchStr, fullDraftsAsContext)); // Check if search string is found in "aliases: [" line drafts.forEach(draft => { draft.lines.forEach(line => { if (line.trim().startsWith("aliases: [")) { const aliases = line.substring(line.indexOf("[") + 1, line.lastIndexOf("]")).split(","); aliases.forEach(alias => { const aliasSearchStr = alias.trim(); const aliasDrafts = Draft.query(aliasSearchStr, searchScope, [], [ignoreResultsTaggedWith], modOrCreated, true); backlinks.push(...processDrafts(aliasDrafts, aliasSearchStr, fullDraftsAsContext)); }); } }); }); } else { const drafts = Draft.query(searchStr, searchScope, [], [ignoreResultsTaggedWith], modOrCreated, true); backlinks.push(...processDrafts(drafts, searchStr, fullDraftsAsContext)); // Check if search string is found in "aliases: [" line drafts.forEach(draft => { draft.lines.forEach(line => { if (line.trim().startsWith("aliases: [")) { const aliases = line.substring(line.indexOf("[") + 1, line.lastIndexOf("]")).split(","); aliases.forEach(alias => { const aliasSearchStr = alias.trim(); const aliasDrafts = Draft.query(aliasSearchStr, searchScope, [], [ignoreResultsTaggedWith], modOrCreated, true); backlinks.push(...processDrafts(aliasDrafts, aliasSearchStr, fullDraftsAsContext)); }); } }); }); } // Filter out duplicate entries based on UUID and description let filteredBacklinks = []; let seenDescriptions = new Set(); backlinks.forEach(item => { const description = item.description; const key = item.uuid + '|' + description; if (!seenDescriptions.has(key)) { seenDescriptions.add(key); filteredBacklinks.push(item); } }); // Clear the contents of backlinks backlinks.length = 0; // Turns out, I have some drafts that have empty first lines; these can break the prompt. Push the filtered items into backlinks with adjustments for empty titles... filteredBacklinks.forEach(item => { if (item.title === "") { item.title = "(first line of draft empty)"; } backlinks.push(item); }); if (outputMode == "new draft with results") { let output = "# FILTER FOR: " + searchStr + "\n" backlinks.forEach(function(item){ if ((item.description === undefined) || (item.description == null) || (item.description == "undefined")) { output += "\n[[" + item.title + "]]\n+ " + item.info + "\n[[uuid:" + item.uuid + "]]\n" } else { output += "\n[[" + item.title + "]]\n↳ " + item.description.replace("undefined\n↳","").replaceAll(" ","\t") + "\n\t+ " + item.info + "\n\t[[uuid:" + item.uuid + "]]\n" } }) let filterD = new Draft() filterD.content = output filterD.addTag(ignoreResultsTaggedWith) filterD.update() if (device.model == "iPhone") { editor.load(filterD) } else { app.openInNewWindow(filterD) } } else { // Create an MGCheckListPrompt. var prompt = new MGCheckListPrompt(); prompt.message = "Query: " + searchStr; prompt.escapeHTML = false prompt.addItems(backlinks) prompt.allowsTypeToFilter = true if (outputMode == "menu (select)") { prompt.singleSelectionMode = true prompt.selectsImmediately = true } else if (outputMode == "menu (insert)") { prompt.singleSelectionMode = false prompt.selectsImmediately = false } prompt.processMarkdown = true prompt.includedContent = ` <style> .item-title { display: inline; white-space: normal; } .item-description { font-size: 90%; display: inline; } .item-info { font-size: 70%; display: inline; } .mark { background: rgba(245, 221, 0, 0.2); display: inline; white-space: normal; } </style> ` // Show the prompt. var selectedItems = prompt.show(); // Report the result. if (prompt.didShow) { if (selectedItems != null) { if (outputMode == "menu (select)") { source = Draft.find(backlinks[selectedItems[0]].uuid); editor.load(source) fullText = editor.getText() anchor = backlinks[selectedItems[0]].metadata pos = fullText.indexOf(anchor) // find position of selected line // pos = fullText.search("/"+ escapeRegex(anchor) +"/i") // if (queries) {searchStr = queries[0].toString().substr(2).slice(0, -2)} // replaced this to make the positioning of search results from tag queries more accurate but need to check this against linked and unlinked references... if (queries) {searchStr = backlinks[selectedItems[0]].queryString} pos = fullText.toUpperCase().indexOf(searchStr.toString().toUpperCase(), pos) // this to select the search term editor.setSelectedRange(pos,searchStr.toString().length) // anchor.length or searchStr.length, to select search term editor.activate(); } else { const doneReg = /@done\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/g // If I'm using this to grab an exercise from an exercise log and insert it into a current log, I'll likely want to remove the done date/time. I don't imagine many other people will use it like this, but I can loop back here and check for a specific tag to make it even less likely to affect anyone else's workflows unwittingly... // editor.setSelectedText(backlinks[selectedItems[0]].description.replaceAll(" ","\t").replaceAll(doneReg,"")) let selectedText = ""; selectedItems.forEach(index => { const selectedItem = backlinks[index]; const processedDescription = selectedItem.description.replaceAll(" ", "\t").replaceAll(doneReg, ""); selectedText += processedDescription + "\n"; }); editor.setSelectedText(selectedText); } } else { // most likely when you tap a link within the menu... in this instance the "paste to current draft" links... let s = context.previewValues["myString"] if (s != null) { editor.setSelectedText(s) } } } else { app.displayErrorMessage("Looks like your query returned no results."); context.cancel() } }
Options
-
After Success Nothing Notification None Log Level Error