├── .npmrc ├── .eslintignore ├── styles.css ├── versions.json ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Sets all the text color to red! */ 2 | body { 3 | } 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.7", 3 | "1.0.1": "0.12.0" 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-etherpad-plugin", 3 | "name": "Etherpad", 4 | "version": "1.0.6", 5 | "minAppVersion": "0.12.0", 6 | "description": "Etherpad Integration", 7 | "author": "egradman", 8 | "authorUrl": "https://www.gradman.com", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /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 | "lib": [ 14 | "DOM", 15 | "ES5", 16 | "ES6", 17 | "ES7" 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.0.1", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "^5.2.0", 17 | "@typescript-eslint/parser": "^5.2.0", 18 | "builtin-modules": "^3.2.0", 19 | "esbuild": "0.13.12", 20 | "obsidian": "latest", 21 | "tslib": "2.3.1", 22 | "typescript": "4.4.4" 23 | }, 24 | "dependencies": { 25 | "etherpad-lite-client": "^0.9.0", 26 | "turndown": "^7.1.1", 27 | "yaml": "^1.10.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 eric gradman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | 10 | -------------------------------------------------------------------------------- /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 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins], 45 | format: 'cjs', 46 | watch: !prod, 47 | target: 'es2016', 48 | logLevel: "info", 49 | sourcemap: prod ? false : 'inline', 50 | treeShaking: true, 51 | outfile: 'main.js', 52 | }).catch(() => process.exit(1)); 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Etherpad-lite Obsidian Plugin 2 | 3 | My first draft of everything happens in Obsidian. The second draft usually happens in collaboration with others. I find myself cutting and pasting my work into a Google Doc, sharing the URL with coworkers, and replacing the original with a link to the Google Doc. This makes my work unsearchable, untaggable, and unlinkable. 4 | 5 | This plugin uses an Etherpad-Lite server as a lightweight collaboration tool. Etherpad-Lite is a web-based editor with no frills. I've always thought of it as the "pastebin of editors." With this plugin, you can upload any note to an Etherpad-Lite server, share the URL, and allow others to collaboratively edit. The document remains in your vault. Each time it's opened, its contents will be replaced with the latest version from the Etherpad-Lite server. 6 | 7 | When you're happy with the results and don't want to sync anymore, simply remove the `etherpad_id` key from the frontmatter. 8 | 9 | There are three commands: 10 | 11 | ### Convert current note to Etherpad 12 | 13 | This command uploads the text of the current note to your Etherpad-Lite server. The id of the note on the server will be the same as the basename of the note in your vault. This command adds a metadata key (`etherpad_id`) to the frontmatter of your document which signals to the plugin that this note canonically lives in the cloud. 14 | 15 | ### Replace note content from Etherpad 16 | 17 | This command explicitly replaces the contents of the current note with its version on the server. It uses the `etherpad_id` frontmatter key to determine where to fetch from. If no such key exists, this command is a no-op. 18 | 19 | This is exactly the behavior as when a note with an `etherpad-id` key is opened. 20 | 21 | ### Visit note in Etherpad in system browser 22 | 23 | This command opens the Etherpad-Lite server in your system browser. Copy the URL and share it with others! 24 | 25 | ## Configuration 26 | 27 | Set the server's `host`, `port`, and `apikey`. 28 | 29 | The API key can be found in `APIKEY.txt` in the root of your server installation. 30 | 31 | ## Set up an Etherpad-Lite server 32 | 33 | _"Wait... I have to set up my own server?"_ 34 | 35 | Easier than it sounds. It can be done in AWS with a free-tier EC2 machine, or even in Heroku. It takes about 2 minutes, but it's out of the scope of this document. You can literally leave all the defaults as-is for a functional (but insecure) system. Follow the [Etherpad-Lite instructions](https://github.com/ether/etherpad-lite). 36 | 37 | And don't forget to grab your API key from `APIKEY.txt`! 38 | 39 | I know. This part feels like a bait-and-switch. 40 | 41 | ## TODO 42 | 43 | - Command to disconnect document from Etherpad and delete remote version 44 | - Auto-update of document when its open? 45 | - option to prefer random pad_ids 46 | 47 | 48 | ## Caveats 49 | 50 | - GAH! It's currently broken on mobile due to some bad dependencies in a library I'm using. I'll fix it very soon! 51 | 52 | https://user-images.githubusercontent.com/103129/158699049-6d7801d9-82d0-416a-bcdb-dcdb6fd14987.mp4 53 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; 2 | 3 | let etherpad = require('etherpad-lite-client'); 4 | import { stringifyYaml } from 'obsidian'; 5 | 6 | let TurndownService = require('turndown') 7 | 8 | TurndownService.prototype.escape = (text) => text; 9 | 10 | function makeid(length) { 11 | let result = ''; 12 | let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 13 | let charactersLength = characters.length; 14 | for (let i = 0; i < length; i++) { 15 | result += characters.charAt(Math.floor(Math.random() * 16 | charactersLength)); 17 | } 18 | return result; 19 | } 20 | 21 | let td = new TurndownService() 22 | .addRule('strikethrough', { 23 | filter: ['s'], 24 | replacement: function (content) { 25 | return '~~' + content + '~~' 26 | } 27 | }) 28 | .addRule('underline', { 29 | filter: ['u'], 30 | replacement: function (content) { 31 | return '==' + content + '=='; 32 | } 33 | }) 34 | .addRule('a', { 35 | filter: ['a'], 36 | replacement: function (content, node, options) { 37 | return node.getAttribute("href") 38 | } 39 | }) 40 | 41 | interface EtherpadSettings { 42 | host: string; 43 | port: int; 44 | apikey: string; 45 | random_pad_id: bool; 46 | reload: boolean; 47 | } 48 | 49 | const DEFAULT_SETTINGS: EtherpadSettings = { 50 | host: 'localhost', 51 | port: 9001, 52 | apikey: "", 53 | random_pad_id: true, 54 | reload: true 55 | } 56 | 57 | 58 | export default class Etherpad extends Plugin { 59 | settings: EtherpadSettings; 60 | 61 | get etherpad() { 62 | return etherpad.connect({ 63 | apikey: this.settings.apikey, 64 | host: this.settings.host, 65 | port: this.settings.port 66 | }) 67 | } 68 | 69 | async onload() { 70 | await this.loadSettings(); 71 | 72 | // This adds a status bar item to the bottom of the app. Does not work on mobile apps. 73 | //const statusBarItemEl = this.addStatusBarItem(); 74 | //statusBarItemEl.setText('Status Bar Text'); 75 | 76 | this.registerEvent( 77 | this.app.workspace.on('file-open', async (note) => { 78 | if (this.settings.reload) this.replace_note_from_etherpad(note); 79 | }) 80 | ); 81 | 82 | // This adds an editor command that can perform some operation on the current editor instance 83 | this.addCommand({ 84 | id: 'etherpad-create-pad', 85 | name: 'Convert current document to Etherpad', 86 | editorCallback: async (editor: Editor, view: MarkdownView) => { 87 | 88 | const note = view.file; 89 | 90 | if (!note.name) 91 | return; 92 | 93 | let note_text = editor.getValue(); 94 | let note_text_without_frontmatter = await this.get_text_without_frontmatter(note_text, note); 95 | 96 | // check if pad exists and update 97 | let frontmatter = this.get_frontmatter(note); 98 | 99 | if (!frontmatter.etherpad_id) { 100 | let pad_id = this.settings.random_pad_id ? makeid(12) : note.basename; 101 | 102 | this.etherpad.createPad({ 103 | padID: pad_id, 104 | text: note_text_without_frontmatter 105 | }, (error, data) => { 106 | if (error) { 107 | new Notice(`Error creating pad ${pad_id}: ${error.message}`); 108 | } 109 | else { 110 | this.update_frontmatter(note_text, note, { etherpad_id: pad_id }); 111 | } 112 | }) 113 | } else { 114 | let pad_id = frontmatter.etherpad_id; 115 | 116 | this.etherpad.setText({ 117 | padID: pad_id, 118 | text: note_text_without_frontmatter 119 | }, (error, data) => { 120 | if (error) { 121 | new Notice(`Error updating pad ${pad_id}: ${error.message}`); 122 | } 123 | }) 124 | } 125 | 126 | 127 | } 128 | }); 129 | 130 | this.addCommand({ 131 | id: 'etherpad-get-pad', 132 | name: 'Replace note content from Etherpad', 133 | editorCallback: async (editor: Editor, view: MarkdownView) => { 134 | const note = view.file; 135 | this.replace_note_from_etherpad(note); 136 | } 137 | }); 138 | 139 | this.addCommand({ 140 | id: 'etherpad-visit-pad', 141 | name: 'Visit note in Etherpad in system browser', 142 | editorCallback: async (editor: Editor, view: MarkdownView) => { 143 | let note = view.file; 144 | if (!note.name) 145 | return; 146 | 147 | let frontmatter = this.get_frontmatter(note); 148 | if (frontmatter?.etherpad_id) { 149 | let url = this.get_url_for_pad_id(frontmatter.etherpad_id); 150 | window.open(url); 151 | } 152 | } 153 | }); 154 | 155 | // This adds a settings tab so the user can configure various aspects of the plugin 156 | this.addSettingTab(new EtherpadSettingTab(this.app, this)); 157 | 158 | } 159 | 160 | onunload() { 161 | 162 | } 163 | 164 | async loadSettings() { 165 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 166 | } 167 | 168 | async saveSettings() { 169 | await this.saveData(this.settings); 170 | } 171 | 172 | get_frontmatter(note) { 173 | // return a copy 174 | return { ...this.app.metadataCache.getFileCache(note)?.frontmatter }; 175 | } 176 | 177 | async get_text_without_frontmatter(note_text, note) { 178 | //let note_text = await this.app.vault.read(note); 179 | let fmc = app.metadataCache.getFileCache(note)?.frontmatter; 180 | if (!fmc) { 181 | return note_text; 182 | } 183 | //let end = fmc.position.end.line + 1 // account for ending --- 184 | let end = Object.keys(fmc).length + 2; // account for starting & ending --- 185 | return note_text.split("\n").slice(end).join("\n"); 186 | } 187 | 188 | async update_frontmatter(note_text, note, d) { 189 | let frontmatter = this.get_frontmatter(note); 190 | let updated_frontmatter; 191 | if (!frontmatter) { 192 | // create new frontmatter 193 | updated_frontmatter = d; 194 | } else { 195 | updated_frontmatter = { 196 | ...frontmatter, 197 | ...d 198 | }; 199 | } 200 | delete updated_frontmatter.position; 201 | let frontmatter_text = `---\n${stringifyYaml(updated_frontmatter)}---\n`; 202 | //let note_text = await this.get_text_without_frontmatter(note); 203 | this.app.vault.modify(note, frontmatter_text + note_text); 204 | } 205 | 206 | get_url_for_pad_id(pad_id) { 207 | pad_id = pad_id.replace(" ", "_"); 208 | return `http://${this.settings.host}:${this.settings.port}/p/${pad_id}` 209 | } 210 | 211 | async replace_note_from_etherpad(note) { 212 | if (note == null) return; 213 | let frontmatter = this.get_frontmatter(note); 214 | if (!frontmatter) return; 215 | if (!frontmatter.etherpad_id) return; 216 | this.etherpad.getHTML({ padID: frontmatter.etherpad_id }, (err, data) => { 217 | if (err) { 218 | console.log("err", err); 219 | new Notice("error: " + err); 220 | } else { 221 | delete frontmatter.position; 222 | let now = new Date(); 223 | frontmatter.etherpad_get_at = now.toLocaleString(); 224 | let frontmatter_text = `---\n${stringifyYaml(frontmatter)}---\n`; 225 | let note_html = data.html; 226 | 227 | let note_text = td.turndown(note_html) 228 | this.app.vault.modify(note, frontmatter_text + note_text); 229 | let url = this.get_url_for_pad_id(frontmatter.etherpad_id); 230 | new Notice(`Note was reloaded from ${url}.\nLocal edits will be discarded!`); 231 | } 232 | }); 233 | } 234 | } 235 | 236 | class EtherpadSettingTab extends PluginSettingTab { 237 | plugin: Etherpad; 238 | 239 | constructor(app: App, plugin: Etherpad) { 240 | super(app, plugin); 241 | this.plugin = plugin; 242 | } 243 | 244 | display(): void { 245 | const { containerEl } = this; 246 | 247 | containerEl.empty(); 248 | 249 | containerEl.createEl('h2', { text: 'Etherpad Settings' }); 250 | 251 | new Setting(containerEl) 252 | .setName('Server host') 253 | .setDesc('Server host') 254 | .addText(text => text 255 | .setPlaceholder('localhost') 256 | .setValue(this.plugin.settings.host) 257 | .onChange(async (value) => { 258 | this.plugin.settings.host = value; 259 | await this.plugin.saveSettings(); 260 | })); 261 | 262 | new Setting(containerEl) 263 | .setName('Server port') 264 | .setDesc('Server port') 265 | .addText(text => text 266 | .setPlaceholder('9001') 267 | .setValue(this.plugin.settings.port.toString()) 268 | .onChange(async (value) => { 269 | this.plugin.settings.port = parseInt(value); 270 | await this.plugin.saveSettings(); 271 | })); 272 | 273 | new Setting(containerEl) 274 | .setName('API key') 275 | .setDesc('API key') 276 | .addText(text => text 277 | .setPlaceholder('') 278 | .setValue(this.plugin.settings.apikey) 279 | .onChange(async (value) => { 280 | this.plugin.settings.apikey = value; 281 | await this.plugin.saveSettings(); 282 | })); 283 | 284 | new Setting(containerEl) 285 | .setName('Random pad ID') 286 | .setDesc('Use a random pad id, or current file name') 287 | .addToggle(b => b 288 | .setValue(this.plugin.settings.random_pad_id) 289 | .onChange(async (value) => { 290 | this.plugin.settings.random_pad_id = value; 291 | await this.plugin.saveSettings(); 292 | })); 293 | 294 | new Setting(containerEl) 295 | .setName('Reload from Etherpad on open') 296 | .setDesc('Download Pad content when Markdown is opened') 297 | .addToggle(b => b 298 | .setValue(this.plugin.settings.reload) 299 | .onChange(async (value) => { 300 | this.plugin.settings.reload = value; 301 | await this.plugin.saveSettings(); 302 | })); 303 | } 304 | } 305 | --------------------------------------------------------------------------------