Skip to content

Instantly share code, notes, and snippets.

@laundmo
Last active May 16, 2026 11:47
Show Gist options
  • Select an option

  • Save laundmo/de1f386fac9f9e797fd77022d63967c9 to your computer and use it in GitHub Desktop.

Select an option

Save laundmo/de1f386fac9f9e797fd77022d63967c9 to your computer and use it in GitHub Desktop.
Trilium widget to turn file paths into links, with windows path normalization. Add as "JS Frontend" note and make sure it has the #widget property.
/* License: MIT https://opensource.org/licenses/MIT
*
* Usage: Any file paths, except those in code blocks, will become links.
* File paths with spaces can work by first creating a link without a space,
* and then adding the parts after the space.
*/
function normalizePathToUrl(path) {
if (/^[A-Z]:/i.test(path)) {
return 'file:///' + path.replace(/\\/g, '/');
}
return 'file://' + path;
}
function isInCodeBlock(item) {
let element = item.parent;
while (element) {
if (element.is('element', 'codeBlock')) {
return true;
}
element = element.parent;
}
if (item.hasAttribute && item.hasAttribute('code')) {
return true;
}
return false;
}
class PathLinkerWidget extends api.NoteContextAwareWidget {
constructor(...args) {
super(...args);
this.register
}
get position() {
// higher value means position towards the bottom/right
return 100;
}
get parentWidget() { return 'center-pane'; }
async refreshWithNote(note) {
api.getActiveContextTextEditor().then(editor => {
if (!editor) {
return
}
const path_re = /(?<=^|\s)([A-Z]:[\\/]{1,2}[^\s]+|\/[^\s]+)/g;
const path_start_re = /^([A-Z]:[\\/]{1,2}|\/)/;
editor.model.change(writer => {
const root = editor.model.document.getRoot();
const linksToUpdate = [];
const range = editor.model.createRangeIn(root);
for (const { item, previousPosition } of range.getWalker({ ignoreElementEnd: true })) {
if (item.is('$textProxy')) {
if (isInCodeBlock(item)) {
continue;
}
const text = item.data;
if (item.hasAttribute('linkHref')) {
if (path_start_re.test(text)) {
const startPos = previousPosition;
const endPos = startPos.getShiftedBy(text.length);
linksToUpdate.push({
range: writer.createRange(startPos, endPos),
url: normalizePathToUrl(text)
});
}
} else {
let match;
path_re.lastIndex = 0;
while ((match = path_re.exec(text)) !== null) {
const pathText = match[1];
const pathStartIndex = match.index + (match[0].length - pathText.length);
const startPos = previousPosition.getShiftedBy(pathStartIndex);
const endPos = startPos.getShiftedBy(pathText.length);
linksToUpdate.push({
range: writer.createRange(startPos, endPos),
url: normalizePathToUrl(pathText)
});
}
}
}
}
for (const link of linksToUpdate) {
writer.setAttribute('linkHref', link.url, link.range);
}
});
});
}
async entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteContentReloaded(this.noteId)) {
this.refresh();
}
}
doRender() {
this.$widget = $(``);
return this.$widget;
}
}
let widget = new PathLinkerWidget();
console.log("Loaded PathLinkerWidget");
module.exports = widget;
@meichthys

meichthys commented Apr 6, 2023

Copy link
Copy Markdown

@laundmo I feel like i may have asked this somewhere in the past, but i can't seem to get this script to work. Do you know if it's still relevant in recent Trilium release versions?

I did set the note to JS Backend and added the #widget attribute

I receive the following error when trying to run the script:

Class extends value undefined is not a constructor or null

@laundmo

laundmo commented Apr 6, 2023

Copy link
Copy Markdown
Author

I'm not sure, at work we haven't updated yet, so there was no need to try.

have you checked whether NoteContextAwareWidget was removed in a changelog?

@laundmo

laundmo commented Apr 6, 2023

Copy link
Copy Markdown
Author

nvm, just checked. it still exists. hmm, wierd.

@meichthys

meichthys commented Apr 6, 2023

Copy link
Copy Markdown

@laundmo Lol, it just needs to be setup as a "JS Frontend" note instead of JS Backend
Perhaps you could update the description for future reference?

@laundmo

laundmo commented Apr 6, 2023

Copy link
Copy Markdown
Author

oof really? allright I'll fix it

@SiriusXT

SiriusXT commented May 6, 2023

Copy link
Copy Markdown

Could not open path: %USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup

@connectluole

connectluole commented Jan 20, 2024

Copy link
Copy Markdown

Add new update for the usage of this script.
16 line: await document.PathLinkerApi.runOnBackend(async (path) => {
need update to below
16 line: await document.PathLinkerApi.runAsyncOnBackendWithManualTransactionHandling(async (path) => {

This update is due to trilium app (win 0.62.5) notice me like below once i double click on the file link line.
ERROR: You're passing an async function to api.runOnBackend() which will likely not work as you intended. Either make the function synchronous (by removing 'async' keyword), or use api.runAsyncOnBackendWithManualTransactionHandling()

@connectluole

connectluole commented Jun 24, 2024

Copy link
Copy Markdown

add the enhanced code below, which no need the special format for the file path

async function OpenLocalPath(event) {
// Check if the event was a Ctrl+double click.
if (event.ctrlKey) {
var path = event.target.innerText.trim();
console.log(>> ctrl + double_click get line content:\n${path})
//------ Check if the text content contain a file path. ------
if (/[A-Z]:\./.test(path)) {
var path = path.match(/[A-Z]:\.
/)[0]
// --- replace the special string
path = path.replace("USER",require('os').userInfo().username)

        console.log(`>> Open local path: ${path}`)
        // Use the PathLinkerApi to open the file at the path.
        await document.PathLinkerApi.runAsyncOnBackendWithManualTransactionHandling(async (path) => { 
            const shell = require('electron').shell;
            await shell.openPath(path);
            return;
        }, [path]);
    }
}

}

const TEMPLATE = <div style="padding: 10px; border-top: 1px solid var(--main-border-color); contain: none;"> <script> ${OpenLocalPath.toString()} document.addEventListener('dblclick', OpenLocalPath); </script> </div>;

class PathLinkerWidget extends api.NoteContextAwareWidget {
constructor(...args) {
super(...args);
this.balloonEditorCreate = null;
}

get position() { 
    // higher value means position towards the bottom/right
    return 100; 
} 

get parentWidget() { return 'center-pane'; }

doRender() {
    this.$widget = $(TEMPLATE);
    this.$widget.hide();
    
    // store API in document to access it from callback
    document.PathLinkerApi = api;
    return this.$widget;
}

}

let widget = new PathLinkerWidget();
console.log(">> Loaded PathLinkerWidget");
module.exports = widget;

@brianvanderburg2

Copy link
Copy Markdown

I'm unable to get either of these working. I get an error about "require" not being defined, using Trilium Next 0.90.7

frontend_script_api.js:203 Uncaught (in promise) Error: server error: require is not defined
    at x.__runOnBackendInner (frontend_script_api.js:203:19)
    at async x.runAsyncOnBackendWithManualTransactionHandling (frontend_script_api.js:243:36)
    at async HTMLDocument.onClickOpenPath (<anonymous>:9:13)

@CobriMediaJulien

Copy link
Copy Markdown

same here. seems like an update of electron broke it? tried to debug but my skills aren´t good enough

@laundmo

laundmo commented Nov 12, 2025

Copy link
Copy Markdown
Author

I just updated this with a far superior version, fixed for trilium-next, which inserts as file:// links properly. It should "just work" without anything like italics being required. code blocks, inline or not, are excluded.

@ecodiv

ecodiv commented Nov 14, 2025

Copy link
Copy Markdown

The file:// property does not work for me (Ubuntu 24.04). It does work in any browser (Firefox, Edge, Chrome) on my computer, which suggests it is a limitation of Trilium?

@laundmo

laundmo commented Nov 14, 2025

Copy link
Copy Markdown
Author

The file:// property does not work for me (Ubuntu 24.04). It does work in any browser (Firefox, Edge, Chrome) on my computer, which suggests it is a limitation of Trilium?

More of a bug, one i've actually investigated and found a solution for. Now its up to trilium maintainers to either merge my PR: TriliumNext/Trilium#7737 or fix it some other way.

@AlexShyXie

Copy link
Copy Markdown

Hi,
Based on laundmo’s PathLinkerWidget, I put together a small companion widget that opens custom-protocol URLs (e.g. bookxnotepro://..., obsidian://..., zoom://...) on double-click, using the system’s default handler via shell.openExternal.

On double-click, the code extracts the actual custom protocol URL from the double-clicked text and passes it to the backend shell.openExternal to open.

Key points: Double-click any text that contains a custom-protocol URL to open it.
It works well for me. Optionally require Ctrl+double-click by uncommenting the event.ctrlKey check.
Uses the same #widget / “JS Frontend” setup as the original gist.

/*
 * Custom Protocol Opener Widget
 *
 * Opens custom-protocol URLs (e.g. bookxnotepro://..., obsidian://..., zoom://...)
 * on double-click via the system’s default handler.
 *
 * Usage:
 *   - Add as a “JS Frontend” note and give it the #widget attribute.
 *   - Double-click any text that contains a custom-protocol URL to open it.
 *   - Optionally require Ctrl+double-click by uncommenting the event.ctrlKey check.
 *
 * Based on laundmo’s PathLinkerWidget:
 *   https://gist.github.com/laundmo/de1f386fac9f9e797fd77022d63967c9
 */

class CustomProtocolOpenerWidget extends api.NoteContextAwareWidget {
  constructor(...args) {
    super(...args);
  }

  get position() {
    return 100;
  }

  get parentWidget() {
    return 'center-pane';
  }

  doRender() {
    this.$widget = $('<div style="display: none;"></div>');

    // Global double-click handler
    $(document).on('dblclick.customProtocolOpener', async (event) => {
      // Uncomment the next line to require Ctrl+double-click:
      // if (!event.ctrlKey) return;

      // Get text near the double-clicked position
      let text = '';
      if (event.target && event.target.innerText) {
        text = event.target.innerText;
      } else if (event.target && event.target.textContent) {
        text = event.target.textContent;
      }

      if (!text) return;

      // Match custom-protocol URLs: scheme://...
      const urlMatch = text.match(/([a-z][a-z0-9+.-]*:\/\/[^\s]+)/i);

      if (urlMatch && urlMatch[1]) {
        const url = urlMatch[1];
        console.log(`CustomProtocolOpener: Detected double-click, opening URL: ${url}`);

        try {
          // Open URL via backend using shell.openExternal
          await api.runAsyncOnBackendWithManualTransactionHandling(async (url) => {
            const { shell } = require('electron');
            await shell.openExternal(url);
          }, [url]);
        } catch (e) {
          console.error('CustomProtocolOpener: Failed to open URL', e);
        }
      }
    });

    return this.$widget;
  }
}

let widget = new CustomProtocolOpenerWidget();
console.log('Loaded CustomProtocolOpenerWidget');
module.exports = widget;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment