diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3af5b72 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 + +[*.js] +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = true diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f3f398b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +Very Open License (VOL) + +The contributor(s) to this creative work voluntarily grant permission +to any individual(s) or entities of any kind +- to use the creative work in any manner, +- to modify the creative work without restriction, +- to sell the creative work or derivatives thereof for profit, and +- to release modifications of the creative work in part or whole under any license +with no requirement for compensation or recognition of any kind. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ea71c4 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Obsidian `@People` + +Obsidian plugin to add that familiar @-to-tag-someone syntax: + +![](./example.png) + +When you hit enter on a suggestion, it'll create a link that looks like this: + +``` +The author was [[@Rich Hickey]] +``` + +and leave the cursor at the end. + +## Options + +There's not a lot to configure here, but they are important: + +### 1. Where are the people files? + +You probably want to group the people files in a folder. + +I usually do something like this: + +``` +People/ + @Rich Hickey.md + @Rich Harris.md +``` + +You can configure that in settings to point to somewhere else, like `Reference/People/` or whatever makes sense. + +### 2. Explicit link structure? + +By default, the plugin will insert the simple version: + +``` +[[@Rich Hickey]] +``` + +But you might rather make that explicit, in which case you can enable "explicit links" and they'll look like this instead: + +``` +[[People/@Rich Hickey.md|@Rich Hickey]] +``` + +### 3. Last name grouping? + +For my personal Obsidian vaults, I have a lot of people with my same last name, so I put them in sub-folders for organization. + +You can toggle the "last name folder" option, and it'll do that in the links. + +The earlier example folder structure would be: + +``` +People/ + Hickey/ + @Rich Hickey.md + Harris/ + @Rich Harris.md +``` + +And then the inserted link would look like: + +``` +[[People/Hickey/@Rich Hickey.md|@Rich Hickey]] +``` + +> Note: figuring out what the "last name" is (or if it even has one) is really complicated! This plugin takes a very simply approach: if you split a name by the space character, it'll just pick the last "word". So for example "Charles Le Fabre" would be "Fabre" and *not* "Le Fabre". +> +> I'm open to better implementations that don't add a lot of complexity, just start a discussion. + +## License + +Published and made available freely under the [Very Open License](http://veryopenlicense.com/). diff --git a/example.png b/example.png new file mode 100644 index 0000000..8a75d88 Binary files /dev/null and b/example.png differ diff --git a/main.js b/main.js new file mode 100644 index 0000000..4afe74d --- /dev/null +++ b/main.js @@ -0,0 +1,167 @@ +const { App, Editor, EditorSuggest, TFile, Notice, Plugin, PluginSettingTab, Setting } = require('obsidian') + +const DEFAULT_SETTINGS = { + peopleFolder: 'People/', + // Defaults: + // useExplicitLinks: undefined, + // useLastNameFolder: undefined, +} + +const NAME_REGEX = /\/@([^\/]+)\.md$/ +const LAST_NAME_REGEX = /([\S]+)$/ + +const getPersonName = (filename, settings) => filename.startsWith(settings.peopleFolder) + && filename.endsWith('.md') + && filename.includes('/@') + && NAME_REGEX.exec(filename)?.[1] + +module.exports = class AtPeople extends Plugin { + async onload() { + await this.loadSettings() + this.registerEvent(this.app.vault.on('delete', async event => { await this.update(event) })) + this.registerEvent(this.app.vault.on('create', async event => { await this.update(event) })) + this.registerEvent(this.app.vault.on('rename', async (event, originalFilepath) => { await this.update(event, originalFilepath) })) + this.app.workspace.onLayoutReady(this.initialize) + this.addSettingTab(new AtPeopleSettingTab(this.app, this)) + this.suggestor = new AtPeopleSuggestor(this.app, this.settings) + this.registerEditorSuggest(this.suggestor) + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) + } + + async saveSettings() { + await this.saveData(this.settings || DEFAULT_SETTINGS) + } + + updatePeopleMap = () => { + this.suggestor.updatePeopleMap(this.peopleFileMap) + } + + update = async ({ path, deleted, ...remaining }, originalFilepath) => { + this.peopleFileMap = this.peopleFileMap || {} + const name = getPersonName(path, this.settings) + let needsUpdated + if (name) { + this.peopleFileMap[name] = path + needsUpdated = true + } + originalFilepath = originalFilepath && getPersonName(originalFilepath, this.settings) + if (originalFilepath) { + delete this.peopleFileMap[originalFilepath] + needsUpdated = true + } + if (needsUpdated) this.updatePeopleMap() + } + + initialize = () => { + this.peopleFileMap = {} + for (const filename in this.app.vault.fileMap) { + const name = getPersonName(filename, this.settings) + if (name) this.peopleFileMap[name] = filename + } + this.updatePeopleMap() + } +} + +class AtPeopleSuggestor extends EditorSuggest { + constructor(app, settings) { + super(app) + this.settings = settings + } + updatePeopleMap(peopleFileMap) { + this.peopleFileMap = peopleFileMap + } + onTrigger(cursor, editor, tFile) { + let charsLeftOfCursor = editor.getLine(cursor.line).substring(0, cursor.ch) + let atIndex = charsLeftOfCursor.lastIndexOf('@') + let query = atIndex >= 0 && charsLeftOfCursor.substring(atIndex + 1) + if (query && !query.includes(']]')) { + return { + start: { line: cursor.line, ch: atIndex }, + end: { line: cursor.line, ch: cursor.ch }, + query, + } + } + return null + } + getSuggestions(context) { + let suggestions = [] + for (let key in (this.peopleFileMap || {})) + if (key.toLowerCase().startsWith(context.query)) + suggestions.push({ + suggestionType: 'set', + displayText: key, + context, + }) + suggestions.push({ + suggestionType: 'create', + displayText: context.query, + context, + }) + return suggestions + } + renderSuggestion(value, elem) { + if (value.suggestionType === 'create') elem.setText('New person: ' + value.displayText) + else elem.setText(value.displayText) + } + selectSuggestion(value) { + let link + if (this.settings.useExplicitLinks && this.settings.useLastNameFolder) { + let lastName = LAST_NAME_REGEX.exec(value.displayText) + lastName = lastName && lastName[1] && (lastName[1] + '/') || '' + link = `[[${this.settings.peopleFolder}${lastName}@${value.displayText}.md|@${value.displayText}]]` + } else if (this.settings.useExplicitLinks && !this.settings.useLastNameFolder) { + link = `[[${this.settings.peopleFolder}@${value.displayText}.md|@${value.displayText}]]` + } else { + link = `[[@${value.displayText}]]` + } + value.context.editor.replaceRange( + link, + value.context.start, + value.context.end, + ) + } +} + +class AtPeopleSettingTab extends PluginSettingTab { + constructor(app, plugin) { + super(app, plugin) + this.plugin = plugin + } + display() { + const { containerEl } = this + containerEl.empty() + new Setting(containerEl) + .setName('People folder') + .setDesc('The folder where people files live, e.g. "People/". (With trailing slash.)') + .addText( + text => text + .setPlaceholder(DEFAULT_SETTINGS.peopleFolder) + .setValue(this.plugin.settings.peopleFolder) + .onChange(async (value) => { + this.plugin.settings.peopleFolder = value + await this.plugin.saveSettings() + }) + ) + new Setting(containerEl) + .setName('Explicit links') + .setDesc('When inserting links include the full path, e.g. [[People/@Bob Dole.md|@Bob Dole]]') + .addToggle( + toggle => toggle.onChange(async (value) => { + this.plugin.settings.useExplicitLinks = value + await this.plugin.saveSettings() + }) + ) + new Setting(containerEl) + .setName('Last name folder') + .setDesc('When using explicit links, use the "last name" (the last non-spaced word) as a sub-folder, e.g. [[People/Dole/@Bob Dole.md|@Bob Dole]]') + .addToggle( + toggle => toggle.onChange(async (value) => { + this.plugin.settings.useLastNameFolder = value + await this.plugin.saveSettings() + }) + ) + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..195aa15 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "at-people", + "name": "At People", + "version": "1.0.0", + "description": "Use the @ to create links to people files.", + "author": "saibotsivad", + "authorUrl": "https://davistobias.com", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6c5651 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "obsidian-at-people", + "version": "1.0.0", + "description": "Use the @ to create links to people files.", + "main": "main.js", + "repository": { + "type": "git", + "url": "git+https://github.com/saibotsivad/obsidian-at-people.git" + }, + "keywords": [ + "obsidian", + "obsidian-plugin", + "people" + ], + "author": { + "name": "Tobias Davis", + "email": "tobias@davistobias.com", + "url": "https://davistobias.com" + }, + "license": "See license in LICENSE.md", + "bugs": { + "url": "https://github.com/saibotsivad/obsidian-at-people/issues" + }, + "homepage": "https://github.com/saibotsivad/obsidian-at-people#readme" +}