Action

Open Cross-Link at Cursor

Posted by @mattgemmell, Last update 29 days ago

Opens the [[cross-link]] at the text cursor position, or within the text selection. Only intended for use with title links. See the various configurable options at the top of the script.

By Matt Gemmell — @mattgemmell — https://mattgemmell.com/

Steps

  • defineTemplateTag

    name
    newDraftTemplateName
    template
    Cross-Linked Draft Template
  • script

    /*
    
    Description: Drafts 20.0 and later supports cross-linking between drafts using the double-bracket syntax [[like this]]. This action allows the user to open such links programmatically while editing a draft, for example using a keyboard shortcut, making it convenient and efficient to manage a corpus of interconnected documents without removing their hands from the keyboard.
    
    Functionality:
    1. Open the cross-link at the text-editing cursor.
    2. Optionally ask before creating new drafts.
    3. A basic template system is supported.
    
    Author: 		Matt Gemmell
    Twitter: 	@mattgemmell	https://twitter.com/mattgemmell
    Web: 			https://mattgemmell.com/
    
    */
    
    /* =========================== */
    // Configuration
    /* =========================== */
    
    
    var chooseBestMatch = false;
    /*
    When more than one draft could be the destination of a link, this setting determines what happens. If true, the best match (according to Drafts' own internal logic) will be opened without user intervention. If false, the user will be shown a list of matching drafts to choose from.
    */
    
    
    var ensureInitialCapitals = true;
    /*
    If true, newly-created drafts will have their title's first letter made uppercase if necessary.
    */
    
    
    var askBeforeCreatingNewDrafts = false;
    /*
    If true, when a link's target document isn't found, the user will be asked before a new draft with the link's title is created. If false, new drafts will be created implicitly.
    */
    
    
    var newDraftTemplateName = "";
    /*
    Title of an existing draft which will be used as a template when creating new ones.
    If the template is not found, a blank draft will be created with the appropriate title.
    Use an empty string ("") to disable the template functionality.
    
    Notes:
    	- The new draft's title — i.e. the template's first non-blank line — will be replaced appropriately.
    	- Any leading hash/pound symbols (i.e. "#") will be preserved when setting the new title.
    	- Drafts template tags will be processed (https://docs.getdrafts.com/docs/actions/templates ).
    	- Some custom template tags are available:
    	
    		- source_display_title is the display title of the source draft which created the new draft.
    		- source_uuid is the UUID of the source draft which created the new draft.
    		- source_title_link is a preformatted [[backlink]] to the source draft's display title.
    		- source_uuid_link is a preformatted [[u:backlink]] to the source draft's UUID.
    */
    
    
    var inheritTagsFromTemplate = true;
    /*
    If true, any tags which are applied to the template will also be applied to each new draft created from it.
    */
    
    
    var excludedTags = ["template"];
    /*
    The tags present in this array will NOT be inherited by new drafts created from the template. Has no effect if inheritTagsFromTemplate (above) is false.
    */
    
    
    var prependToNewTitles = "";
    /*
    A string to prepend to the titles of new pages (but after any leading Markdown headers and whitespace from the template). This string will be processed for Drafts template tags first, in the contetx of the new draft. A useful example for unique identifiers would be: "[[date|%Y%m%d%H%M%S]] "
    */
    
    
    /* =========================== */
    // END of Configuration
    /* =========================== */
    
    
    /* =========================== */
    // A few useful functions.
    /* =========================== */
    
    
    String.prototype.splitLines = function () {
    	// Splits string into lines, each ending with a newline (\n).
    	// Note: the last fragment might not end with a newline.
    	return this.match(/[^\n]*\n/g);
    };
    
    function minRange(theRange) {
    	// Returns index of start of theRange.
    	return theRange[0];
    }
    
    function maxRange(theRange) {
    	// Returns index immediately AFTER end of theRange.
    	return theRange[0] + theRange[1];
    }
    
    String.prototype.lastChar = function () {
    	return this.substring(this.length - 1, this.length);
    };
    
    String.prototype.inRange = function (theRange) {
    	return this.substring(theRange[0], theRange[0] + theRange[1]);
    }
    
    function editorTextInRange(editor, range) {
    	// Returns editor's text in the given range.
    	return editor.getTextInRange(range[0], range[1]);
    }
    
    function show(title, content) {
    	// Make it easier to see where whitespace/newlines are.
    	alert(title + "\n#" + content + "#");
    }
    
    function showInRange(title, range, text) {
    	show(title, text.substring(range[0], range[0] + range[1]));
    }
    
    function rangesOverlap(range1, range2) {
    	// You're looking at this and thinking WTF, aren't you?
    	// If range1 and range2 overlap, there exists a number which both ranges contain.
    	// Logic as a consequence of De Morgan. Have fun.
    	return ((minRange(range1) <= maxRange(range2)) && (maxRange(range1) >= minRange(range2)));
    }
    
    function getTitle(theDraft) {
    	return theDraft.displayTitle;
    }
    
    function openDraft(theDraft) {
    	editor.load(theDraft);
    	editor.activate();
    }
    
    
    /* =========================== */
    // End of functions.
    /* =========================== */
    
    
    // Grab entire line(s) of text which contain the selection.
    var selectedLineRange = editor.getSelectedLineRange();
    var selectedLines = editorTextInRange(editor, selectedLineRange);
    
    // If selection spans more than one line, abort.
    var newLineIndex = selectedLines.indexOf("\n");
    if (newLineIndex !== -1 && newLineIndex!== selectedLines.length - 1) { // i.e. Just a trailing newline.
    	app.displayInfoMessage("Doesn't work with multi-line selections.");
    	
    } else {
    	var selRange = editor.getSelectedRange();
    	selRange[0] -= selectedLineRange[0]; // Normalise to be line-relative.
    	
    	/*
    	Methodology:
    	1. Find a link either intersecting or immediately adjacent to the selection/insertion.
    	2. If a proposed link title is identified, attempt to find a corresponding existing draft.
    	3. Either open or create the corresponding draft as appropriate.
    	*/
    	
    	// Limit the amount of text we're searching for the closest match to a reasonable length.
    	// In effect, this is the maximum length of link that we'll notice.
    	var searchRadius = 250; // Num chars around selection/insertion to search.
    	
    	var searchRange = [0, selectedLines.length]; // Entire line.
    	if (minRange(selRange) > searchRadius) {
    		// Move beginning of searchRange to within searchRadius of selRange's start.
    		searchRange[0] = minRange(selRange) - searchRadius;
    		searchRange[1] -= searchRange[0];
    	}
    	if ((maxRange(selRange) + searchRadius) < maxRange(searchRange)) {
    		// Move end of searchRange to within searchRadius of selRange's end.
    		searchRange[1] = maxRange(searchRange) + searchRadius;
    	}
    	var searchText = selectedLines.substring(minRange(searchRange), maxRange(searchRange));
    	
    	// Find links within searchText.
    	/*
    	1. We ignore links which are not either adjacent to the selection or overlapping it.
    	2. We prefer links which intersect the selection, as an indicator of user intent.
    	3. For non-intersecting (i.e. adjacent) links, we prefer those behind/before the selection.
    	4. We prefer the first link of multiple candidates, notwithstanding point 2.
    	*/
    	
    	var openingDelimiter = '\\[\\[';
    	var closingDelimiter = '\\]\\]';
    	var crossLinksPattern = openingDelimiter + '(?:d:)?(.+?)' + closingDelimiter;
    	var linksRegExp = new RegExp(crossLinksPattern, "g");
    	var abortSearch = false;
    	var linkTitle = null;
    	var linkRange = null;
    	var match;
    	do {
    		match = linksRegExp.exec(searchText);
    		if (match) {
    			var matchRange = [match.index, match[0].length];
    			
    			// We'll abort searching in three situations:
    			// 1. We find a link intersecting the selection.
    			// 2. We find a second (and thus later, less preferable) non-intersecting link.
    			// 3. Our search has already moved beyond the selection.
    			
    			if (rangesOverlap(matchRange, selRange)) {
    				// Selection and match ranges overlap.
    				abortSearch = true;
    				linkTitle = match[1];
    				linkRange = matchRange;
    				
    			} else {
    				// Determine distance of matchRange from selRange.
    				// For non-overlapping ranges, we define this as the clear interval between them.
    				// As noted, we'll discard candidates whose distance is >0 (i.e. non-adjacent).
    				var distance = 1;
    				if (minRange(matchRange) > minRange(selRange)) {
    					// Match is before selection.
    					distance = minRange(selRange) - maxRange(matchRange);
    					
    				} else {
    					// Match is after selection.
    					distance = minRange(matchRange) - maxRange(selRange);
    				}
    				
    				if (distance == 0) {
    					// This is a candidate.
    					if (linkTitle != null) {
    						// This is the second non-intersecting link we've found, so abort search.
    						// (Since we insist on adjacent links, if we've found two, we're already past the selection.)
    						abortSearch = true;
    						
    					} else {
    						// This is the first non-intersecting link we've found. Make it our fallback candidate.
    						// Keep searching, in case we're before the selection and there's an intersecting link later.
    						linkTitle = match[1];
    						linkRange = matchRange;
    					}
    					
    					// Finally, we can abort searching if we're already past the selection.
    					if (minRange(matchRange) >= maxRange(selRange)) {
    						abortSearch = true;
    					}
    				}
    			}
    		}
    	} while (match && !abortSearch);
    	
    	if (linkTitle) {
    		// Try to find a draft with a matching title.
    		linkTitle = linkTitle.trim();
    		var draftToOpen = null;
    		var foundDrafts = Draft.queryByTitle(linkTitle);
    		if (foundDrafts.length > 0) {
    			// We found at least one match.
    			if (foundDrafts.length == 1 || chooseBestMatch) {
    				// Open the first/only match.
    				draftToOpen = foundDrafts[0];
    				
    			} else {
    				// Choose one of the matching drafts.
    				var draftTitles = foundDrafts.map(getTitle);
    				
    				var prompt = Prompt.create();
    				prompt.isCancellable = true;
    				prompt.title = "Open which draft?";
    				prompt.addButton("Open");
    				prompt.addSelect("select", "", draftTitles, [draftTitles[0]], false);
    				var didSelect = prompt.show();
    				
    				var chosenDraftTitle = prompt.fieldValues["select"][0];
    				if (didSelect) {
    					var draftIndex = draftTitles.indexOf(chosenDraftTitle);
    					draftToOpen = foundDrafts[draftIndex];
    				}
    			}
    			
    			if (draftToOpen != null) {
    				openDraft(draftToOpen);
    			}
    			
    		} else {
    			// We have to create a new draft.
    			var createDraft = true;
    			
    			// Deal with initial capitals.
    			if (ensureInitialCapitals) {
    				linkTitle = linkTitle.charAt(0).toUpperCase() + linkTitle.slice(1);
    			}
    			
    			// Check whether to ask the user's permission first.
    			if (askBeforeCreatingNewDrafts) {
    				var prompt = Prompt.create();
    				prompt.isCancellable = true;
    				prompt.title = "Create new draft?";
    				prompt.addLabel("draftTitle", "“" + linkTitle + "”", {"textSize": "headline"});
    				prompt.addButton("Create");
    				createDraft = prompt.show();
    			}
    			
    			if (createDraft) {
    				draftToOpen = new Draft();
    	
    				// Deal with prepended title string, if set.
    				if (prependToNewTitles.length > 0) {
    					linkTitle = draftToOpen.processTemplate(prependToNewTitles) + linkTitle;
    				}
    				
    				draftToOpen.content = "# " + linkTitle + "\n\n";
    				
    				// Handle template requirements.
    				if (newDraftTemplateName == null || newDraftTemplateName == "") {
    					newDraftTemplateName = draft.getTemplateTag("newDraftTemplateName");
    				}
    				
    				if (newDraftTemplateName != null && newDraftTemplateName != "") {
    					// Attempt to locate template.
    					var foundTemplates = Draft.queryByTitle(newDraftTemplateName);
    					if (foundTemplates.length > 0) {
    						var template = foundTemplates[0];
    						var templateContent = template.content;
    						
    						// Replace title appropriately.
    						templateContent = templateContent.replace(template.displayTitle, linkTitle);
    						
    						// Process template tags.
    						draftToOpen.setTemplateTag("source_display_title", draft.displayTitle);
    						draftToOpen.setTemplateTag("source_uuid", draft.uuid);
    						draftToOpen.setTemplateTag("source_title_link", "\[[" + draft.displayTitle + "]]");
    						draftToOpen.setTemplateTag("source_uuid_link", "\[[u:" + draft.uuid + "]]");
    						templateContent = draftToOpen.processTemplate(templateContent);
    						
    						// Transfer tags if appropriate.
    						if (inheritTagsFromTemplate) {
    							for (tag of template.tags) {
    								if (!excludedTags.includes(tag)) {
    									draftToOpen.addTag(tag);
    								}
    							}
    						}
    						
    						// Apply contents to our new draft.
    						draftToOpen.content = templateContent;
    						
    					} else {
    						app.displayInfoMessage("Couldn't find new draft template.");
    					}
    				}
    				
    				// Open draft.			
    				if (draftToOpen != null) {
    					draftToOpen.update();
    					openDraft(draftToOpen);
    				}
    			}
    		}
    		
    	} else {
    		app.displayInfoMessage("No cross-link at cursor position.");
    	}
    }
    
    /*
    
    Test test [[test [and] stuff]]. Can also do [single] words [[linked]] too. More [[d:tests and things]].
    Link to a [[sample document]] for testing.
    
    */

Options

  • After Success Nothing
    Notification Error
    Log Level Error
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.