├── .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 | 
8 | 
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 |
14 | `);
15 |
16 | addIcon('DEVONthink-logo-blue', `
17 |
18 |
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 |
--------------------------------------------------------------------------------