Action

Block level filter :MGCL

Posted by @jsamlarose, Last update 5 months ago

UPDATES

5 months ago

Added option to adjust continuation character in first script step.

Releasing this update as a new action (again). It contains some developments since the last update that may or may not be useful to others.

CONTINUATION BLOCKS IN PREVIEWS: When you search for a term, previews will now also include blocks immediately following the line that contains your search term, if those lines contain the continuation symbol (↳), up until the next line that doesn’t contain that symbol. Useful for proximally related context blocks (obvious inspiration drawn from PKM apps like Roam and Logseq). There’s support for rendering continuation characters as bullets in my Drafts syntax (https://actions.getdrafts.com/s/1xC), and an action for toggling through bullet points (https://directory.getdrafts.com/a/2Ln).

WORKAROUND FOR INSERTS: The action implements a workaround for inserting a selected search result in the current draft. Use case example: I use Drafts to log workouts. This allows me to search for the previous instances of an exercise (allowing me a sense of progress over time), and allows me to insert a previous instance of an exercise in today’s workout log.

MISCELLANY: updated the way results from the current draft are highlighted. And I really need to refactor some of the scripting here, I know…

This is a development on the original block level filter. Posting it anew rather than just as an update because it includes a prompt element that’s specific to Drafts 33.

See older versions for further notes on functions.

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 (select)"
    
    // 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...
    
    // 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 (insert)");	
    		p.addButton("menu (select)");	
    
    		var didSelect = p.show();
    
    	// set variables and settings in response to chosen/selected terms...
    		
    		if (p.buttonPressed == "menu (select)" || p.buttonPressed == "menu (insert)" ||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 tabcount = (descr.split("\t").length - 1)
    				for (let contCheck = 1; contCheck < 10; contCheck++) {
    					let cont = drafts[i].lines[drafts[i].lines.indexOf(hits[n])+contCheck]  
    					//if (cont && cont.includes("↳")) {descr += "\n" + cont} else {break;} 
    					if (cont && cont.includes(contChar)) {descr += "\n" + cont} else {break;} 
    				}
    // This helps to catch the first "continuation" block. Could be improved. 
    				// let cont = drafts[i].lines[drafts[i].lines.indexOf(hits[n])+1]
    				// if (cont && cont.includes("↳")) {descr += "\n" + cont} 
    // alternatively, could I handle this with a regex? Find this block and each one after it (linked by an \n that contains a continuation symbol? That might be easier... 
        				// cont = drafts[i].lines[drafts[i].lines.indexOf(hits[n])+2]
    				// if (cont && cont.includes("↳")) {descr += "\n" + cont} 
    
    
    
        				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){
    					css_class = { 
    						item: "mark"
    					}
    				} else { 
    					css_class = {} 
    				}
    				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,
    				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]
    			}
    
    			// This helps to catch the first "continuation" block. Could be improved. 
    			for (let contCheck = 1; contCheck < 10; contCheck++) {
    					let cont = drafts[i].lines[drafts[i].lines.indexOf(hits[n])+contCheck]  
    					// if (cont && cont.includes("↳")) {descr += "\n" + cont} else {break;}
    					if (cont && cont.includes(contChar)) {descr += "\n" + cont} else {break;} 
    				}
    
    			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){
    					css_class = { 
    						item: "mark"
    					}
    				} else { 
    					css_class = {} 
    				}
    				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,
    						metadata: hits[n],
    						uuid: drafts[i].uuid,
    						css_classes: css_class
            			});
        		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
    	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 {
    			
    				editor.setSelectedText(backlinks[selectedItems[0]].description)
    			
    			}
    				
    		} 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.