├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js ├── src ├── readwiseApi.ts └── settings.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "semi": ["error", "always"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | main.js 4 | *.js.map 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Mike Skalnik 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose 4 | with or without fee is hereby granted, provided that the above copyright notice 5 | and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 13 | THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian ← Readwise Sync 2 | 3 | ⚠️ This is pretty basic. Use at your own risk! ⚠️ 4 | 5 | A way to sync highlights and annotations from [Readwise](https://readwise.io) to 6 | Obsidian. 7 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import { Notice, DataAdapter, Vault, Plugin } from 'obsidian'; 3 | import ReadwiseApi from './src/readwiseApi'; 4 | import { ReadwiseSettings, ReadwiseSettingsTab } from './src/settings'; 5 | import * as path from 'path'; 6 | 7 | type BookCache = { 8 | [id: number]: { 9 | title: string; 10 | normalizedTitle: string; 11 | } 12 | }; 13 | 14 | export default class ObsidianReadwise extends Plugin { 15 | cacheFilename = ".cache.json"; 16 | forbiddenCharRegex = /\*|"|\\|\/|<|>|:|\||\?/g; 17 | 18 | client: ReadwiseApi; 19 | settings: ReadwiseSettings; 20 | fs: DataAdapter; 21 | vault: Vault; 22 | lastUpdate: string; 23 | cachedBooks: BookCache; 24 | 25 | async onload(): Promise { 26 | this.settings = (await this.loadData()) || new ReadwiseSettings(); 27 | this.addSettingTab(new ReadwiseSettingsTab(this.app, this)); 28 | this.addCommand({ 29 | id: 'readwise-sync', 30 | name: 'Sync Readwise highlights', 31 | callback: async () => this.syncNotes() 32 | }); 33 | } 34 | 35 | syncNotes(): void { 36 | this.vault = this.app.vault; 37 | this.fs = this.vault.adapter; 38 | this.lastUpdate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 365).toISOString(); 39 | this.cachedBooks = {}; 40 | 41 | this.readCache(); 42 | } 43 | 44 | async readCache(): Promise { 45 | // 1 year ago 46 | this.lastUpdate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 365).toISOString(); 47 | const exists = await this.fs.exists(this.cacheFilename); 48 | if (exists) { 49 | const data = await this.fs.read(this.cacheFilename); 50 | const cache = JSON.parse(data); 51 | 52 | this.cachedBooks = cache.books; 53 | this.lastUpdate = cache.lastUpdate; 54 | } 55 | 56 | this.client = new ReadwiseApi(this.settings.token, this.lastUpdate); 57 | this.fetchBooks(); 58 | } 59 | 60 | async fetchBooks(): Promise { 61 | const apiBooks = await this.client.fetchBooks(); 62 | 63 | for (const book of apiBooks) { 64 | const normalizedTitle = book.title.replace(this.forbiddenCharRegex, "-"); 65 | this.cachedBooks[book.id] = { 66 | title: book.title, 67 | normalizedTitle: normalizedTitle 68 | }; 69 | const filename = path.join(this.settings.referencesDir, `${normalizedTitle}.md`); 70 | const exists = await this.fs.exists(filename); 71 | if (!exists) { 72 | const body = ['---', 73 | 'tags: book', 74 | '---', 75 | '', 76 | `**Title**: ${book.title}`, 77 | `**Author**: [[${book.author}]]`, 78 | `**ISBN**: `, 79 | `**Read**: [[]]`, 80 | ].join("\n"); 81 | 82 | this.fs.write(filename, body).then(()=> { 83 | console.log(`${normalizedTitle}.md created!`); 84 | }); 85 | } 86 | } 87 | 88 | console.log("All books fetched!"); 89 | this.fetchHighlights(); 90 | } 91 | 92 | async fetchHighlights(): Promise { 93 | const highlights = await this.client.fetchHighlights(); 94 | for (const highlight of highlights) { 95 | const name = highlight.id; 96 | const filename = path.join(this.settings.inboxDir, `${name}.md`); 97 | 98 | if (highlight.highlighted_at && highlight.highlighted_at.length > 0 && 99 | ((new Date(highlight.highlighted_at)) > (new Date(this.lastUpdate)))) { 100 | this.lastUpdate = highlight.highlighted_at; 101 | } 102 | 103 | const exists = await this.fs.exists(filename); 104 | if (!exists) { 105 | let body = [`> ${highlight.text}`, 106 | `— [[${this.cachedBooks[highlight.book_id].normalizedTitle}]]` 107 | ].join("\n"); 108 | 109 | if (highlight.note.length > 0) { 110 | body += `\n\n${highlight.note}`; 111 | } 112 | 113 | this.fs.write(filename, body).then(() => { 114 | console.log(`${filename} created!`); 115 | }); 116 | } 117 | } 118 | new Notice('Readwise highlights synced!'); 119 | this.writeCache(); 120 | } 121 | 122 | writeCache(): void { 123 | const cache = { 124 | books: this.cachedBooks, 125 | lastUpdate: this.lastUpdate, 126 | }; 127 | 128 | this.fs.write(this.cacheFilename, JSON.stringify(cache)); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-readwise-sync", 3 | "name": "Obsidian Readwise Sync", 4 | "version": "0.0.1", 5 | "minAppVersion": "0.9.20", 6 | "description": "Syncs Readwise highlights into Obsidian", 7 | "author": "Mike Skalnik", 8 | "authorUrl": "https://skalnik.com", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-readwise", 3 | "version": "0.0.1", 4 | "description": "Syncs your Readwise highlights and annotations to Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js", 9 | "lint": "eslint . --ext .ts", 10 | "lint:fix": "eslint . --ext .ts --fix" 11 | }, 12 | "dependencies": { 13 | "axios": "^0.21.0" 14 | }, 15 | "devDependencies": { 16 | "@rollup/plugin-commonjs": "^15.1.0", 17 | "@rollup/plugin-node-resolve": "^9.0.0", 18 | "@rollup/plugin-typescript": "^6.0.0", 19 | "@types/node": "^14.14.12", 20 | "@typescript-eslint/eslint-plugin": "^4.9.1", 21 | "@typescript-eslint/parser": "^4.9.1", 22 | "eslint": "^7.15.0", 23 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 24 | "rollup": "^2.32.1", 25 | "tslib": "^2.0.3", 26 | "typescript": "^4.1.2" 27 | }, 28 | "author": "Mike Skalnik", 29 | "license": "ISC" 30 | } 31 | -------------------------------------------------------------------------------- /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 | export default { 6 | input: 'main.ts', 7 | output: { 8 | dir: '.', 9 | sourcemap: 'inline', 10 | format: 'cjs', 11 | exports: 'default' 12 | }, 13 | external: ['obsidian', 'path', 'fs'], 14 | plugins: [ 15 | typescript(), 16 | nodeResolve({browser: true}), 17 | commonjs(), 18 | ] 19 | }; 20 | -------------------------------------------------------------------------------- /src/readwiseApi.ts: -------------------------------------------------------------------------------- 1 | export type Book = { 2 | id: number; 3 | title: string; 4 | author: string; 5 | } 6 | 7 | export type Highlight = { 8 | id: number; 9 | book_id: number; 10 | text: string; 11 | note: string; 12 | highlighted_at: string; 13 | } 14 | 15 | export default class ReadwiseApi { 16 | baseUrl = "https://readwise.io/api/v2" 17 | token: string; 18 | lastUpdate: string; 19 | 20 | constructor(token: string, lastUpdate = "") { 21 | this.token = token; 22 | this.lastUpdate = lastUpdate; 23 | } 24 | 25 | fetchBooks(): Promise<[Book]> { 26 | console.log("Fetching books…"); 27 | const params = { page_size: "1000", category: "books", last_highlighted_at__gt: this.lastUpdate, }; 28 | 29 | return this.apiRequest<[Book]>('/books', params); 30 | } 31 | 32 | fetchHighlights(): Promise<[Highlight]> { 33 | console.log("Fetching highlights…"); 34 | const params = { page_size: "1000", highlighted_at__gt: this.lastUpdate }; 35 | 36 | return this.apiRequest<[Highlight]>('/highlights', params); 37 | } 38 | 39 | async apiRequest(path: string, params: Record): Promise { 40 | // The Readwise API doesn't include CORs headers. This is really pretty 41 | // crap our user here 😭 42 | const url = new URL("https://cors-anywhere.herokuapp.com/" + this.baseUrl + path); 43 | url.search = new URLSearchParams(params).toString(); 44 | 45 | const request = new Request(url.toString(), { 46 | headers: { 47 | 'Authorization': `Token ${this.token}` 48 | }, 49 | }); 50 | 51 | const response = await fetch(request).then(response => { 52 | if(!response.ok) { 53 | throw new Error(response.statusText); 54 | } 55 | return response; 56 | }); 57 | const json = await (response.json() as Promise<{ results: T }>); 58 | 59 | return json.results; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, Setting } from 'obsidian'; 2 | import ObsidianReadwise from '../main'; 3 | 4 | export class ReadwiseSettings { 5 | token = ""; 6 | inboxDir = "Inbox"; 7 | referencesDir = "References"; 8 | } 9 | 10 | export class ReadwiseSettingsTab extends PluginSettingTab { 11 | display(): void { 12 | const { containerEl } = this; 13 | const plugin: ObsidianReadwise = (this as any).plugin; 14 | containerEl.empty(); 15 | containerEl.createEl('h2', { text: 'Settings for Obsidian ← Readwise.' }); 16 | 17 | new Setting(containerEl) 18 | .setName('Readwise Access Token') 19 | .setDesc('You can get this from https://readwise.io/access_token') 20 | .addText(text => { 21 | text.setPlaceholder('token') 22 | .setValue(plugin.settings.token) 23 | .onChange((value) => { 24 | plugin.settings.token = value; 25 | plugin.saveData(plugin.settings); 26 | }); 27 | }); 28 | 29 | new Setting(containerEl) 30 | .setName('Inbox Directory') 31 | .setDesc('Where should new highlights be created?') 32 | .addText(text => { 33 | text.setPlaceholder('Inbox/') 34 | .setValue(plugin.settings.inboxDir) 35 | .onChange((value) => { 36 | plugin.settings.inboxDir = value; 37 | plugin.saveData(plugin.settings); 38 | }); 39 | }); 40 | 41 | new Setting(containerEl) 42 | .setName('References Directory') 43 | .setDesc('Directory where highlighted books will be created to reference') 44 | .addText(text => { 45 | text.setPlaceholder('References') 46 | .setValue(plugin.settings.referencesDir) 47 | .onChange((value) => { 48 | plugin.settings.referencesDir = value; 49 | plugin.saveData(plugin.settings); 50 | }); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 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 | --------------------------------------------------------------------------------