├── .npmrc ├── .eslintignore ├── use-case1.gif ├── use-case2.gif ├── use-case3.gif ├── use-case4.gif ├── use-case5.gif ├── use-case6.gif ├── .vscode ├── extensions.json └── settings.json ├── jest.config.js ├── .editorconfig ├── .prettierrc ├── versions.json ├── manifest.json ├── .gitignore ├── tests ├── TestUtils.ts └── TestMarkdownUtils.ts ├── tsconfig.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── version-bump.mjs ├── .eslintrc ├── src ├── TemplaterIntegration.ts ├── VaultUtils.ts ├── Utils.ts ├── InjectAlias.ts ├── ListenerRegistry.ts ├── settings.ts ├── EditorCursorListener.ts ├── PositionUtils.ts ├── MarkdownUtils.ts └── main.ts ├── package.json ├── LICENSE ├── esbuild.config.mjs └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /use-case1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case1.gif -------------------------------------------------------------------------------- /use-case2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case2.gif -------------------------------------------------------------------------------- /use-case3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case3.gif -------------------------------------------------------------------------------- /use-case4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case4.gif -------------------------------------------------------------------------------- /use-case5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case5.gif -------------------------------------------------------------------------------- /use-case6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvojtechovsky/obsidian-link-with-alias/HEAD/use-case6.gif -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | verbose: true, 4 | preset: "ts-jest", 5 | rootDir: "tests", 6 | testEnvironment: "node", 7 | testMatch: ["**/*.ts"], 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "trailingComma": "all", 4 | "tabWidth": 4, 5 | "useTabs": true, 6 | "semi": true, 7 | "singleQuote": false, 8 | "printWidth": 150, 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.1.0", 3 | "1.0.1": "1.1.0", 4 | "1.0.2": "1.1.0", 5 | "1.0.3": "1.1.0", 6 | "1.0.4": "1.1.0", 7 | "1.0.5": "1.1.0", 8 | "1.0.6": "1.1.0", 9 | "1.0.7": "1.1.0", 10 | "1.0.8": "1.1.0", 11 | "1.0.9": "1.1.0", 12 | "1.0.10": "1.1.0", 13 | "1.0.11": "1.1.0" 14 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.enableScriptExplorer": true, 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit", 6 | "source.organizeImports": "explicit" 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "eslint.alwaysShowStatus": true, 12 | "prettier.configPath": ".prettierrc" 13 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "link-with-alias", 3 | "name": "Link with alias", 4 | "version": "1.0.11", 5 | "minAppVersion": "1.1.0", 6 | "description": "Creates links and aliases in front matter of target document", 7 | "author": "Pavel Vojtěchovský", 8 | "authorUrl": "https://github.com/pvojtechovsky", 9 | "fundingUrl": "https://www.buymeacoffee.com/pavel.knowledge", 10 | "isDesktopOnly": false 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # Eclipse 9 | .project 10 | 11 | # npm 12 | node_modules 13 | 14 | # Don't include the compiled main.js file in the repo. 15 | # They should be uploaded to GitHub releases instead. 16 | main.js 17 | 18 | # Exclude sourcemaps 19 | *.map 20 | 21 | # obsidian 22 | data.json 23 | 24 | # Exclude macOS Finder (System Explorer) View States 25 | .DS_Store 26 | 27 | /.hotreload -------------------------------------------------------------------------------- /tests/TestUtils.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from "../src/Utils"; 2 | 3 | describe("", () => { 4 | it("uppercase first letter from 3", () => { 5 | expect(capitalize("abc")).toEqual("Abc"); 6 | }); 7 | it("Uppercase one letter", () => { 8 | expect(capitalize("a")).toEqual("A"); 9 | }); 10 | it("Uppercase empty string", () => { 11 | expect(capitalize("")).toEqual(""); 12 | }); 13 | it("Uppercase whitespace", () => { 14 | expect(capitalize(" a")).toEqual(" a"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": ["src/**.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | #Not specified file extensions are not affected by line ending conversion 2 | * -text 3 | 4 | #Files with following file extensions have their line endings converted to LF 5 | *.aj text eol=lf 6 | *.bat text eol=crlf 7 | *.css text eol=lf 8 | *.ftl text eol=lf 9 | *.gitignore text eol=lf 10 | *.gradle text eol=lf 11 | *.htm text eol=lf 12 | *.html text eol=lf 13 | *.java text eol=lf 14 | *.js text eol=lf 15 | *.json text eol=lf 16 | *.jsp text eol=lf 17 | *.project text eol=lf 18 | *.properties text eol=lf 19 | *.svg text eol=lf 20 | *.txt text eol=lf 21 | *.xml text eol=lf 22 | *.xsl text eol=lf 23 | *.prefs text eol=lf 24 | *.springBeans text eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] bug title" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional Information** 27 | Add any other information about the problem here. 28 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /src/TemplaterIntegration.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin } from "obsidian"; 2 | import { delay, getPlugin } from "./Utils"; 3 | 4 | export function getTemplaterPlugin(application: App): TemplaterWrapper | undefined { 5 | const plugin = getPlugin(application, "templater-obsidian"); 6 | return plugin ? new TemplaterWrapper(plugin as TemplaterPlugin) : undefined; 7 | } 8 | 9 | interface TemplaterPlugin extends Plugin { 10 | templater: Templater; 11 | } 12 | 13 | interface Templater { 14 | files_with_pending_templates: Set; 15 | } 16 | 17 | export class TemplaterWrapper { 18 | constructor(private templaterPlugin: TemplaterPlugin) {} 19 | 20 | async waitUntilDone(): Promise { 21 | //templater uses delay 300ms 22 | await delay(400); 23 | while (this.templaterPlugin.templater.files_with_pending_templates.size > 0) { 24 | await delay(50); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST] feature request title" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | Note: It is critical to understand problem you are facing in order to let me design the best solution. So do not skip this section. 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /src/VaultUtils.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | 3 | /** 4 | * @param app 5 | * @param target link path 6 | * @param sourcePath the path of source document 7 | * @returns target file of link target if existing or creates a new file if missing 8 | */ 9 | export async function getOrCreateFileOfLink(app: App, target: string, sourcePath: string | undefined): Promise { 10 | sourcePath = sourcePath || "/"; 11 | const existingFile = app.metadataCache.getFirstLinkpathDest(target, sourcePath); 12 | if (existingFile) { 13 | return existingFile; 14 | } 15 | //create new file 16 | /** 17 | * Do not use undocumented function 18 | * const folder = app.fileManager.getNewFileParent(sourcePath); 19 | * return app.fileManager.createNewMarkdownFile(folder, target); 20 | */ 21 | const filePath = app.fileManager.getNewFileParent(sourcePath).path + "/" + target + ".md"; 22 | return app.vault.create(filePath, ""); 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-link-with-alias", 3 | "version": "1.0.11", 4 | "description": "Creates links and aliases in front matter of target document", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "test": "jest", 10 | "version": "node version-bump.mjs && git add package.json manifest.json versions.json && git commit -m \"Release %npm_package_version%\" && git tag -a %npm_package_version% -m %npm_package_version%" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/jest": "^29.5.1", 17 | "@types/node": "^16.11.6", 18 | "@typescript-eslint/eslint-plugin": "5.29.0", 19 | "@typescript-eslint/parser": "5.29.0", 20 | "builtin-modules": "3.3.0", 21 | "esbuild": "0.17.3", 22 | "jest": "^29.5.0", 23 | "obsidian": "^1.1.0", 24 | "ts-jest": "^29.1.0", 25 | "tslib": "2.4.0", 26 | "typescript": "4.7.4" 27 | } 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pavel Vojtechovsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, TFile } from "obsidian"; 2 | 3 | /** 4 | * converts single value or array of values into array of values 5 | * @param v 6 | * @returns 7 | */ 8 | export function toArray(v: T | T[] | null | undefined): T[] { 9 | if (v == null) { 10 | return []; 11 | } 12 | if (Array.isArray(v)) { 13 | return v; 14 | } 15 | return [v]; 16 | } 17 | 18 | export function capitalize(str: string): string { 19 | return str.charAt(0).toUpperCase() + str.substring(1); 20 | } 21 | 22 | export async function delay(time: number): Promise { 23 | return new Promise((resolve) => setTimeout(resolve, time)); 24 | } 25 | 26 | export function isNewFile(file: TFile): boolean { 27 | return now() - file.stat.ctime < 50; 28 | } 29 | 30 | export function now(): number { 31 | return new Date().getTime(); 32 | } 33 | 34 | interface PluginsAware extends App { 35 | plugins: PluginsHolder; 36 | } 37 | 38 | interface PluginsHolder { 39 | getPlugin(name: string): Plugin; 40 | } 41 | 42 | function isPluginsAware(app: App): app is PluginsAware { 43 | return "plugins" in app; 44 | } 45 | 46 | export function getPlugin(application: App, pluginName: string): Plugin | undefined { 47 | if (isPluginsAware(application)) { 48 | return application.plugins.getPlugin(pluginName); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/InjectAlias.ts: -------------------------------------------------------------------------------- 1 | import { FileManager, TFile } from "obsidian"; 2 | import { toArray } from "./Utils"; 3 | 4 | const aliasPropertyNames = ["aliases", "alias"]; 5 | 6 | /** 7 | * Adds `requiredAliases` if they don't exist yet in the `file` 8 | * @param fileManager 9 | * @param file 10 | * @param requiredAliases 11 | */ 12 | export async function addMissingAliasesIntoFile(fileManager: FileManager, file: TFile, requiredAliases: string[]): Promise { 13 | await fileManager.processFrontMatter(file, (frontmatter) => { 14 | if (typeof frontmatter == "object") { 15 | const aliasPropName = aliasPropertyNames.find((name) => frontmatter[name] != null) || aliasPropertyNames[0]; 16 | const existingAliases = toArray(frontmatter[aliasPropName]); 17 | const toBeAdded: string[] = []; 18 | requiredAliases.forEach((requiredAlias) => { 19 | const lowercaseRequiredAlias = requiredAlias.toLocaleLowerCase(); 20 | if (!existingAliases.some((alias) => alias.toLocaleLowerCase() == lowercaseRequiredAlias)) { 21 | toBeAdded.push(requiredAlias); 22 | } 23 | }); 24 | if (toBeAdded.length === 0) { 25 | //alias already exists. Do nothing 26 | return; 27 | } 28 | const newAliases = [...existingAliases, ...toBeAdded]; 29 | //longest firs. It is needed for correct detection of back references 30 | newAliases.sort((a, b) => b.length - a.length); 31 | frontmatter[aliasPropName] = newAliases; 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/ListenerRegistry.ts: -------------------------------------------------------------------------------- 1 | export type Unregister = () => void; 2 | 3 | /** 4 | * return true to keep callback registered and be called again. false to unregister 5 | */ 6 | export type Callback = (args: C) => boolean; 7 | 8 | interface RegistryItem { 9 | callback: Callback; 10 | destroyed?: true; 11 | } 12 | 13 | /** 14 | * Registry for repeatadly called callbacks. The callback can deregister by a return value false 15 | */ 16 | export class ListenerRegistry { 17 | private callbacks: RegistryItem[] = []; 18 | 19 | constructor(private name: string) {} 20 | 21 | /** 22 | * Calls all registered callbacks 23 | * @param args 24 | */ 25 | process(args: C) { 26 | if (this.callbacks.length === 0) { 27 | return; 28 | } 29 | const callbacks = [...this.callbacks]; 30 | this.callbacks.length = 0; 31 | callbacks.forEach((item) => { 32 | if (!item.destroyed && item.callback(args)) { 33 | if (!item.destroyed) { 34 | //add it if callback did not unregistered this item during its call 35 | this.callbacks.push(item); 36 | } 37 | } 38 | }); 39 | } 40 | 41 | /** 42 | * register callback which will be called with each `process` method calls as long as callback returns true or Unregister method is not called. 43 | * @param callback 44 | * @returns 45 | */ 46 | register(callback: Callback): Unregister { 47 | const item: RegistryItem = { callback }; 48 | this.callbacks.push(item); 49 | return () => { 50 | item.destroyed = true; 51 | this.callbacks.remove(item); 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting, ToggleComponent } from "obsidian"; 2 | import { default as FrontmatterLinksPlugin, default as LinkWithAliasPlugin } from "./main"; 3 | 4 | export interface LinksSettings { 5 | copyDisplayText: boolean; 6 | capitalizeFileName: boolean; 7 | } 8 | 9 | export const DEFAULT_SETTINGS: LinksSettings = { 10 | copyDisplayText: true, 11 | capitalizeFileName: true, 12 | }; 13 | 14 | export class LinksSettingTab extends PluginSettingTab { 15 | private plugin: LinkWithAliasPlugin; 16 | 17 | constructor(app: App, plugin: FrontmatterLinksPlugin) { 18 | super(app, plugin); 19 | this.plugin = plugin; 20 | } 21 | 22 | display() { 23 | new Setting(this.containerEl) 24 | .setName("Copy selected text as link file") 25 | .setDesc("When selected then creates link `[[text|text]]`, otherwise `[[|text]]`.") 26 | .addToggle((component: ToggleComponent) => { 27 | component.setValue(this.plugin.settings.copyDisplayText); 28 | component.onChange((value: boolean) => { 29 | this.plugin.settings.copyDisplayText = value; 30 | this.plugin.saveSettings(); 31 | }); 32 | }); 33 | new Setting(this.containerEl) 34 | .setName("Capitalize link file name") 35 | .setDesc("When selected then `text` creates link `[[Text|text]]`, otherwise `[[text|text]]`.") 36 | .setDisabled(!this.plugin.settings.copyDisplayText) 37 | .addToggle((component: ToggleComponent) => { 38 | component.setValue(this.plugin.settings.capitalizeFileName); 39 | component.onChange((value: boolean) => { 40 | this.plugin.settings.capitalizeFileName = value; 41 | this.plugin.saveSettings(); 42 | }); 43 | }); 44 | } 45 | 46 | hide() { 47 | this.containerEl.empty(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/EditorCursorListener.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorPosition, MarkdownFileInfo, MarkdownView, Plugin, TFile, WorkspaceLeaf } from "obsidian"; 2 | import { ListenerRegistry, Unregister } from "./ListenerRegistry"; 3 | import { equalsPosition } from "./PositionUtils"; 4 | 5 | /** 6 | * @param cursorPosition if undefined then editor file changed 7 | * @return true to be called again with next cursor change, false to cancel this registration 8 | */ 9 | export type CursorChangeCallback = (cursorPosition: EditorPosition | undefined) => boolean; 10 | 11 | interface EventData { 12 | leaf?: WorkspaceLeaf | null; 13 | file?: TFile | null; 14 | } 15 | 16 | /** 17 | * Controller which can listen on move of editor cursor or deactivation of editor 18 | */ 19 | export class EditorCursorListener { 20 | private listenerRegistry = new ListenerRegistry("EditorCursorListener"); 21 | 22 | /** 23 | * 24 | * @param plugin 25 | * @param cursorCheckTimeout number of ms when cursor position has to be checked 26 | */ 27 | constructor(private plugin: Plugin, cursorCheckTimeout = 1000) { 28 | this.plugin.registerEvent( 29 | //Listen on closing or deactivation current editor 30 | this.plugin.app.workspace.on("active-leaf-change", (leaf) => { 31 | this.listenerRegistry.process({ leaf }); 32 | }), 33 | ); 34 | this.plugin.registerEvent( 35 | //Listen on modification of file 36 | this.plugin.app.workspace.on("editor-change", (editor: Editor, info: MarkdownView | MarkdownFileInfo) => { 37 | this.listenerRegistry.process({ file: info.file }); 38 | }), 39 | ); 40 | this.plugin.registerInterval(window.setInterval(() => this.onTimeInterval(), cursorCheckTimeout)); 41 | } 42 | 43 | private onTimeInterval(): void { 44 | this.listenerRegistry.process({}); 45 | } 46 | 47 | /** 48 | * Calls `onCursorChange` when cursor moves or when editor becomes inactive 49 | * @param editor 50 | * @param onCursorChange 51 | */ 52 | fireOnCursorChange(editor: Editor, onCursorChange: CursorChangeCallback): Unregister { 53 | const originFile = getFileFromEditor(editor); 54 | let lastCursorPosition = editor.getCursor(); 55 | return this.listenerRegistry.register(({ leaf, file }) => { 56 | if (file && originFile.path != file.path) { 57 | //we do not care about changes on different files 58 | return true; 59 | } 60 | if (leaf != null) { 61 | //another editor was opened 62 | onCursorChange(undefined); 63 | return false; 64 | } 65 | const cursorPosition = editor.getCursor(); 66 | if (!file && equalsPosition(cursorPosition, lastCursorPosition)) { 67 | return true; 68 | } 69 | lastCursorPosition = cursorPosition; 70 | return onCursorChange(cursorPosition); 71 | }); 72 | } 73 | } 74 | 75 | function getFileFromEditor(editor: Editor): TFile { 76 | return (editor as any).editorComponent.file; 77 | } 78 | -------------------------------------------------------------------------------- /src/PositionUtils.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorPosition, Loc, Pos } from "obsidian"; 2 | 3 | /** Converts Loc to EditorPosition */ 4 | export function locToEditorPositon(loc: Loc): EditorPosition { 5 | return { 6 | line: loc.line, 7 | ch: loc.col, 8 | }; 9 | } 10 | 11 | /** 12 | * 13 | * @param loc 14 | * @param offsetDif 15 | * @returns creates new EditorPostion by moving `loc` by `offsetDif` characters 16 | */ 17 | export function moveEditorPosition(loc: EditorPosition, offsetDif: number): EditorPosition { 18 | const loc2: EditorPosition = { 19 | line: loc.line, 20 | ch: loc.ch + offsetDif, 21 | }; 22 | if (loc2.ch < 0) { 23 | throw new Error("Negative col"); 24 | } 25 | return loc2; 26 | } 27 | 28 | /** 29 | * @param loc 30 | * @param offsetDif 31 | * @returns creates new Loc by moving `loc` by `offsetDif` characters 32 | */ 33 | export function moveLoc(loc: Loc, offsetDif: number): Loc { 34 | const loc2: Loc = { 35 | line: loc.line, 36 | col: loc.col + offsetDif, 37 | offset: loc.offset + offsetDif, 38 | }; 39 | if (loc2.col < 0) { 40 | throw new Error("Negative col"); 41 | } 42 | return loc2; 43 | } 44 | 45 | /** 46 | * 47 | * @param editor 48 | * @param offsetDif 49 | * @returns EditorPosition of the Editor curosor moved by `offsetDif` characters 50 | */ 51 | export function moveCursor(editor: Editor, offsetDif: number): EditorPosition { 52 | const newPos = moveEditorPosition(editor.getCursor(), offsetDif); 53 | editor.setCursor(newPos); 54 | return newPos; 55 | } 56 | 57 | /** 58 | * 59 | * @param cursor 60 | * @param pos 61 | * @param includingStart if true then returns true also when cursor is at the start. If false cursor must be after start 62 | * @param includingEnd if true then returns true also when cursor is at the end. If false cursor must be before end 63 | * @returns true if cursor is in pos 64 | */ 65 | export function isEditorPositionInPos(cursor: EditorPosition, pos: Pos, includingStart = false, includingEnd = false): boolean { 66 | if (includingStart) { 67 | if (comparePosition(cursor, pos.start) < 0) { 68 | return false; 69 | } 70 | } else { 71 | if (comparePosition(cursor, pos.start) <= 0) { 72 | return false; 73 | } 74 | } 75 | if (includingEnd) { 76 | if (comparePosition(cursor, pos.end) > 0) { 77 | return false; 78 | } 79 | } else { 80 | if (comparePosition(cursor, pos.end) >= 0) { 81 | return false; 82 | } 83 | } 84 | return true; 85 | } 86 | 87 | /** 88 | * @param a 89 | * @returns offset in line from view or editor position 90 | */ 91 | export function getColumn(a: EditorPosition | Loc): number { 92 | if ("ch" in a) { 93 | return a.ch; 94 | } 95 | return a.col; 96 | } 97 | 98 | /** 99 | * returns number greater then 0 if a > b 100 | * returns 0 if a == b 101 | * returns number lower then 0 if a < b 102 | */ 103 | export function comparePosition(a: EditorPosition | Loc, b: EditorPosition | Loc): number { 104 | const lineDif = a.line - b.line; 105 | if (lineDif !== 0) { 106 | return lineDif; 107 | } 108 | return getColumn(a) - getColumn(b); 109 | } 110 | 111 | /** 112 | * returns true if two positions are equal 113 | */ 114 | export function equalsPosition(a: EditorPosition | Loc, b: EditorPosition | Loc): boolean { 115 | if (a.line !== b.line) { 116 | return false; 117 | } 118 | return getColumn(a) == getColumn(b); 119 | } 120 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: link-with-alias 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "16.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build --if-present 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | - name: Upload zip file 44 | id: upload-zip 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 51 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 52 | asset_content_type: application/zip 53 | - name: Upload main.js 54 | id: upload-main 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | - name: Upload manifest.json 64 | id: upload-manifest 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./manifest.json 71 | asset_name: manifest.json 72 | asset_content_type: application/json 73 | # - name: Upload styles.css 74 | # id: upload-css 75 | # uses: actions/upload-release-asset@v1 76 | # env: 77 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | # with: 79 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | # asset_path: ./styles.css 81 | # asset_name: styles.css 82 | # asset_content_type: text/css 83 | -------------------------------------------------------------------------------- /src/MarkdownUtils.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorPosition, Pos, ReferenceCache } from "obsidian"; 2 | import { locToEditorPositon, moveLoc } from "./PositionUtils"; 3 | 4 | const linkPrefix = "[["; 5 | const linkSuffix = "]]"; 6 | const displaTextSeparator = "|"; 7 | 8 | /** 9 | * @param editor 10 | * @param pos 11 | * @returns ReferenceCache like structure which describes the link in `editor` on `pos` position or undefined if there is no link at `pos` position 12 | */ 13 | export function getReferenceCacheFromEditor(editor: Editor, pos?: EditorPosition): ReferenceCache | undefined { 14 | if (!pos) pos = editor.getCursor(); 15 | const line = editor.getLine(pos.line); 16 | 17 | let posOffset = pos.ch; 18 | if (line.substring(posOffset, posOffset + 2) == linkPrefix) { 19 | //cursor is at the beginning of link 20 | posOffset += 2; 21 | } 22 | //search for closing ]] 23 | if (line.charAt(posOffset) == "]") { 24 | posOffset--; 25 | } 26 | let lastLookup: string | undefined; 27 | let endIdx = firstIndexOf(line, posOffset, (lookup) => { 28 | lastLookup = lookup(2); 29 | return lastLookup == linkSuffix || lastLookup == linkPrefix; 30 | }); 31 | if (endIdx < 0 || lastLookup != linkSuffix) { 32 | return; 33 | } 34 | endIdx += linkSuffix.length; 35 | //search for openning [[ 36 | let lastLookup2: string | undefined; 37 | const startIdx = lastIndexOf(line, posOffset, (lookup) => { 38 | lastLookup2 = lookup(2); 39 | return lastLookup2 == linkSuffix || lastLookup2 == linkPrefix; 40 | }); 41 | if (startIdx < 0 || lastLookup2 != linkPrefix) { 42 | return; 43 | } 44 | const original = line.substring(startIdx, endIdx); 45 | const parts = original.substring(2, original.length - 2).split(displaTextSeparator); 46 | return { 47 | link: parts[0], 48 | position: { 49 | start: { 50 | col: startIdx, 51 | line: pos.line, 52 | offset: -1, 53 | }, 54 | end: { 55 | col: endIdx, 56 | line: pos.line, 57 | offset: -1, 58 | }, 59 | }, 60 | original, 61 | //keep displayText undefined in case the link contains no display text, just link name 62 | displayText: parts[1], 63 | }; 64 | } 65 | 66 | type LookupFn = (count: number) => string; 67 | 68 | export function firstIndexOf(str: string, from: number, predicate: (lookup: LookupFn) => boolean): number { 69 | const len = str.length; 70 | const lookupFn: LookupFn = (count) => { 71 | return str.substring(from, Math.min(from + count, len)); 72 | }; 73 | while (from < len) { 74 | if (predicate(lookupFn)) { 75 | return from; 76 | } 77 | from++; 78 | } 79 | return -1; 80 | } 81 | 82 | export function lastIndexOf(str: string, from: number, predicate: (lookup: LookupFn) => boolean): number { 83 | const len = str.length; 84 | const lookupFn: LookupFn = (count) => { 85 | return str.substring(from, Math.min(from + count, len)); 86 | }; 87 | while (from > 0) { 88 | from--; 89 | if (predicate(lookupFn)) { 90 | return from; 91 | } 92 | } 93 | return -1; 94 | } 95 | 96 | /** 97 | * 98 | * @param link 99 | * @returns Pos of link text where the Pos.start points to the link pipe character 100 | */ 101 | export function getLinkTextPosWithPipe(link: ReferenceCache): Pos { 102 | //empty string in display text should be handled here too 103 | if (link.displayText != null) { 104 | return { 105 | start: moveLoc(link.position.end, -link.displayText.length - 3), 106 | end: moveLoc(link.position.end, -2), 107 | }; 108 | } 109 | return { 110 | start: moveLoc(link.position.end, -2), 111 | end: moveLoc(link.position.end, -2), 112 | }; 113 | } 114 | 115 | /** 116 | * Sets the link text of `link` to be a `linkText` 117 | * @param link 118 | * @param editor 119 | * @param linkText 120 | */ 121 | export function setLinkText(link: ReferenceCache, editor: Editor, linkText: string | undefined): void { 122 | if (link.displayText !== linkText) { 123 | //it was changed rollback the change now 124 | const linkTextPos = getLinkTextPosWithPipe(link); 125 | editor.replaceRange(linkText != null ? `|${linkText}` : "", locToEditorPositon(linkTextPos.start), locToEditorPositon(linkTextPos.end)); 126 | link.displayText = linkText; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/TestMarkdownUtils.ts: -------------------------------------------------------------------------------- 1 | import { Editor, ReferenceCache } from "obsidian"; 2 | import { getReferenceCacheFromEditor, setLinkText } from "../src/MarkdownUtils"; 3 | 4 | describe("MarkdownUtils", () => { 5 | it("getReferenceCacheFromEditor no display text", () => { 6 | expect(getReferenceCacheFromEditor(createEditorWithCursor(0, 0, `[[target name]]`))).toEqual({ 7 | link: "target name", 8 | original: "[[target name]]", 9 | displayText: undefined, 10 | position: { 11 | start: { 12 | line: 0, 13 | col: 0, 14 | offset: -1, 15 | }, 16 | end: { 17 | line: 0, 18 | col: 15, 19 | offset: -1, 20 | }, 21 | }, 22 | }); 23 | }); 24 | it("add link alias", () => { 25 | const editor = createEditorWithCursor(0, 0, `[[target name]]`); 26 | const linkCache = getReferenceCacheFromEditor(editor); 27 | if (linkCache == null) throw new Error("Link cache is missing"); 28 | setLinkText(linkCache, editor, "alias"); 29 | expect(editor.getLine(0)).toEqual("[[target name|alias]]"); 30 | }); 31 | it("replace empty link alias ", () => { 32 | const editor = createEditorWithCursor(0, 0, `[[target name|]]`); 33 | const linkCache = getReferenceCacheFromEditor(editor); 34 | if (linkCache == null) throw new Error("Link cache is missing"); 35 | setLinkText(linkCache, editor, "alias"); 36 | expect(editor.getLine(0)).toEqual("[[target name|alias]]"); 37 | }); 38 | it("replace link alias ", () => { 39 | const editor = createEditorWithCursor(0, 0, `[[target name|aa]]`); 40 | const linkCache = getReferenceCacheFromEditor(editor); 41 | if (linkCache == null) throw new Error("Link cache is missing"); 42 | setLinkText(linkCache, editor, "alias"); 43 | expect(editor.getLine(0)).toEqual("[[target name|alias]]"); 44 | }); 45 | it("getReferenceCacheFromEditor link only", () => { 46 | const linkText = "[[target|text]]"; 47 | const cacheLink = getReferenceCacheFromEditor(createEditorWithCursor(7, 0, `${linkText}`)); 48 | if (!cacheLink) { 49 | throw new Error(); 50 | } 51 | expect(linkText.substring(cacheLink.position.start.col, cacheLink?.position.end.col)).toBe(linkText); 52 | expect(cacheLink).toEqual({ 53 | link: "target", 54 | original: "[[target|text]]", 55 | displayText: "text", 56 | position: { 57 | start: { 58 | line: 7, 59 | col: 0, 60 | offset: -1, 61 | }, 62 | end: { 63 | line: 7, 64 | col: 15, 65 | offset: -1, 66 | }, 67 | }, 68 | } as ReferenceCache); 69 | }); 70 | const linkText = "[[target|text]]"; 71 | const line = `something before ${linkText} and after`; 72 | const startOff = line.indexOf(linkText); 73 | const endOff = startOff + linkText.length; 74 | let o = 0; 75 | while (o <= line.length) { 76 | const off = o; 77 | if (off >= startOff && off < endOff) { 78 | it(`getReferenceCacheFromEditor off=${off} returns value`, () => { 79 | const cacheLink = getReferenceCacheFromEditor(createEditorWithCursor(7, off, line)); 80 | if (!cacheLink) { 81 | throw new Error(); 82 | } 83 | // expect(linkText.substring(cacheLink.position.start.col, cacheLink?.position.end.col)).toBe(linkText); 84 | expect(cacheLink).toEqual({ 85 | link: "target", 86 | original: "[[target|text]]", 87 | displayText: "text", 88 | position: { 89 | start: { 90 | line: 7, 91 | col: startOff, 92 | offset: -1, 93 | }, 94 | end: { 95 | line: 7, 96 | col: endOff, 97 | offset: -1, 98 | }, 99 | }, 100 | } as ReferenceCache); 101 | }); 102 | } else { 103 | it(`getReferenceCacheFromEditor off=${off} returns undefined`, () => { 104 | const cacheLink = getReferenceCacheFromEditor(createEditorWithCursor(7, off, line)); 105 | expect(cacheLink).toBeUndefined(); 106 | }); 107 | } 108 | o++; 109 | } 110 | }); 111 | 112 | function createEditorWithCursor(line: number, ch: number, lineText: string): Editor { 113 | const ed: Partial = { 114 | getCursor() { 115 | return { 116 | line, 117 | ch, 118 | }; 119 | }, 120 | getLine(line2?: number) { 121 | if (line2 !== line) { 122 | throw new Error("Unexpected getLine call"); 123 | } 124 | return lineText; 125 | }, 126 | replaceRange: (replacement, from, to) => { 127 | if (from.line != line || to?.line != line) { 128 | throw new Error("Unexpected line call"); 129 | } 130 | lineText = lineText.substring(0, from.ch) + replacement + lineText.substring(to.ch); 131 | }, 132 | }; 133 | return ed as Editor; 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Link with alias 2 | 3 | ![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22link-with-alias%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json&style=plastic) ![](https://img.shields.io/github/v/release/pvojtechovsky/obsidian-link-with-alias?label=Latest%20Release&style=plastic) 4 | 5 | This plugin implements these commands 6 | 7 | - Create link with alias - provides fast creation of link whose display text is added into aliases atribute in front matter of the target note. 8 | - Create link - provides fast creation of link 9 | - Toggle link display text - toggles display text (alias) of the just edited link. 10 | 11 | Both `Create link` commands assures that link display text is kept => isn't replaced by Obsidian link autocompletion. 12 | 13 | # Use cases 14 | 15 | ## Make link on existing text 16 | 17 | User selects some text and runs the command "Create link with alias". The command creates a new link with target and display name copied from selected text and opens the link autocompletion popup and ... 18 | 19 | A) ... user can just select a value from the autocompletion popup, press Enter and link and alias are created. 20 | 21 | ![Run command, press Enter, done](use-case1.gif) 22 | 23 | B) ... user can edit the link target then select a value from the autocompletion popup, press Enter and link and alias are created. Note that link text is kept. 24 | 25 | ![Run command, edit link, select in autocompletion, press Enter, done](use-case5.gif) 26 | 27 | C) ... user can enter name of new note, let cursor leave the link, then the new note is created automatically with link display text as alias. 28 | 29 | ![Run command, edit link, leave the link, done](use-case6.gif) 30 | 31 | ## Add alias for existing link 32 | 33 | User puts cursor into existing link and runs command "Create link with alias". The command creates the target document, if it doesn't exist, and adds link display text as alias into front matter of the target note 34 | 35 | ![Run command in link, done](use-case2.gif) 36 | 37 | ## Make completely new link 38 | 39 | User puts cursor into text and runs command "Create link with alias". It creates link brackets and opens autocompletion popup for entering of link target name. After user types in part of the target name or alias and selects it by enter, the link is created. If there is no display text and user moves back into link and enters one, then system detects it and after cursor leaves the brackets or user closes the window, the link display text is added as alias into front matter of the target note. While this use case is supported, it is usually faster to write text without link first and then **Make link on existing text**. 40 | 41 | ![Run command, select target, press Enter, move cursor back, write alias, leave the link, done](use-case3.gif) 42 | 43 | ## Toggle link display text 44 | 45 | As long as rename of Note has to keep the text with link to note understandable, it is good idea to keep the link display text in the link. In such case the Note is renamed but link display text stays unchanged. That is wanted behavior in many cases. 46 | But in case you have just list of Notes, where you want to see current note name, then the link display text is not helpful. The "Toggle link display text" command is a fast way how to remove unwanted display text and to keep just plain link. 47 | 48 | # Settings 49 | 50 | You can configured whether 51 | 52 | A) the text which is selected when command is executed is copied as link target name, so the autocompletion can immediatelly offer the similar term 53 | 54 | B) or the link target is kept empty so you can immediatelly type in the target note name 55 | 56 | # Notes 57 | 58 | - The alias is added into front matter of the target note only when it isn't there yet 59 | - The aliases are sorted from longest to shortest, so the Obsidian backlinks are detected correctly 60 | - The link autocompletion popup is the standard one provided by Obsidian. It sometime replaces the link text automaticaly, but it isn't wanted in this use case. The action "Create link with alias" will keep the link text exactly the same like it was before. 61 | 62 | ![Run command, press Enter, done](use-case4.gif) 63 | 64 | # About me 65 | 66 | I am a Software developer and architect with more then 35 years of programming experience. I am highly interested in creation and maintenance of human understandable, up to date, distributed and trustworthy knowledge. 67 | 68 | I love lifetime, nature, people, psychology and dancing. I am exited about the Obsidian because it helps me to experiment, prototype and prepare concepts of that knowledge base. 69 | 70 | Thank You for Your support which helps me to give more time for Obsidian plugins and that Knowledge base project I am dreaming of. 71 | 72 | [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/pavel_knowledge) 73 | 74 | [BuyMeACoffee](https://www.buymeacoffee.com/pavel.knowledge) 75 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Editor, EditorPosition, MarkdownFileInfo, MarkdownView, Plugin, PluginManifest, ReferenceCache, TFile } from "obsidian"; 2 | 3 | import { EditorCursorListener } from "./EditorCursorListener"; 4 | import { addMissingAliasesIntoFile } from "./InjectAlias"; 5 | import { Unregister } from "./ListenerRegistry"; 6 | import { getReferenceCacheFromEditor, setLinkText } from "./MarkdownUtils"; 7 | import { equalsPosition, isEditorPositionInPos, moveCursor, moveEditorPosition } from "./PositionUtils"; 8 | import { DEFAULT_SETTINGS, LinksSettingTab } from "./settings"; 9 | import { getTemplaterPlugin } from "./TemplaterIntegration"; 10 | import { capitalize, isNewFile } from "./Utils"; 11 | import { getOrCreateFileOfLink } from "./VaultUtils"; 12 | 13 | interface CanvasNode { 14 | canvas: unknown; 15 | } 16 | 17 | function isCanvasNode(obj: unknown): obj is CanvasNode { 18 | if (typeof obj == "object" && obj != null && "canvas" in obj) { 19 | return true; 20 | } 21 | return false; 22 | } 23 | 24 | interface CanvasNodeContext { 25 | node: CanvasNode; 26 | } 27 | 28 | function isCanvasNodeContext(ctx: unknown): ctx is CanvasNodeContext { 29 | if (typeof ctx == "object" && ctx != null && "node" in ctx) { 30 | return isCanvasNode((ctx as CanvasNodeContext).node); 31 | } 32 | return false; 33 | } 34 | 35 | /** 36 | * Information about to be handled link 37 | */ 38 | class LinkInfo { 39 | /** */ 40 | private readonly unregister: Unregister[] = []; 41 | 42 | constructor( 43 | /** Editor position of the link start bracket `[` */ 44 | public readonly linkStart: EditorPosition, 45 | /** The file which contains the link */ 46 | public readonly file: TFile | undefined, 47 | /** The Editor which contains the link*/ 48 | public readonly editor: Editor, 49 | /** At the end make alias */ 50 | public readonly makeAlias: boolean, 51 | /** The latest version of link cache */ 52 | public cacheLink: ReferenceCache, 53 | /** The string of link text from the last process check */ 54 | public linkText?: string, 55 | ) {} 56 | 57 | register(unregister: Unregister): this { 58 | this.unregister.push(unregister); 59 | return this; 60 | } 61 | 62 | destroy() { 63 | this.unregister.forEach((c) => c()); 64 | this.unregister.length = 0; 65 | } 66 | } 67 | 68 | /** 69 | * Main plugin class 70 | */ 71 | export default class LinkWithAliasPlugin extends Plugin { 72 | editorCursorListener: EditorCursorListener; 73 | linkInfo?: LinkInfo; 74 | settings = DEFAULT_SETTINGS; 75 | 76 | constructor(app: App, manifest: PluginManifest) { 77 | super(app, manifest); 78 | this.editorCursorListener = new EditorCursorListener(this); 79 | } 80 | 81 | async onload() { 82 | await this.loadSettings(); 83 | 84 | this.addCommand({ 85 | id: "create-link-with-alias", 86 | name: "Create link with alias", 87 | icon: "bracket-glyph", 88 | editorCallback: (editor: Editor, ctx) => { 89 | this.createLinkFromSelection(this.getFileFromContext(ctx), editor, editor.getCursor(), { 90 | makeAlias: true, 91 | pathFromText: this.settings.copyDisplayText, 92 | }); 93 | }, 94 | }); 95 | this.addCommand({ 96 | id: "create-link", 97 | name: "Create link", 98 | icon: "bracket-glyph", 99 | editorCallback: (editor: Editor, ctx) => { 100 | this.createLinkFromSelection(this.getFileFromContext(ctx), editor, editor.getCursor(), { 101 | makeAlias: false, 102 | pathFromText: this.settings.copyDisplayText, 103 | }); 104 | }, 105 | }); 106 | 107 | this.addCommand({ 108 | id: "toggle-link-display-text", 109 | name: "Toggle link display text", 110 | icon: "link-2", 111 | editorCallback: (editor: Editor, ctx) => { 112 | this.toggleLinkTextFromSelection(this.getFileFromContext(ctx), editor, editor.getCursor()); 113 | }, 114 | }); 115 | 116 | this.addSettingTab(new LinksSettingTab(this.app, this)); 117 | } 118 | 119 | async loadSettings() { 120 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 121 | } 122 | 123 | async saveSettings(): Promise { 124 | await this.saveData(this.settings); 125 | } 126 | 127 | private getFileFromContext(ctx: MarkdownView | MarkdownFileInfo): TFile | undefined { 128 | if (ctx.file) { 129 | return ctx.file; 130 | } 131 | if (isCanvasNodeContext(ctx)) { 132 | //TODO detect file of the canvas 133 | } 134 | return; 135 | } 136 | 137 | /** 138 | * starts create link with alias process for current `editor` of `file` on `position` 139 | * Only one link is processed, so only last call matters 140 | * @param file 141 | * @param editor 142 | * @param position 143 | */ 144 | private createLinkFromSelection( 145 | file: TFile | undefined, 146 | editor: Editor, 147 | position: EditorPosition, 148 | options: { makeAlias: boolean; pathFromText: boolean }, 149 | ): void { 150 | /** 151 | * returns ReferenceCache like structure which describes the link in `editor` on `pos` position or undefined if there is no link at `pos` position 152 | */ 153 | const cacheLink = getReferenceCacheFromEditor(editor, position); 154 | if (cacheLink != null && cacheLink.position.start.col !== position.ch) { 155 | //the cursor is inside the link, do not make nested link 156 | //but instead check that display text is used as alias 157 | if (options.makeAlias) { 158 | this.addMissingAlias(cacheLink, file?.path); 159 | } 160 | return; 161 | } 162 | const selected_word = editor.getSelection(); 163 | let linkStart; 164 | let linkText; 165 | if (selected_word == "") { 166 | //nothing is selected, just create a new empty link 167 | editor.replaceSelection(`[[]]`); 168 | linkStart = moveEditorPosition(moveCursor(editor, -2), -2); 169 | } else if (selected_word.indexOf("|") >= 0) { 170 | const parts = selected_word.split("|"); 171 | if (parts.length > 2) { 172 | return; 173 | } 174 | //selected text already contains a file name and display text 175 | editor.replaceSelection(`[[${selected_word}]]`); 176 | linkStart = moveEditorPosition(moveCursor(editor, -(parts[1].length + 3)), -(parts[0].length + 2)); 177 | linkText = parts[1]; 178 | } else { 179 | //text is selected 180 | if (options.pathFromText) { 181 | // use it as link target and also link display text 182 | editor.replaceSelection(`[[${this.capitalizeOptionally(selected_word)}|${selected_word}]]`); 183 | linkStart = moveEditorPosition(moveCursor(editor, -(selected_word.length + 3)), -(selected_word.length + 2)); 184 | linkText = selected_word; 185 | } else { 186 | // use it as link target 187 | editor.replaceSelection(`[[|${selected_word}]]`); 188 | linkStart = moveEditorPosition(moveCursor(editor, -(selected_word.length + 3)), -2); 189 | linkText = selected_word; 190 | } 191 | } 192 | 193 | if (this.linkInfo) { 194 | //destroy old link handling request 195 | this.linkInfo.destroy(); 196 | delete this.linkInfo; 197 | } 198 | const newCacheLink = getReferenceCacheFromEditor(editor, position); 199 | if (!newCacheLink) throw new Error("cannot find newly create link"); 200 | //create new link handling request 201 | const lastLink = new LinkInfo(linkStart, file, editor, options.makeAlias, newCacheLink, linkText); 202 | 203 | lastLink.register( 204 | //listen on cursor move or deactivation of editor 205 | this.editorCursorListener.fireOnCursorChange(editor, (cursorPosition) => { 206 | if (!cursorPosition) { 207 | //user is editing another file now. Add missing alias from origin file 208 | if (lastLink.makeAlias) { 209 | this.addMissingAlias(lastLink.cacheLink, lastLink.file?.path); 210 | } 211 | return false; 212 | } 213 | return this.handleChangeOnLastLink(editor, lastLink); 214 | }), 215 | ); 216 | 217 | this.linkInfo = lastLink; 218 | } 219 | 220 | capitalizeOptionally(name: string): string { 221 | if (this.settings.capitalizeFileName) { 222 | return capitalize(name); 223 | } 224 | return name; 225 | } 226 | 227 | toggleLinkTextFromSelection(file: TFile | undefined, editor: Editor, position: EditorPosition): void { 228 | const cacheLink = getReferenceCacheFromEditor(editor, position); 229 | if (cacheLink != null && cacheLink.position.start.col !== position.ch) { 230 | //the cursor is inside the link, toggle display text 231 | if (cacheLink.displayText == null) { 232 | //add display text separator to open drop down menu 233 | editor.setCursor({ line: cacheLink.position.end.line, ch: cacheLink.position.end.col - 2 }); 234 | editor.replaceSelection("|"); 235 | } else { 236 | // delete display text from this link and keep just plain link 237 | setLinkText(cacheLink, editor, undefined); 238 | } 239 | return; 240 | } 241 | } 242 | 243 | /** 244 | * Handles cache or editor cursor position change on the lastLink 245 | * @param editor 246 | * @param lastLink 247 | * @returns false if we are finished with that link 248 | */ 249 | private handleChangeOnLastLink(editor: Editor, lastLink: LinkInfo): boolean { 250 | const cacheLink = getReferenceCacheFromEditor(editor, lastLink.linkStart); 251 | if (cacheLink && equalsPosition(lastLink.linkStart, cacheLink.position.start)) { 252 | //the link still exist and starts on the expected position, continue handling 253 | lastLink.cacheLink = cacheLink; 254 | //the cache link for just created link exists now 255 | if (isEditorPositionInPos(lastLink.editor.getCursor(), cacheLink.position)) { 256 | //User still edits the last link, 257 | //update the link text if it was changed by user 258 | lastLink.linkText = cacheLink.displayText; 259 | //wait until he is done and moves cursor out 260 | return true; 261 | } 262 | //user left the link so s/he is done 263 | if (lastLink.linkText) { 264 | //Reset the link text in case the obsidian autocompletion changed it 265 | setLinkText(cacheLink, editor, lastLink.linkText); 266 | } 267 | if (lastLink.makeAlias) { 268 | //now we can create an alias 269 | this.addMissingAlias(cacheLink, lastLink.file?.path); 270 | } 271 | //continue handling here, because user can come back and add different alias for that last link 272 | return true; 273 | } 274 | //the link doesn't exist or start of the link was moved, do not handle it 275 | //unregister all handlers for this link, not just this one callback 276 | lastLink.destroy(); 277 | //and do not call this callback anynmore 278 | return false; 279 | } 280 | 281 | /** 282 | * creates target file and alias if something is not existing 283 | * @param cacheLink 284 | * @param sourcePath 285 | * @returns 286 | */ 287 | private async addMissingAlias(cacheLink: ReferenceCache, sourcePath: string | undefined): Promise { 288 | if (!cacheLink.original.contains("|") || !cacheLink.displayText) { 289 | //there is no special display text = no alias to add 290 | return; 291 | } 292 | //the link contains display text. Add it as alias 293 | const linkTargetPath = cacheLink.link; 294 | if (!linkTargetPath) { 295 | //there is no link target 296 | return; 297 | } 298 | const target = await getOrCreateFileOfLink(this.app, linkTargetPath, sourcePath); 299 | if (isNewFile(target)) { 300 | const templaterPlugin = getTemplaterPlugin(this.app); 301 | if (templaterPlugin) { 302 | await templaterPlugin.waitUntilDone(); 303 | } 304 | } 305 | await addMissingAliasesIntoFile(this.app.fileManager, target, [cacheLink.displayText]); 306 | } 307 | } 308 | --------------------------------------------------------------------------------