Copy block link, embed, footnote or URL with Templater

Copy URL to block

Outputs: obsidian://open?vault=vault&file=filename%23%5Eblock-id


Templater script

Copy_URL_to_Block.md (10.6 KB)

<%* 
/**
 * Template Name: Copy URL to Block
 * Description: Extracts or generates a block ID from cursor position or selection and copies a URL to it.
 * Version: 2.2
 * Author: Created via Claude
 * Source: https://forum.obsidian.md/t/copy-block-link-embed-footnote-or-url-with-templater/92600/4
 * Last Updated: 2024-12-14
 */

if (app.workspace.activeEditor.getMode() === "preview") {
    new Notice("Reading view isn't supported");
    tR = selection;
    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
            });
        }
    }
    const vaultName = app.vault.getName();
    const filePath = file.path.replace('.md', '');
    const encodedVault = encodeURIComponent(vaultName);
    const encodedPath = encodeURIComponent(filePath);
    return `obsidian://open?vault=${encodedVault}&file=${encodedPath}%23%5E${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("URL copied to your clipboard");
}
tR = selection;
%>

Alternative solutions

Block Link Plus

In ver. 1.0.6, Block Link Plus has added a command to copy URL to block. The advantage the script still has over the plugin is that it generates/copies standalone block IDs for headings, which survive heading renames.

Advanced URI

Advanced URI adds a “Copy URL for current block” command. It uses a custom URL scheme instead of a native one e.g.:

obsidian://adv-uri?vault=Vault&filepath=Note&block=block-id

QuickAdd macro

  1. Place a file with the script below anywhere in your vault:

Copy_URL_to_block.js.zip (1.2 KB)

module.exports = async function getObsidianUri(params) {
    const { app, quickAddApi } = params;
    const clipboard = await quickAddApi.utility.getClipboard();

    const blockId = clipboard.match(/\^(.*?)\]/)[1];

    const vaultName = app.vault.getName();
    const activeFile = app.workspace.getActiveFile();
    if (!activeFile) return;
    
    let filePath = activeFile.path;
    if (filePath.endsWith('.md')) {
        filePath = filePath.slice(0, -3);
    }

    const encodedVault = encodeURIComponent(vaultName);
    const encodedPath = encodeURIComponent(filePath);
    const uri = `obsidian://open?vault=${encodedVault}&file=${encodedPath}%23%5E${blockId}`;
    
    await quickAddApi.utility.setClipboard(uri);
    new Notice("URL copied to your clipboard");
    return uri;
}
  1. Create a macro in QuickAdd.
  2. As step one, add Obsidian command: “Copy Block Link: Copy link to current block or heading”.
  3. As step two, add user script: Copy_URL_to_block.js.

Copy Block Link cannot generate block IDs for headings, so this only works for non-heading blocks.


Room for improvement

Could create a modified version of the script that would, when run:

  1. Insert 🔗 before the block ID–to mark that the block is linked to outside the vault, and that either the block ID shouldn’t be renamed, or the URL to it should be updated on block ID rename.
  2. Add a task-type property value to frontmatter titled linked to and set its key to true–to mark that either the note shouldn’t be renamed, or the URLs to it should be updated on note rename.
  3. Insert the current timestamp after the emoji in block e.g. Regular block text. 🔗 %%YYYY-MM-DDTHH:mmZ%% ^block-id–to capture when the URL was generated.

Do note that a URL will break if the vault, note or block ID get renamed. See feature request for permanent URLs.