Copy link to current block
Read your blog post and thought I'd share this gem I've been using for a while. Basically copies a link to the current block at your cursor, creating the ^xxx at the end of the line if it doesn't exist. Very useful if you like to create new pages and embed blocks of other notes.
https://gist.github.com/benhughes/1cd1a3f6239d51ffdf8fbf2e3efe7286
Happy to create a PR if you want it in your repo
Thanks for the offer 😎
I don't personally use block links much, but do so wish embeds would be editable - I couldn't get myself into the hover edit approach. When I looked back through my lesser used templates, I did have a couple for doing something similar.
It looks like your template is modifying the file directly (via app.vault.modify()), which is quite a bit more sophisticated than what I have that are just doing cursor insertions, though I do also copy a link.
I've now neatened up the code a little (better variable names, added comments that might be meaningful to others rather than just me).
I do like the idea of being able to apply the unique section ID regardless of where your cursor is positioned in the block. The only limitation I noted when I was going through the code (and a subsequent test), was if the section text is non-unique within the note.
If I have a shopping list like this:
- Milk Chocolate
- Eggs
- Flour
- Milk
- Sugar
And I place my cursor on line 4 to add a section link for the item "Milk", I end up with a note like this:
- Milk ^x0p6tt4 Chocolate
- Eggs
- Flour
- Milk
- Sugar
It doesn't even need to be the start of a line - it could appear somewhere in the line.
If I add this to the top of the note:
# My Cookies - Milkiest Cookies Ever!
The ID would get added mid-word.
# My Cookies - Milk ^1t9pqc5iest Cookies Ever!
- Milk Chocolate
- Eggs
- Flour
- Milk
- Sugar
I will mull this over, but I would want to have a way that can account for this. While I am reasonably confident that the majority of people would simply not hit this issue, I am equally confident that there are people out there who would have particular use cases where they do want to link to a block which happens to have text that appears within other text in the note.
Thanks for your thorough response. I originally copied and pasted from an obscure reddit post (that I now can't find) so didn't give it a proper review before I shared. Apologies for wasting your time on that.
Had a look and fixed the issues. Should now work with all your examples. So duplicate text and also duplicate lines.
Basically went a different way from the original author and based everything on lines, so get the line number, then split the text and replace that line.
Yeah looking at the docs it's a shame templater doesn't allow direct editing of the file beyond cursor manipulation so still a bit hacky, so feel free to ignore if it doesn't suit the ethos of your scripts.
Thanks for your hard work on creating these by the way.
I tried it out, and it was not working for me. Looking at the code, I could see a couple of things that looked off.
First is that line 17 has this definition for newLines.
const newLines = result.split("\n")
Line 19 then attempts to modify the constant.
newLines[cursorLine] = `${lineText} ^${lineId}`
So I changed that to be defined by a let.
According to the Obsidian documentation, the vault.modify() function is defined as a promise, so I think line 22 should have an await like this.
await app.vault.modify(currentFile, newLines.join("\n"));
[!NOTE]
I think technically, since the file content is being both read and written thatvault.process()should be used instead ofvalue.read()andvault.write(). I don't think this would make sense to be responsible for the behaviour I am seeing though.
I didn't try modifying for this last step, but until I had done the other two steps I wasn't getting the ID being output in the note (when there was no existing one). With the two changes I do get the ID being output, but it is always appearing on a new line ... even though if I look at the joined newLines array via an alert, it is on the same line as expected.
If I switch out the new text to modify with a simple string "foo", the resulting file I see is a blank line follows by foo. So the value.modify() function for me in Obsidian 1.5.3 seems to be adding random additional newlines. I did a search in case this is a known issue, but I came up empty, so I'm not exactly sure why an additional newline is being added. Probably something obvious I'm missing somewhere.
Can you double check and confirm your original code is working (without the await, and with newLines defined as const), and if not, if the suggested alterations above give you the same extra newline issues I am seeing?
<%*
const currentFile = app.workspace.activeLeaf.view.file;
const editor = app.workspace.activeLeaf.view.sourceMode.cmEditor
const cursor = editor.getCursor();
const cursorLine = cursor.line;
const lineText = editor.getLine(cursor.line);
let lineId
if (lineText.contains(" ^")) {
alert("ID found");
const lineTextSplit = lineText.split(" ^");
// If the line already contains a ^ then get the text after it
lineId = lineTextSplit[lineTextSplit.length -1]
alert("found lineId\n" + lineId);
} else {
alert("ID to be created");
// Create a new ID
lineId = createBlockHash();
alert("created lineId\n" + lineId);
const result = await app.vault.read(currentFile)
//** CHANGED - switched from const to let to allow content to be modified ***
let newLines = result.split("\n")
// replace the line at the cursor with the text followed by the new ID
newLines[cursorLine] = `${newLines[cursorLine]} ^${lineId}`
let strTest = newLines.join("\n")
alert("UPDATED FILE CONTENT\n" + strTest)
// save the file with the new line of text
// ** CHANGED - added an await ***
await app.vault.modify(currentFile, strTest);
}
// copy the embed link to the block
navigator.clipboard.writeText(`![[${tp.file.title}#^${lineId}]]`);
function createBlockHash() {
let result = "";
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
for ( var i = 0; i < 7; i++ ) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
-%>
I'm not programmer but Claude helped me create this script. I stress-tested it--it works without a hitch. The script extracts or generates a block ID from the current cursor position or text selection, using existing IDs if found at line ends or after code blocks/tables (including any trailing empty lines), otherwise creating and adding a new unique ID, then copies a reference in the format ^[[[filename#^block-id]]] to clipboard.
Templater script
<%*
/**
* Template Name: Grab Block Reference
* Description: Extracts or generates a block ID from cursor position or selection and copies a reference as ^[[[filename#^block-id]]].
* Version: 2.1
* Author: Created via Claude
* Source: https://forum.obsidian.md/t/copy-link-to-block-as-footnote-or-url-with-templater/92600
* Last Updated: 2024-12-14
*/
if (app.workspace.activeEditor.getMode() === "preview") {
new Notice("Reading view isn't supported");
return;
}
const editor = tp.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView).editor;
const selections = editor.listSelections();
if (selections.length > 1) {
new Notice("Multiple selections or cursors aren't supported");
tR = editor.getSelection();
return;
}
function generateId() {
return Math.random().toString(36).substr(2, 6);
}
function shouldInsertAfter(block) {
if (block.type) {
return [
"blockquote",
"code",
"table",
"comment",
"footnoteDefinition",
].includes(block.type);
}
return block.heading !== undefined;
}
function isValidBlockId(id) {
return /^[a-zA-Z0-9-]+$/.test(id);
}
function findNearestNonEmptyLineAbove(editor, currentLine) {
for (let i = currentLine - 1; i >= 0; i--) {
const lineContent = editor.getLine(i).trim();
if (lineContent !== '') {
const isBlockId = lineContent.match(/^\^[a-zA-Z0-9-]+$/);
if (!isBlockId) return i;
}
}
return -1;
}
function findStandaloneBlockId(startLine, editor) {
for (let i = startLine; i < editor.lineCount(); i++) {
const line = editor.getLine(i).trim();
if (line === '') continue;
const match = line.match(/^\^([a-zA-Z0-9-]+)$/);
if (match) return match[1];
break;
}
return null;
}
function getInlineBlockId(line) {
const match = line.match(/\s\^([a-zA-Z0-9-]+)$/);
return match ? match[1] : null;
}
function getBlock(editor, fileCache) {
const cursor = editor.getCursor("to");
const cursorLine = cursor.line;
const currentLineContent = editor.getLine(cursorLine).trim();
const currentSection = fileCache?.sections?.find(section =>
section.position.start.line <= cursorLine &&
section.position.end.line >= cursorLine
);
if (currentSection?.type === "table") {
const blockId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
return {
...currentSection,
id: blockId,
type: blockId ? 'text-with-standalone-id' : 'table'
};
}
if (currentLineContent.match(/^\^[a-zA-Z0-9-]+$/)) {
const blockLine = findNearestNonEmptyLineAbove(editor, cursorLine);
if (blockLine === -1) return null;
const section = fileCache?.sections?.find(section =>
section.position.start.line <= blockLine &&
section.position.end.line >= blockLine
);
if (section?.type === "list") {
return {
...section,
id: currentLineContent.substring(1),
type: 'text-with-standalone-id'
};
}
return {
position: {
start: { line: blockLine, ch: 0 },
end: { line: blockLine, ch: editor.getLine(blockLine).length }
},
type: 'text-with-standalone-id',
id: currentLineContent.substring(1)
};
}
if (currentSection?.type === "list") {
const listItem = fileCache?.listItems?.find(item =>
item.position.start.line <= cursorLine &&
item.position.end.line >= cursorLine &&
currentLineContent === editor.getLine(item.position.start.line).trim()
);
if (listItem) {
const standaloneId = findStandaloneBlockId(listItem.position.end.line + 1, editor);
if (currentLineContent === '' && standaloneId) {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return listItem;
} else {
const standaloneId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
if (standaloneId && currentLineContent === '') {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return currentSection;
}
} else if (currentSection?.type === "heading") {
const heading = fileCache.headings.find(heading =>
heading.position.start.line === currentSection.position.start.line
);
if (heading) {
const headingLine = editor.getLine(heading.position.start.line);
const inlineId = getInlineBlockId(headingLine);
if (inlineId) {
return {...heading, id: inlineId, type: 'text-with-standalone-id'};
}
const standaloneId = findStandaloneBlockId(heading.position.start.line + 1, editor);
if (standaloneId) {
return {...heading, id: standaloneId, type: 'text-with-standalone-id'};
}
return heading;
}
} else if (currentSection) {
const standaloneId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
if (standaloneId) {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return currentSection;
}
const prevLine = cursorLine - 1;
if (prevLine >= 0) {
const prevSection = fileCache?.sections?.find(section =>
section.type === "list" && section.position.end.line === prevLine
);
if (prevSection) {
const standaloneId = findStandaloneBlockId(cursorLine + 1, editor);
if (standaloneId && currentLineContent === '') {
return {
...prevSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
if (currentLineContent === '') {
return {
position: {
start: { line: cursorLine, ch: 0 },
end: { line: cursorLine, ch: 0 }
},
type: 'empty-after-list'
};
}
}
}
if (currentLineContent !== '') {
const standaloneId = findStandaloneBlockId(cursorLine + 1, editor);
return {
position: {
start: { line: cursorLine, ch: 0 },
end: { line: cursorLine, ch: editor.getLine(cursorLine).length }
},
type: standaloneId ? 'text-with-standalone-id' : 'text',
id: standaloneId
};
}
return null;
}
function checkSelectionSpansBlocks(from, to, fileCache, editor) {
const listItems = fileCache?.listItems || [];
const selectedItems = listItems.filter(item => {
const itemStart = item.position.start.line;
let itemEnd = item.position.end.line;
if (findStandaloneBlockId(itemEnd + 1, editor)) {
itemEnd += 2;
}
return (itemStart >= from.line && itemStart <= to.line) ||
(itemEnd >= from.line && itemEnd <= to.line) ||
(itemStart <= from.line && itemEnd >= to.line);
});
if (selectedItems.length > 1) return true;
const sections = fileCache?.sections || [];
const selectedSections = sections.filter(section => {
const sectionStart = section.position.start.line;
let sectionEnd = section.position.end.line;
if (findStandaloneBlockId(sectionEnd + 1, editor)) {
sectionEnd += 2;
}
return (sectionStart >= from.line && sectionStart <= to.line) ||
(sectionEnd >= from.line && sectionEnd <= to.line) ||
(sectionStart <= from.line && sectionEnd >= to.line);
});
return selectedSections.length > 1;
}
async function handleBlock(file, editor, block) {
let blockId;
if (block.type === 'text-with-standalone-id' || block.id) {
blockId = block.id;
} else {
blockId = generateId();
const currentLine = block.position.end.line;
if (block.type === 'empty-after-list') {
await editor.replaceRange("\n", {line: currentLine - 1, ch: editor.getLine(currentLine - 1).length});
const nextLineEmpty = currentLine + 2 < editor.lineCount() && editor.getLine(currentLine + 2).trim() === '';
await editor.replaceRange(`^${blockId}${nextLineEmpty ? '' : '\n'}`, {line: currentLine + 1, ch: 0});
} else if (shouldInsertAfter(block)) {
const nextLineEmpty = currentLine + 1 < editor.lineCount() && editor.getLine(currentLine + 1).trim() === '';
if (!nextLineEmpty) {
await editor.replaceRange("\n\n", {line: currentLine, ch: editor.getLine(currentLine).length});
await editor.replaceRange(`^${blockId}\n`, {line: currentLine + 2, ch: 0});
} else {
await editor.replaceRange("\n", {line: currentLine + 1, ch: 0});
await editor.replaceRange(`^${blockId}\n`, {line: currentLine + 2, ch: 0});
}
} else {
await editor.replaceRange(` ^${blockId}`, {
line: currentLine,
ch: block.position.end.col || editor.getLine(currentLine).length
});
}
}
return `^[[[${file.basename}#^${blockId}]]]`;
}
const view = tp.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView);
if (!view) return;
const selection = editor.getSelection();
if (selection && checkSelectionSpansBlocks(editor.getCursor('from'), editor.getCursor('to'), tp.app.metadataCache.getFileCache(view.file), editor)) {
new Notice("Selections spanning multiple blocks aren't supported");
tR = selection;
return;
}
const block = getBlock(editor, tp.app.metadataCache.getFileCache(view.file));
if (!block) {
tR = selection;
return;
}
const result = await handleBlock(view.file, editor, block);
if (result) {
await navigator.clipboard.writeText(result);
new Notice("Copied to your clipboard");
}
tR = selection;
%>