Skip to content

Instantly share code, notes, and snippets.

@aarongilly
Created July 16, 2026 00:39
Show Gist options
  • Select an option

  • Save aarongilly/7ac2cc785647bde24bf40378e681088e to your computer and use it in GitHub Desktop.

Select an option

Save aarongilly/7ac2cc785647bde24bf40378e681088e to your computer and use it in GitHub Desktop.
A tiny Obsidian Plugin for reifying edges in a graph
const { Plugin, Notice, PluginSettingTab, Setting, normalizePath } = require('obsidian');
const DEFAULT_SETTINGS = {
targetFolder: '', // '' = same folder as the note the selection came from
openAfterCreate: true
};
// Matches either a wikilink [[Page]] / [[Page|Alias]], or a markdown link [Text](target).
const LINK_RE = /\[\[([^\[\]]+)\]\]|\[([^\[\]]+)\]\(([^()]+)\)/g;
/**
* Finds every wikilink or markdown link in the text, in order, with:
* - display: human-readable text (alias for wikilinks, link text for markdown links)
* - raw: the original link syntax, e.g. "[[Page]]" or "[Text](target.md)"
* - index / length: position in the source text
*/
function extractLinks(text) {
const links = [];
let m;
LINK_RE.lastIndex = 0;
while ((m = LINK_RE.exec(text)) !== null) {
if (m[1] !== undefined) {
// Wikilink: [[Page]] or [[Page|Alias]]
const raw = m[1].trim();
const display = raw.includes('|') ? raw.split('|').pop().trim() : raw;
links.push({ index: m.index, length: m[0].length, display, raw: `[[${raw}]]` });
} else {
// Markdown link: [Text](target)
const display = m[2].trim();
const target = m[3].trim();
links.push({ index: m.index, length: m[0].length, display, raw: `[${display}](${target})` });
}
}
return links;
}
/**
* Parses selected text of the form:
* [[A]] some text [[B]] -> from A, to B (2-link form)
* [[A]] [[Verb]] [[B]] -> from A, to B, class Verb (3-link, adjacent form)
* Either link in either form may be a wikilink or a markdown link, in any combination.
* Returns null if the pattern isn't recognized.
*/
function parseRelation(rawText) {
const text = rawText.trim();
const links = extractLinks(text);
if (links.length === 2) {
const [l0, l1] = links;
const connector = text.slice(l0.index + l0.length, l1.index).trim();
if (!connector) return null; // need some connecting text for the 2-link form
return {
fromDisplay: l0.display,
toDisplay: l1.display,
fromRaw: l0.raw,
toRaw: l1.raw,
classDisplay: null,
classRaw: null,
connector
};
}
if (links.length === 3) {
const [l0, l1, l2] = links;
const gap1 = text.slice(l0.index + l0.length, l1.index).trim();
const gap2 = text.slice(l1.index + l1.length, l2.index).trim();
// Only treat as the "class" form when the links are adjacent (nothing but
// whitespace between them) — otherwise it's an ambiguous pattern.
if (gap1 === '' && gap2 === '') {
return {
fromDisplay: l0.display,
toDisplay: l2.display,
fromRaw: l0.raw,
toRaw: l2.raw,
classDisplay: l1.display,
classRaw: l1.raw,
connector: l1.display
};
}
return null;
}
return null;
}
function sanitizeFilename(name) {
return name
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function buildFrontmatter(relation) {
const lines = ['---'];
lines.push(`from: "${relation.fromRaw}"`);
lines.push(`to: "${relation.toRaw}"`);
if (relation.classRaw) {
lines.push(`class: "${relation.classRaw}"`);
}
lines.push('---', '');
return lines.join('\n');
}
module.exports = class RelationNoteMaker extends Plugin {
async onload() {
await this.loadSettings();
this.addSettingTab(new RelationNoteMakerSettingTab(this.app, this));
// Right-click context menu entry, shown only when there's a selection.
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor, view) => {
const selection = editor.getSelection();
if (!selection || !selection.trim()) return;
menu.addItem((item) => {
item
.setTitle('Convert selection to relation note')
.setIcon('git-fork')
.onClick(() => this.convertSelectionToNote(editor, view));
});
})
);
// Same action, available from the command palette / hotkeys.
this.addCommand({
id: 'convert-selection-to-relation-note',
name: 'Convert selection to relation note',
editorCallback: (editor, view) => this.convertSelectionToNote(editor, view)
});
}
async convertSelectionToNote(editor, view) {
const selection = editor.getSelection();
if (!selection || !selection.trim()) {
new Notice('Nothing selected.');
return;
}
const relation = parseRelation(selection);
if (!relation) {
new Notice(
'Selection must look like "[[A]] some text [[B]]" or "[[A]] [[Verb]] [[B]]" (wikilinks or markdown links).'
);
return;
}
const title = relation.classDisplay
? `${relation.fromDisplay} ${relation.classDisplay} ${relation.toDisplay}`
: `${relation.fromDisplay} ${relation.connector} ${relation.toDisplay}`;
const filename = sanitizeFilename(title);
if (!filename) {
new Notice('Could not derive a valid note title from the selection.');
return;
}
const folder = this.settings.targetFolder
? this.settings.targetFolder
: (view.file && view.file.parent ? view.file.parent.path : '');
const filePath = await this.getAvailablePath(folder, filename);
const content = buildFrontmatter(relation);
let newFile;
try {
newFile = await this.app.vault.create(filePath, content);
} catch (err) {
new Notice(`Failed to create note: ${err.message}`);
return;
}
// Replace the highlighted text in the original note with a link to the new note.
editor.replaceSelection(`[[${newFile.basename}]]`);
new Notice(`Created "${newFile.basename}"`);
if (this.settings.openAfterCreate) {
await this.app.workspace.getLeaf('tab').openFile(newFile);
}
}
async getAvailablePath(folder, filename) {
const base = folder ? `${folder}/${filename}` : filename;
let path = normalizePath(`${base}.md`);
let counter = 1;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${base} ${counter}.md`);
counter++;
}
return path;
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
};
class RelationNoteMakerSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Relation Note Maker' });
new Setting(containerEl)
.setName('Target folder')
.setDesc(
'Where new relation notes are created. Leave blank to use the same folder as the note you\'re editing.'
)
.addText((text) =>
text
.setPlaceholder('e.g. Relations')
.setValue(this.plugin.settings.targetFolder)
.onChange(async (value) => {
this.plugin.settings.targetFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Open new note after creation')
.setDesc('Automatically open the newly created relation note in a new tab.')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.openAfterCreate).onChange(async (value) => {
this.plugin.settings.openAfterCreate = value;
await this.plugin.saveSettings();
})
);
}
}
{
"id": "relation-note-maker",
"name": "Relation Note Maker",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Convert highlighted \"[[A]] verb [[B]]\" or \"[[A]] [[Verb]] [[B]]\" text into a new note with from/to/class frontmatter.",
"author": "Aaron",
"authorUrl": "",
"isDesktopOnly": false
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment