Created
July 16, 2026 00:39
-
-
Save aarongilly/7ac2cc785647bde24bf40378e681088e to your computer and use it in GitHub Desktop.
A tiny Obsidian Plugin for reifying edges in a graph
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| }) | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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