├── .gitignore ├── README.md ├── icon-devonthink.afdesign ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js ├── styles.css ├── tsconfig.json └── versions.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | # etc 14 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DEVONlink - Integrate Obsidian with DEVONthink 2 | 3 | Open or reveal the current note in DEVONthink. Or, insert related files using DEVONthink's AI features. 4 | 5 | Pair it with the companion AppleScript to integrate Obsidian and DEVONthink notes. Read more about the plugin and find the AppleScript here: https://axle.design/devonlink-integrate-obsidian-and-devonthink 6 | 7 | ![This video shows the user using the plugin and AppleScript to open notes back and forth from Obsidian and DEVONthink.](https://i.imgur.com/VRurr9L.gif) 8 | ![Version 2 introduces a way to insert related items from DEVONthink's AI into your notes in Obsidian.](https://user-images.githubusercontent.com/3618647/113517367-c6c04d80-953c-11eb-81ca-5f898c776ff0.gif) 9 | 10 | ### How to use 11 | 12 | 1. Make sure your notes are indexed in a DEVONthink database, and that the database is open in DEVONthink. 13 | 2. Click the ribbon button to invoke one of DEVONlink's commands, configurable in the plugin's settings. Or, use the commands via the Command Palette (type cmd+p, then search for "DEVONlink"). Or, assign hotkeys to those commands in Obsidian's hotkey preferences. 14 | 15 | ### How it works 16 | 17 | The plugin uses the name of your active note to look for the first instance of a file with that name in any of your open databases. 18 | 19 | If you have the same file in multiple places (e.g., via replicants or duplicates), or if you have multiple files with the same name as your note, you may not get desirable results. 20 | 21 | ### Installing the plugin 22 | 23 | Look it up in Obsidian's Community Plugins gallery and select "Install." 24 | 25 | ### Manually installing the plugin 26 | 27 | - Copy over `main.js` and `manifest.json` to your vault folder in the directory `VaultFolder/.obsidian/plugins/DEVONlink-obsidian/`. 28 | -------------------------------------------------------------------------------- /icon-devonthink.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryanjamurphy/DEVONlink-obsidian/cd4df7f7e179993a20a18952ad303e840c9dbd28/icon-devonthink.afdesign -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { addIcon, App, FileSystemAdapter, Notice, Plugin, PluginSettingTab, Setting, MarkdownView } from 'obsidian'; 2 | import {runAppleScriptAsync} from 'run-applescript'; 3 | 4 | addIcon('DEVONthink-logo-neutral', ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | `); 15 | 16 | addIcon('DEVONthink-logo-blue', ` 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | `); 27 | 28 | interface DEVONlinkSettings { 29 | ribbonButtonAction: string; 30 | DEVONlinkIconColor: string; 31 | maximumRelatedItemsSetting: number; 32 | relatedItemsPrefixSetting: string; 33 | linkTypeSetting: string; 34 | } 35 | 36 | const DEFAULT_SETTINGS: DEVONlinkSettings = { 37 | ribbonButtonAction: 'open', 38 | DEVONlinkIconColor: 'DEVONthink-logo-blue', 39 | maximumRelatedItemsSetting: 10, 40 | relatedItemsPrefixSetting: "- ", 41 | linkTypeSetting: "intelligentLinking" 42 | } 43 | 44 | export default class DEVONlinkPlugin extends Plugin { 45 | settings: DEVONlinkSettings; 46 | ribbonIcon: HTMLElement; 47 | 48 | async onload() { 49 | console.log('Loading the DEVONlink plugin.'); 50 | 51 | await this.loadSettings(); 52 | 53 | this.ribbonIcon = this.addRibbonIcon(this.settings.DEVONlinkIconColor, 'DEVONlink', () => { 54 | this.doRibbonAction(); 55 | }); 56 | 57 | 58 | this.addCommand({ 59 | id: 'open-indexed-note-in-DEVONthink', 60 | name: 'Open indexed note in DEVONthink', 61 | checkCallback: this.openInDEVONthink.bind(this) 62 | }); 63 | 64 | this.addCommand({ 65 | id: 'reveal-indexed-note-in-DEVONthink', 66 | name: 'Reveal indexed note in DEVONthink', 67 | checkCallback: this.revealInDEVONthink.bind(this) 68 | }); 69 | 70 | this.addCommand({ 71 | id: 'insert-related-items-from-DEVONthink-analysis', 72 | name: 'Insert related items from DEVONthink concordance', 73 | checkCallback: this.insertRelatedFromDEVONthink.bind(this) 74 | }) 75 | 76 | this.addSettingTab(new DEVONlinkSettingsTab(this.app, this)); 77 | } 78 | 79 | onunload() { 80 | console.log('Unloading the DEVONlink plugin'); 81 | } 82 | 83 | async resetRibbonIcon() { //Hat-tip to @liam for this elegant way of managing the plugin's ribbon button. The idea is to give the plugin the ribbon icon as an object to hold onto. Then, since the ribbon icons are a `HTMLElement`, you can `.detach()` them to remove them and re-add them, reassigning the object. 84 | this.ribbonIcon.detach(); 85 | this.ribbonIcon = this.addRibbonIcon(this.settings.DEVONlinkIconColor, 'DEVONlink', () => { 86 | this.doRibbonAction(); 87 | }); 88 | } 89 | 90 | async loadSettings() { 91 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 92 | } 93 | 94 | async saveSettings() { 95 | await this.saveData(this.settings); 96 | } 97 | 98 | doRibbonAction() { 99 | if (this.settings.ribbonButtonAction == "open") { 100 | this.openInDEVONthink(false); 101 | } else if (this.settings.ribbonButtonAction == "reveal") { 102 | this.revealInDEVONthink(false); 103 | } else if (this.settings.ribbonButtonAction == "related") { 104 | this.insertRelatedFromDEVONthink(false); 105 | } 106 | }; 107 | 108 | getVaultPath(someVaultAdapter: FileSystemAdapter) { 109 | return someVaultAdapter.getBasePath(); 110 | } 111 | 112 | async insertRelatedFromDEVONthink(checking: boolean) : Promise { 113 | 114 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 115 | let activeFile = this.app.workspace.getActiveFile(); 116 | 117 | if (!checking) { 118 | if (activeView) { 119 | const editor = activeView.editor; 120 | let noteFilename = activeFile.name; 121 | let vaultAdapter = this.app.vault.adapter; 122 | let maximumRelatedItemsSetting = this.settings.maximumRelatedItemsSetting; 123 | let relatedItemsPrefix = this.settings.relatedItemsPrefixSetting; 124 | let vaultName = this.app.vault.getName(); 125 | // let fullNotePath = `${this.app.vault.adapter.basePath}/${activeFile.name}`; // Not actually the "full' path, can't catch folders within the Vault 126 | 127 | if (vaultAdapter instanceof FileSystemAdapter) { 128 | // let vaultPath = vaultAdapter.getBasePath(); // No longer needed, but leaving it in in case I ever decide to return to try to figure out how to get the path right. It would be a more robust solution than relying on filenames. 129 | // let homeFolderPath = await runAppleScriptAsync('get POSIX path of the (path to home folder)'); // see above 130 | // attempting to get path to work: await runAppleScriptAsync('tell application id "DNtp" to open window for record (first item in (lookup records with path "~' + vaultPath + '/' + notePath + '") in database (get database with uuid "'+ this.settings.databaseUUID + '")) with force'); // see above 131 | let currentLinkType = this.settings.linkTypeSetting; 132 | let wikiLinkLine = `"${relatedItemsPrefix}" & "[[" & name of eachRecord & "]]" & return`; 133 | let DEVONthinkLinkLine = `"${relatedItemsPrefix}" & "[" & name of eachRecord & "](" & reference URL of eachRecord & ")" & return`; 134 | let appleScript = `tell application id "DNtp" 135 | if not running then 136 | run 137 | end if 138 | try 139 | set theDatabases to databases 140 | repeat with thisDatabase in theDatabases 141 | set theNoteRecords to (lookup records with file "${noteFilename}" in thisDatabase) 142 | if theNoteRecords is not {} then 143 | set theNoteRecord to the first item in theNoteRecords 144 | try 145 | set seeAlso to compare record theNoteRecord to theNoteRecord's database 146 | set listOfRecords to "" 147 | set maximumItems to ${maximumRelatedItemsSetting} 148 | set itemCount to 0 149 | repeat with eachRecord in seeAlso 150 | if itemCount is not 0 then 151 | if itemCount is greater than maximumItems then 152 | return listOfRecords 153 | else 154 | if ("${currentLinkType}" is equal to "intelligentLinking") then 155 | if eachRecord's type is markdown then 156 | if eachRecord's path contains "${vaultName}" then 157 | set listOfRecords to listOfRecords & ${wikiLinkLine} 158 | else 159 | set listOfRecords to listOfRecords & ${DEVONthinkLinkLine} 160 | end if 161 | else 162 | set listOfRecords to listOfRecords & ${DEVONthinkLinkLine} 163 | end if 164 | else if ("${currentLinkType}" is equal to "wikiLinks") then 165 | set listOfRecords to listOfRecords & ${wikiLinkLine} 166 | else if ("${currentLinkType}" is equal to "devonthinkURL") then 167 | set listOfRecords to listOfRecords & ${DEVONthinkLinkLine} 168 | end if 169 | end if 170 | end if 171 | set itemCount to itemCount + 1 172 | end repeat 173 | return listOfRecords 174 | on error 175 | return "failure" 176 | end try 177 | end if 178 | end repeat 179 | return "failure" 180 | end try 181 | end tell` 182 | let DEVONlinkResults = await runAppleScriptAsync(appleScript); 183 | if (DEVONlinkResults == "failure") { 184 | new Notice("Sorry, DEVONlink couldn't find a matching record in your DEVONthink databases. Make sure your notes are indexed, the index is up to date, and the DEVONthink database with the indexed notes is open."); 185 | console.log("Debugging DEVONlink. Failed filename: '" + noteFilename +"'."); 186 | } else { 187 | console.log(DEVONlinkResults); 188 | let cursor = editor.getCursor(); 189 | // let lineText = editor.getLine(cursor.line); 190 | editor.replaceSelection(DEVONlinkResults); 191 | } 192 | } 193 | return true; 194 | } else { 195 | new Notice("No active pane. Try again with a note open in edit mode."); 196 | } 197 | } 198 | return false; 199 | 200 | 201 | } 202 | 203 | async openInDEVONthink(checking: boolean) : Promise { 204 | let activeFile = this.app.workspace.getActiveFile(); 205 | if (!checking) { 206 | if (activeFile) { 207 | let noteFilename = activeFile.name; 208 | let vaultAdapter = this.app.vault.adapter; 209 | if (vaultAdapter instanceof FileSystemAdapter) { 210 | // let vaultPath = vaultAdapter.getBasePath(); // No longer needed, but leaving it in in case I ever decide to return to try to figure out how to get the path right. It would be a more robust solution than relying on filenames. 211 | // let homeFolderPath = await runAppleScriptAsync('get POSIX path of the (path to home folder)'); // see above 212 | // attempting to get path to work: await runAppleScriptAsync('tell application id "DNtp" to open window for record (first item in (lookup records with path "~' + vaultPath + '/' + notePath + '") in database (get database with uuid "'+ this.settings.databaseUUID + '")) with force'); // see above 213 | let DEVONlinkResults = await runAppleScriptAsync( 214 | `tell application id "DNtp" 215 | activate 216 | try 217 | set theDatabases to databases 218 | repeat with thisDatabase in theDatabases 219 | try 220 | set theNoteRecord to (first item in (lookup records with file "${noteFilename}" in thisDatabase)) 221 | set newDEVONthinkWindow to open window for record theNoteRecord with force 222 | activate 223 | return "success" 224 | end try 225 | end repeat 226 | on error 227 | return "failure" 228 | end try 229 | end tell`); 230 | if (DEVONlinkResults != "success") { 231 | new Notice("Sorry, DEVONlink couldn't find a matching record in your DEVONthink databases. Make sure your notes are indexed, the index is up to date, and the DEVONthink database with the indexed notes is open."); 232 | console.log("Debugging DEVONlink. Failed filename: '" + noteFilename +"'."); 233 | } 234 | } 235 | return true; 236 | } else { 237 | new Notice("No active pane. Try again with a note open."); 238 | } 239 | } 240 | return false; 241 | } 242 | 243 | async revealInDEVONthink(checking: boolean) : Promise { 244 | let activeFile = this.app.workspace.getActiveFile(); 245 | if (!checking) { 246 | if (activeFile) { 247 | let noteFilename = activeFile.name; 248 | let vaultAdapter = this.app.vault.adapter; 249 | if (vaultAdapter instanceof FileSystemAdapter) { 250 | // let vaultPath = vaultAdapter.getBasePath(); // No longer needed, but leaving it in in case I ever decide to return to try to figure out how to get the path right. It would be a more robust solution than relying on filenames. 251 | // let homeFolderPath = await runAppleScriptAsync('get POSIX path of the (path to home folder)'); // see above 252 | // attempting to get path to work: await runAppleScriptAsync('tell application id "DNtp" to open window for record (first item in (lookup records with path "~' + vaultPath + '/' + notePath + '") in database (get database with uuid "'+ this.settings.databaseUUID + '")) with force'); // see above 253 | let DEVONlinkResults = await runAppleScriptAsync( 254 | `tell application id "DNtp" 255 | activate 256 | try 257 | set theDatabases to databases 258 | repeat with thisDatabase in theDatabases 259 | try 260 | set theNoteRecord to (first item in (lookup records with file "${noteFilename}" in thisDatabase)) 261 | set theParentRecord to the first parent of theNoteRecord 262 | set newDEVONthinkWindow to open window for record theParentRecord with force 263 | set newDEVONthinkWindow's selection to theNoteRecord as list 264 | activate 265 | return "success" 266 | end try 267 | end repeat 268 | on error 269 | return "failure" 270 | end try 271 | end tell`); 272 | if (DEVONlinkResults != "success") { 273 | new Notice("Sorry, DEVONlink couldn't find a matching record in your DEVONthink databases. Make sure your notes are indexed, the index is up to date, and the DEVONthink database with the indexed notes is open."); 274 | console.log("Debugging DEVONlink. Failed filename: '" + noteFilename +"'."); 275 | } 276 | } 277 | return true; 278 | } else { 279 | new Notice("No active pane. Try again with a note open."); 280 | } 281 | } 282 | return false; 283 | } 284 | } 285 | 286 | class DEVONlinkSettingsTab extends PluginSettingTab { 287 | plugin: DEVONlinkPlugin; 288 | 289 | constructor(app: App, plugin: DEVONlinkPlugin) { 290 | super(app, plugin); 291 | this.plugin = plugin; 292 | } 293 | 294 | display(): void { 295 | let {containerEl} = this; 296 | 297 | containerEl.empty(); 298 | 299 | containerEl.createEl('h2', {text: 'DEVONlink Settings'}); 300 | 301 | new Setting(containerEl) 302 | .setName('Ribbon button action') 303 | .setDesc('Should the ribbon button open the note in DEVONthink, or reveal it in the DEVONthink database?') 304 | .addDropdown(buttonMenu => buttonMenu 305 | .addOption("open", "Open the note in the database") 306 | .addOption("reveal", "Reveal the note in the database") 307 | .addOption("related", "Insert a list of items related to the active note") 308 | .setValue(this.plugin.settings.ribbonButtonAction) 309 | .onChange(async (value) => { 310 | this.plugin.settings.ribbonButtonAction = value; 311 | await this.plugin.saveSettings(); 312 | })); 313 | 314 | new Setting(containerEl) 315 | .setName('Ribbon button colour') 316 | .setDesc('Should the ribbon button be DEVONthink blue or inherit the theme colour?') 317 | .addDropdown(buttonMenu => buttonMenu 318 | .addOption("DEVONthink-logo-neutral", "Inherit the theme colour") 319 | .addOption("DEVONthink-logo-blue", "DEVONthink blue") 320 | .setValue(this.plugin.settings.DEVONlinkIconColor) 321 | .onChange(async (value) => { 322 | this.plugin.settings.DEVONlinkIconColor = value; 323 | this.plugin.resetRibbonIcon(); 324 | await this.plugin.saveSettings(); 325 | })); 326 | 327 | new Setting(containerEl) 328 | .setName('Maximum items returned with the Related Items command') 329 | .setDesc('Set the maximum number of related items DEVONlink will try to return when using the Related Items command.') 330 | .addText(textbox => textbox 331 | .setValue(this.plugin.settings.maximumRelatedItemsSetting.toString()) 332 | .onChange(async (value) => { 333 | if (Number(value)) { 334 | this.plugin.settings.maximumRelatedItemsSetting = Number(value); 335 | await this.plugin.saveSettings(); 336 | } else { 337 | new Notice("It looks like you haven't entered a number. Please use integers (e.g.: 1, 2, or 3) only. Resetting the maximum related items to the default value of 5."); 338 | this.plugin.settings.maximumRelatedItemsSetting = 5; 339 | await this.plugin.saveSettings(); 340 | } 341 | })); 342 | 343 | new Setting(containerEl) 344 | .setName('Related items list prefix') 345 | .setDesc('The Related Items command returns a list of `[[`-wrapped file names. This setting allows you to configure what the list items look like. E.g., use `- ` for a bulleted list or `!` for embeds.') 346 | .addText(textbox => textbox 347 | .setValue(this.plugin.settings.relatedItemsPrefixSetting) 348 | .onChange(async (value) => { 349 | this.plugin.settings.relatedItemsPrefixSetting = value; 350 | await this.plugin.saveSettings(); 351 | })); 352 | 353 | new Setting(containerEl) 354 | .setName('Link type for related items') 355 | .setDesc('Set the type of link returned by the Related Items command') 356 | .addDropdown(buttonMenu => buttonMenu 357 | .addOption("wikilinks", "[[Wikilinks]]") 358 | .addOption("devonthinkURL", "DEVONthink x-devonthink-item links") 359 | /* 360 | * "Wikilinks") { 361 | linkLine = `"${relatedItemsPrefix}" & "[[" & name of eachRecord & "]]" & return` 362 | } else if (currentLinkType == "DEVONthink URL") { 363 | linkLine = `"${relatedItemsPrefix}" & "[" & name of eachRecord & "](" & reference URL of eachRecord & ")" & return` 364 | } else if (currentLinkType == "Intelligent Linking") { 365 | * */ 366 | .addOption("intelligentLinking", "Automatically switch between Wikilinks and DEVONthink URL") 367 | .setValue(this.plugin.settings.linkTypeSetting) 368 | .onChange(async (value) => { 369 | this.plugin.settings.linkTypeSetting = value; 370 | await this.plugin.saveSettings(); 371 | })); 372 | } 373 | 374 | } 375 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "DEVONlink-obsidian", 3 | "name": "DEVONlink", 4 | "version": "2.2.1", 5 | "minAppVersion": "0.9.12", 6 | "description": "Open or reveal the current note in DEVONthink.", 7 | "author": "ryanjamurphy", 8 | "authorUrl": "https://axle.design", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DEVONlink-obsidian", 3 | "version": "2.2.1", 4 | "description": "Open or reveal the current note in DEVONthink.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^15.1.0", 15 | "@rollup/plugin-node-resolve": "^9.0.0", 16 | "@rollup/plugin-typescript": "^6.0.0", 17 | "@types/node": "^14.14.36", 18 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 19 | "rollup": "^2.42.4", 20 | "run-applescript": "^5.0.0", 21 | "tslib": "^2.0.3", 22 | "typescript": "^4.0.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ROLLUP 8 | if you want to view the source visit the plugins github repository 9 | */ 10 | `; 11 | 12 | export default { 13 | input: 'main.ts', 14 | output: { 15 | dir: '.', 16 | sourcemap: 'inline', 17 | format: 'cjs', 18 | exports: 'default', 19 | banner, 20 | }, 21 | external: ['obsidian'], 22 | plugins: [ 23 | typescript(), 24 | nodeResolve({browser: true}), 25 | commonjs(), 26 | ] 27 | }; -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Sets all the text color to red! */ 2 | body { 3 | color: red; 4 | } 5 | -------------------------------------------------------------------------------- /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 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.9.12", 3 | "1.0.0": "0.9.7" 4 | } 5 | --------------------------------------------------------------------------------