Action

Block level filter :MGCL

Posted by @jsamlarose, Last update about 2 years ago

This is a development on the original block level filter (https://directory.getdrafts.com/a/1px). Posting it anew rather than just as an update because it includes a prompt element that’s specific to Drafts 33.

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"
    
    var outputMode = "menu"
    
    // 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...
    
    // 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

    // INITIAL PROMPT TO DEFINE FILTER
    
    // the way the action is invoked determines whether this prompt is displayed...
    
    if (editor.getSelectedText().length > 1) {
    	var searchStr = editor.getSelectedText()
    } else if (draft.content.startsWith("fromWikiLink:")) {
    	var searchStr = draft.content.replace("fromWikiLink:","")
    	// alert(searchStr)
    	// 20211026: if tag includes an underscore, search both as tag AND key phrase
    	// if it's an inline tag or something that's been run via a shortcut, this search will have been called from the syntax, so it'll be drawn in under this context...
    	if (searchStr.includes("_")){
    		var queries = [
      				searchStr,
      				searchStr.replaceAll("_"," ").replace("#",""),
    			];
    	}
    } else { 
    
    	// no selection, and it wasn't invoked from a wikilink, so let's build the prompt...
    	
    	var searchStr = ""
    	lSel = editor.getSelectedLineRange()
    	sel = editor.getTextInRange(lSel[0],lSel[1])
    
    	var p = Prompt.create();
    	p.title = "Filter";
    
    	p.addLabel("label","Search for:", {
    		"textSize": "headline"
    	});
    
    	p.addTextField("searchTerm", "", "", {
      		"wantsFocus": true
    	});
    	
    	p.addLabel("label","OR filter by:", {
    		"textSize": "headline"
    	});
    	
        // options for backlinks...
    
    	var linkRefs = ["(Linked references for this draft)","(Unlinked references for this draft)"];
    
    	// is there a wiki-link in the current line? Let's check, and if so, add to the list of potential filters...
    	var buildArr = []; 
    	
    	if (/\B#(\d*[A-Za-z0-9-_]+\w*)\b(?!;)/.test(sel)){ 
    		buildArr = [...new Set(buildArr.concat(sel.match(/\B#(\d*[A-Za-z_]+\w*)\b(?!;)/g)))];
    		}
    
    	if  (/\[\[[\#|\w|\s|@|\.|\?|»|\:|\(|\)|\/|\||\-|\+"]+\]\]/.test(sel)) {
    		buildArr = [...new Set(buildArr.concat(sel.match(/\[\[[\#|\w|\s|@|\.|\?|»|\:|\(|\)|\/|\||\-|\+"]+\]\]/g)))]} 
    	
    	var opts = buildArr.concat(linkRefs)
    
    	p.addSelect("linkSel", "", opts, [], false);
    
    	// show search settings if enabled in first action step...
    
    	if (preferences == "form") {
    
    		// p.addSwitch("modOrCreated", "Sort by modified? (off = created)", false);
    		// p.addSwitch("context", "Show full draft if search term is in title?", false);
    		// p.addSwitch("searchScope", "Search all? (off = search inbox only)", false);
    		p.addLabel("label","Set sort order & search scope:", {
    			"textSize": "headline"
    		});
    		p.addSegmentedControl("modOrCreated", "", ["modified", "created"], "created");
    		p.addSegmentedControl("searchScope", "", ["all", "inbox"], "inbox");
    	// }
    		p.addButton("new draft with results");
    		p.addButton("menu");	
    
    		var didSelect = p.show();
    
    	// set variables and settings in response to chosen/selected terms...
    		
    		if (p.buttonPressed == "menu" || p.buttonPressed == "new draft with results") {
    		// if (preferences == "form") {
    
    			// var fullDraftsAsContext = p.fieldValues["context"]
    			var searchScope = p.fieldValues["searchScope"] // ? "all" : "inbox"
    			var modOrCreated = p.fieldValues["modOrCreated"] // ? "modified" : "created"
    			var mocProp = modOrCreated + "At"
    			var outputMode = p.buttonPressed
    		}
    
    		if (p.fieldValues["searchTerm"].length > 0){
    
    			searchStr = p.fieldValues["searchTerm"]
    		
    		} else if (p.fieldValues["linkSel"].toString().startsWith("[[s:")||p.fieldValues["linkSel"].toString().startsWith("[[@:")){
    			
    			// a syntax defined search should just be a search term... 
    			searchStr = p.fieldValues["linkSel"].toString().replace("[[s:","").replace("[[@:","").replace("]]","")
    		
    		} else if (p.fieldValues["linkSel"].toString().includes("(Linked references for this draft)")){
    			
    			searchStr = p.fieldValues["linkSel"].toString()
    			var queries = [
      				"[["+ draft.displayTitle +"]]",
      				"[[# "+ draft.displayTitle +"]]",
    			]
    		
    		} else if (p.fieldValues["linkSel"].toString().includes("(Unlinked references for this draft)")){
    			
    			var searchStr = draft.displayTitle;
    		
    		} else if (/\B#(\d*[A-Za-z0-9-_]+\w*)\b(?!;)/.test(p.fieldValues["linkSel"].toString())){
    			
    			var searchStr = p.fieldValues["linkSel"].toString();
    		
    		} else if (p.fieldValues["linkSel"].length > 0){
    			// searchStr = p.fieldValues["linkSel"].toString().replace("[[s:","[[")
    			searchStr = p.fieldValues["linkSel"].toString()
    			searchStr = searchStr.split('[[').pop().split(']]')[0].replace("# ","")
    			// searchStr = "\"[[" + searchStr + "\" OR \"[[# " + searchStr + "\""
    			var 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, "&lt;")
             .replace(/>/g, "&gt;")
             .replace(/"/g, "&quot;")
             .replace(/'/g, "&#039;");
     }
    
    // 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
    
    var i = 0;
    var backlinks = []
    var myTitle = ""
    var myInfo = ""
    var testhits = ""
    
    if (queries) { // this case if we're searching for a linked reference or a tag with an underscore in it (from a shortcut, or via a syntax wiki-link. 
    			// problem: this is failing with simple queries now. It's okay for things that require OR queries, but if it's just one search term... 
    
    	let queriesDD = [...new Set(queries)];
    	var searchStr = "\"" + queriesDD.join("\" OR \"") + "\""
    
    	let drafts = Draft.query(searchStr, searchScope,[],[ignoreResultsTaggedWith],modOrCreated,true)
    
    	while(drafts[i]){
    		// get array of headers in this draft
    		const headerIdx = getAllIndexes(drafts[i].lines,/^#+ /)
    		let q=0 
    
    		while(queries[q]){
        			hits = drafts[i].lines.filter(element => element.toString().toUpperCase().includes(queries[q].toString().toUpperCase()) )
        			let n=0
        			while(hits[n]){
    				// testhits += hits[n]
        				let descr = hits[n] ? hits[n] : drafts[i].title
        				let separator = drafts[i].tags.join() == "" ? "" :  " ... "
        				let regex = /(\B#(\d*[A-Za-z_]+\w*)\b(?!;))/g
        				let 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 {
    				// TO FIX: this is supposed to highlight search result if drawn from draft currently loaded in editor, but this often won't work here because this context is typically loaded from the syntax (as a tag query) or from a shortcut... fix by getting the UUID of the draft in the editor... 
    					// if (draft.uuid == drafts[i].uuid){
    					if (editor.getText().split("\n")[0] == drafts[i].title){
    						myTitle = "<mark> "+drafts[i].title+"</mark>"
    					} else {
    						myTitle = drafts[i].title
    					}
    					myInfo = inlineTags + drafts[i].tags.join(", ") + separator + timeDifference(drafts[i][mocProp])
    				}
    
    			const index = drafts[i].lines.findIndex(line => line === hits[n]); // index of current hit in array to calculate header...
    
    			let myHeader = getClosestValue(headerIdx,index) // find header for this hit
    			if (typeof myHeader == 'undefined') {myHeader = 0} // if draft contains no headers
    			myHeader = (myHeader == 0) ? "" : drafts[i].lines[myHeader].replaceAll('#','').trim() + " ↵ "
    			
    			backlinks.push({
    				title: myTitle,
    				description: descr.replace(/\t/g,""),
    				info: myHeader + myInfo + "\n<a id=\"myLink\" href=\"javascript:void(0)\" onclick=\"Drafts.send('myString', '" + hits[n] + "');Drafts.cancel();return false;\">paste to current draft</a>",
    				metadata: escapeHtml(drafts[i].content), // this was breaking the list in some cases. Might need to watch out for other cases that might break the list... 
    				// metadata: hits[n],
    				uuid: drafts[i].uuid,
    				permalink: drafts[i].permalink,
    				queryString: queries[q]
            		});
        			n++;
        		}
    		q++;
    	}
    	i++;
    	}
    
    //	const
    //	keys = ['title', 'description'],
    //    		filtered = backlinks.filter(
    //        		(s => o => 
    //            		(k => !s.has(k) && s.add(k))
    //            		(keys.map(k => o[k]).join('|'))
    //        		)
    //        		(new Set)
    //    		);
    } else {
    	// all other searches...
    	// alert(searchStr)
    
    	let drafts = Draft.query(searchStr, searchScope,[],[ignoreResultsTaggedWith],modOrCreated,true)
    
    	while(drafts[i]){
    	
    		// get array of headers in this draft
    		const headerIdx = getAllIndexes(drafts[i].lines,/^#+ /)
    		
        		hits = drafts[i].lines.filter(element => element.toString().toUpperCase().includes(searchStr.toString().toUpperCase()) )
        		var n=0
        		while(hits[n]){
        			if (fullDraftsAsContext == true) {
    
        			descr = drafts[i].title == hits[n] ? drafts[i].body : hits[n]
        			} else {
        			descr = drafts[i].title == hits[n] ? drafts[i].lines.filter((a) => a)[1] + "\n↳ _**first line of draft; search term in title**_" : hits[n]
    			}
    			let separator = drafts[i].tags.join() == "" ? "" :  " ... "
    			let regex = /(\B#(\d*[A-Za-z_]+\w*)\b(?!;))/g
        			let 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 {
    				// highlight search result if drawn from draft currently loaded in editor
    				// if (draft.uuid == drafts[i].uuid){
    				if (editor.getText().split("\n")[0] == drafts[i].title){
    					myTitle = "<mark> "+drafts[i].title+"</mark>"
    				} else {
    					myTitle = drafts[i].title
    				}
    				myInfo = "**" + timeDifference(drafts[i][mocProp]) + "** " + inlineTags + drafts[i].tags.join(", ")
    			}
    
    			const index = drafts[i].lines.findIndex(line => line === hits[n]); // index of current hit in array to calculate header...
    
    			let myHeader = getClosestValue(headerIdx,index) // find header for this hit
    			if (typeof myHeader == 'undefined') {myHeader = 0} // if draft contains no headers
    			myHeader = (myHeader == 0) ? "" :  drafts[i].lines[myHeader].replaceAll('#','').trim() + " ↵ "
    			
    			backlinks.push({
    						title: myTitle,
    						description: descr.replace(/\t/g,""),
    						info: myHeader + myInfo + "\n<a id=\"myLink\" href=\"javascript:void(0)\" onclick=\"Drafts.send('myString', '" + hits[n] + "');Drafts.cancel();return false;\">paste to current draft</a>",
    						metadata: hits[n],
    						uuid: drafts[i].uuid
            			});
        		n++;
        		}
    	i++;
    	}
    
    }
    
    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[[u:" + item.uuid + "]]\n"
    		} else {
    			output += "\n[[" + item.title + "]]\n↳ " + item.description.replace("undefined\n↳","") + "\n\t+ " + item.info + "\n\t[[u:" + 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
    prompt.singleSelectionMode = true
    prompt.selectsImmediately = true
    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-color: gold;
        color: black;
    }
    </style>
    `
    
    // Show the prompt.
    var selectedItems = prompt.show();
    
    // Report the result.
    if (prompt.didShow) {
    	if (selectedItems != null) {
    		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 { // 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 None
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.