├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── manifest.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── api │ ├── api.ts │ ├── models.ts │ └── raw_models.ts ├── authorsMapping.ts ├── date │ ├── handler.ts │ ├── index.ts │ └── interface.ts ├── fileDoc.ts ├── fileSystem │ ├── handler.ts │ ├── index.ts │ └── interface.ts ├── index.ts ├── log.ts ├── modals │ └── enterApiToken │ │ ├── enterApiTokenModalContent.svelte │ │ └── tokenModal.ts ├── obsidianTypes.ts ├── promiseQueue.ts ├── result.ts ├── settings.ts ├── settingsTab.ts ├── status.ts ├── template │ ├── index.ts │ ├── loader.ts │ ├── renderers.ts │ └── templateTypes.ts └── tokenManager.ts ├── tests ├── authorsMapping.ts ├── data │ ├── plugins │ │ └── obsidian-readwise │ │ │ └── authors.json │ └── templates │ │ ├── headers │ │ ├── Header.md │ │ └── Num Highlights.md │ │ └── highlights │ │ ├── Highlight.md │ │ ├── Missing Id.md │ │ └── Updated Field.md ├── fileDoc.ts ├── helpers.ts ├── settings.ts ├── templates.ts └── tokenManager.ts ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | 7 | [*.{ts,json,svelte}] 8 | indent_size = 4 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | on: 4 | push: 5 | branches: 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout source 13 | uses: actions/checkout@v2 14 | 15 | - name: Install NPM dependencies 16 | run: npm install 17 | 18 | - name: Lint 19 | run: npm run lint 20 | 21 | - name: Build 22 | run: npm run build 23 | env: 24 | BUILD: production 25 | 26 | - name: Test 27 | run: npm run test -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | jobs: 8 | artifacts: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: FranzDiebold/github-env-vars-action@v2 12 | - name: Checkout source 13 | uses: actions/checkout@v2 14 | 15 | - name: Install NPM dependencies 16 | run: npm install 17 | 18 | - name: Build 19 | run: npm run build 20 | env: 21 | BUILD: production 22 | 23 | - name: Cache release artifacts 24 | uses: actions/cache@v2 25 | with: 26 | path: | 27 | ./dist 28 | key: dist-${{ env.CI_REF_NAME_SLUG }}-${{ env.CI_RUN_NUMBER }} 29 | 30 | changelog: 31 | name: Update Changelog 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: FranzDiebold/github-env-vars-action@v2 35 | - uses: actions/checkout@v2 36 | with: 37 | ref: main 38 | - uses: heinrichreimer/github-changelog-generator-action@v2.2 39 | with: 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | issues: true 42 | issuesWoLabels: true 43 | pullRequests: true 44 | prWoLabels: true 45 | author: true 46 | excludeLabels: 'duplicate,question,invalid,wontfix,release' 47 | addSections: '{"documentation":{"prefix":"**Documentation Updates:**","labels":["documentation"]}}' 48 | - uses: stefanzweifel/git-auto-commit-action@v4 49 | with: 50 | commit_message: Update Changelog for ${{ env.CI_REF_NAME }} 51 | file_pattern: CHANGELOG.md 52 | 53 | release: 54 | name: Create Release 55 | runs-on: ubuntu-latest 56 | needs: [changelog, artifacts] 57 | steps: 58 | - uses: FranzDiebold/github-env-vars-action@v2 59 | - uses: actions/checkout@v2 60 | with: 61 | ref: main 62 | 63 | - name: Get Changelog Entry 64 | id: changelog_reader 65 | uses: mindsers/changelog-reader-action@v1 66 | with: 67 | version: ${{ env.CI_REF_NAME }} 68 | path: CHANGELOG.md 69 | 70 | - name: Load release binaries 71 | uses: actions/cache@v2 72 | with: 73 | path: | 74 | ./dist 75 | key: dist-${{ env.CI_REF_NAME_SLUG }}-${{ env.CI_RUN_NUMBER }} 76 | 77 | - name: Create Release 78 | uses: softprops/action-gh-release@v1 79 | with: 80 | tag_name: ${{ env.CI_REF_NAME }} 81 | name: ${{ env.CI_REF_NAME }} 82 | body: ${{ steps.changelog_reader.outputs.log_entry }} 83 | draft: false 84 | prerelease: false 85 | files: | 86 | dist/* 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm / yarn 6 | node_modules 7 | yarn.lock 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | dist/ 13 | 14 | # Development 15 | data.json 16 | readwise_token 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.0.9](https://github.com/renehernandez/obsidian-readwise/tree/0.0.9) (2021-08-27) 4 | 5 | **Implemented enhancements:** 6 | 7 | - Can I add date time to note highlight template? [\#65](https://github.com/renehernandez/obsidian-readwise/issues/65) 8 | - Expand the set of fields that can be used in highlights and headers templates [\#69](https://github.com/renehernandez/obsidian-readwise/pull/69) ([renehernandez](https://github.com/renehernandez)) 9 | 10 | **Fixed bugs:** 11 | 12 | - Sanitize the Pipe \(|\) Character in Titles [\#64](https://github.com/renehernandez/obsidian-readwise/issues/64) 13 | - Add sanitization for pipe character [\#68](https://github.com/renehernandez/obsidian-readwise/pull/68) ([renehernandez](https://github.com/renehernandez)) 14 | 15 | **Closed issues:** 16 | 17 | - Twitter threads not being synchronised [\#66](https://github.com/renehernandez/obsidian-readwise/issues/66) 18 | 19 | ## [0.0.8](https://github.com/renehernandez/obsidian-readwise/tree/0.0.8) (2021-07-07) 20 | 21 | **Implemented enhancements:** 22 | 23 | - Evaluate using the `Adapter` interface instead of `FileSystemAdapter` concrete class [\#31](https://github.com/renehernandez/obsidian-readwise/issues/31) 24 | - Mapping of authors in json [\#6](https://github.com/renehernandez/obsidian-readwise/issues/6) 25 | - Remove FileSystemAdapter concrete class usage [\#59](https://github.com/renehernandez/obsidian-readwise/pull/59) ([renehernandez](https://github.com/renehernandez)) 26 | - Initial authors mapping [\#57](https://github.com/renehernandez/obsidian-readwise/pull/57) ([renehernandez](https://github.com/renehernandez)) 27 | 28 | **Closed issues:** 29 | 30 | - Date format customization? [\#55](https://github.com/renehernandez/obsidian-readwise/issues/55) 31 | 32 | ## [0.0.7](https://github.com/renehernandez/obsidian-readwise/tree/0.0.7) (2021-05-13) 33 | 34 | **Implemented enhancements:** 35 | 36 | - Setting option to automatically sync Readwise changes on a given interval [\#33](https://github.com/renehernandez/obsidian-readwise/issues/33) 37 | 38 | **Documentation Updates:** 39 | 40 | - Mention Readwise Mirror plugin as alternative [\#50](https://github.com/renehernandez/obsidian-readwise/pull/50) ([renehernandez](https://github.com/renehernandez)) 41 | 42 | **Merged pull requests:** 43 | 44 | - Sync on interval [\#51](https://github.com/renehernandez/obsidian-readwise/pull/51) ([renehernandez](https://github.com/renehernandez)) 45 | 46 | ## [0.0.6](https://github.com/renehernandez/obsidian-readwise/tree/0.0.6) (2021-05-06) 47 | 48 | **Implemented enhancements:** 49 | 50 | - Expand metadata information available for the templates [\#42](https://github.com/renehernandez/obsidian-readwise/issues/42) 51 | - Add option to customize location where notes should be saved [\#22](https://github.com/renehernandez/obsidian-readwise/issues/22) 52 | - Add num\_highlights field to the metadata list available for header te… [\#47](https://github.com/renehernandez/obsidian-readwise/pull/47) ([renehernandez](https://github.com/renehernandez)) 53 | 54 | **Documentation Updates:** 55 | 56 | - Append author in PR sections in Changelog and releases [\#48](https://github.com/renehernandez/obsidian-readwise/pull/48) ([renehernandez](https://github.com/renehernandez)) 57 | 58 | **Merged pull requests:** 59 | 60 | - Add explicit storagepath for notes and handle URL titles [\#43](https://github.com/renehernandez/obsidian-readwise/pull/43) ([Zowie](https://github.com/Zowie)) 61 | 62 | ## [0.0.5](https://github.com/renehernandez/obsidian-readwise/tree/0.0.5) (2021-05-03) 63 | 64 | **Fixed bugs:** 65 | 66 | - Update the cache name to account for the job id [\#46](https://github.com/renehernandez/obsidian-readwise/pull/46) ([renehernandez](https://github.com/renehernandez)) 67 | 68 | ## [0.0.5-beta1](https://github.com/renehernandez/obsidian-readwise/tree/0.0.5-beta1) (2021-05-03) 69 | 70 | **Fixed bugs:** 71 | 72 | - Readwise Plugin not listed in Plugin option menu [\#44](https://github.com/renehernandez/obsidian-readwise/issues/44) 73 | 74 | **Merged pull requests:** 75 | 76 | - Restrict status bar loading to desktop mode only [\#45](https://github.com/renehernandez/obsidian-readwise/pull/45) ([renehernandez](https://github.com/renehernandez)) 77 | 78 | ## [0.0.4](https://github.com/renehernandez/obsidian-readwise/tree/0.0.4) (2021-04-21) 79 | 80 | **Implemented enhancements:** 81 | 82 | - Change plugin name to Readwise Community [\#34](https://github.com/renehernandez/obsidian-readwise/issues/34) 83 | - Change return type on `TryGet` [\#30](https://github.com/renehernandez/obsidian-readwise/issues/30) 84 | 85 | **Documentation Updates:** 86 | 87 | - Add Changelog generation for releases [\#39](https://github.com/renehernandez/obsidian-readwise/pull/39) ([renehernandez](https://github.com/renehernandez)) 88 | - README update [\#38](https://github.com/renehernandez/obsidian-readwise/pull/38) ([renehernandez](https://github.com/renehernandez)) 89 | 90 | **Merged pull requests:** 91 | 92 | - Unify `tryGet` method into `get` [\#37](https://github.com/renehernandez/obsidian-readwise/pull/37) ([renehernandez](https://github.com/renehernandez)) 93 | - Change public facing name for readwise plugin [\#36](https://github.com/renehernandez/obsidian-readwise/pull/36) ([renehernandez](https://github.com/renehernandez)) 94 | 95 | ## [0.0.3](https://github.com/renehernandez/obsidian-readwise/tree/0.0.3) (2021-04-15) 96 | 97 | **Implemented enhancements:** 98 | 99 | - Remove unnecessary casting for localStorage [\#20](https://github.com/renehernandez/obsidian-readwise/issues/20) 100 | - Replace NodeJS path api usage in `fileDoc` [\#18](https://github.com/renehernandez/obsidian-readwise/issues/18) 101 | - Support customizing the way a highlight is written in the note [\#15](https://github.com/renehernandez/obsidian-readwise/issues/15) 102 | - Handle the note field associated with a highlight [\#14](https://github.com/renehernandez/obsidian-readwise/issues/14) 103 | 104 | **Fixed bugs:** 105 | 106 | - Return proper `ObsidianReadwiseSettings` from `withData` method [\#21](https://github.com/renehernandez/obsidian-readwise/issues/21) 107 | - Check return from `localStorage` to avoid null values [\#19](https://github.com/renehernandez/obsidian-readwise/issues/19) 108 | - Fixes nunjucks autoescaping html char and incorrect path location [\#29](https://github.com/renehernandez/obsidian-readwise/pull/29) ([renehernandez](https://github.com/renehernandez)) 109 | 110 | **Merged pull requests:** 111 | 112 | - Highlight improvements [\#28](https://github.com/renehernandez/obsidian-readwise/pull/28) ([renehernandez](https://github.com/renehernandez)) 113 | - Improve name and description for custom template header [\#27](https://github.com/renehernandez/obsidian-readwise/pull/27) ([renehernandez](https://github.com/renehernandez)) 114 | - Remove NodeJS path api [\#26](https://github.com/renehernandez/obsidian-readwise/pull/26) ([renehernandez](https://github.com/renehernandez)) 115 | - Improvements in TokenManager [\#24](https://github.com/renehernandez/obsidian-readwise/pull/24) ([renehernandez](https://github.com/renehernandez)) 116 | - Split Settings into interface and Generator [\#23](https://github.com/renehernandez/obsidian-readwise/pull/23) ([renehernandez](https://github.com/renehernandez)) 117 | 118 | ## [0.0.2](https://github.com/renehernandez/obsidian-readwise/tree/0.0.2) (2021-04-09) 119 | 120 | **Implemented enhancements:** 121 | 122 | - Store LastUpdate setting field as a unix timestamp [\#4](https://github.com/renehernandez/obsidian-readwise/issues/4) 123 | - Update to better load mechanism [\#3](https://github.com/renehernandez/obsidian-readwise/issues/3) 124 | - Store API token using localStorage instead of plaintext [\#2](https://github.com/renehernandez/obsidian-readwise/issues/2) 125 | - Replace luxon library with moment.js [\#1](https://github.com/renehernandez/obsidian-readwise/issues/1) 126 | 127 | **Fixed bugs:** 128 | 129 | - Set initial timestamp to moment of plugin installation [\#13](https://github.com/renehernandez/obsidian-readwise/issues/13) 130 | 131 | **Documentation Updates:** 132 | 133 | - Explain sync process in Readme [\#9](https://github.com/renehernandez/obsidian-readwise/issues/9) 134 | 135 | **Merged pull requests:** 136 | 137 | - Set initial timestamp on plugin install [\#17](https://github.com/renehernandez/obsidian-readwise/pull/17) ([renehernandez](https://github.com/renehernandez)) 138 | - Explain how the sync process work and limitation [\#16](https://github.com/renehernandez/obsidian-readwise/pull/16) ([renehernandez](https://github.com/renehernandez)) 139 | - Switch to use localStorage to manage Readwise token [\#12](https://github.com/renehernandez/obsidian-readwise/pull/12) ([renehernandez](https://github.com/renehernandez)) 140 | - Refactor settings [\#11](https://github.com/renehernandez/obsidian-readwise/pull/11) ([renehernandez](https://github.com/renehernandez)) 141 | - Replace luxon [\#10](https://github.com/renehernandez/obsidian-readwise/pull/10) ([renehernandez](https://github.com/renehernandez)) 142 | 143 | ## [0.0.1](https://github.com/renehernandez/obsidian-readwise/tree/0.0.1) (2021-04-06) 144 | 145 | 146 | 147 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 148 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rene Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Readwise (Community Plugin) 2 | 3 | **Obsidian Readwise (Community Plugin)** is an unofficial plugin to synchronize [Readwise](https://readwise.io) highlights into your Obsidian Vault. 4 | 5 | **Note:** This plugin requires a subscription with Readwise — a paid service that makes it easy to aggregate and review all your reading data into one place. 6 | 7 | ## Features at glance 8 | 9 | - Sync highlights on Obsidian startup 10 | - Update existing notes with new highlights 11 | - Customization for note header and highlights through templating 12 | - Mappings of authors 13 | 14 | ## Usage 15 | 16 | After installation, it will ask for an [API token](https://readwise.io/access_token). This is required in order to pull the highlights from Readwise into your vault. 17 | 18 | If you don't configure the API token on installation, you can always configure it on the Settings section. 19 | 20 | **NOTE:** The token is stored using [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and it may have conflicts if the same vault were to be open on 2 different windows. 21 | 22 | ### Commands 23 | 24 | `Readwise: Sync highlights`: Will pull any new highlights from Readwise since the last time it was synced. 25 | 26 | ### Templating 27 | 28 | The plugin supports templating the header of a note and each individual highlight. Templates are only evaluated during note creation and when adding new highlights. 29 | 30 | The templating system in use is [Nunjucks](https://mozilla.github.io/nunjucks/). 31 | 32 | #### Header Template 33 | 34 | The default header template is: 35 | 36 | ```markdown 37 | - **URL:** {{ source_url }} 38 | - **Author:** {{ author }} 39 | - **Tags:** #{{ category }} 40 | - **Date:** [[{{ updated }}]] 41 | --- 42 | ``` 43 | 44 | This can be overwritten by configuring the `Custom Header Template Path` setting to the path of a different template. The available parameters for a custom header template are: 45 | 46 | - `title` 47 | - `source_url` 48 | - `author` 49 | - `category` 50 | - `updated` 51 | - `num_highlights` 52 | - `id` 53 | - `highlights_url` 54 | 55 | You can find the details of these fields in the [Readwise API docs](https://readwise.io/api_deets), under the `Books LIST` section. 56 | 57 | #### Highlight Template 58 | 59 | The default highlight template is: 60 | 61 | ```markdown 62 | {{ text }} %% highlight_id: {{ id }} %% 63 | {%- if note %} 64 | Note: {{ note }} 65 | {%- endif %} 66 | ``` 67 | 68 | This can be overwritten by configuring the `Custom Highlight Template Path` setting to the path of a different template. The available parameters for a custom highlight template are: 69 | 70 | - `text` 71 | - `note` 72 | - `id` 73 | - `location` 74 | - `book_id` 75 | - `url` 76 | - `location` 77 | - `updated` 78 | 79 | You can find the details of these fields in the [Readwise API docs](https://readwise.io/api_deets), under the `Highlight DETAIL` section. 80 | 81 | If the custom highlight template doesn't include `highlight_id: `, then this will be appended at the end of the rendered content as `%% highlight_id: %%` ( will be replaced by the actual highlight's id). 82 | 83 | **Note:** You can find examples of custom templates under [tests/data](./tests/data) folder. 84 | 85 | ### Mapping of Authors 86 | 87 | On plugin load, an `authors.json` file will be created (if not present), under the `obsidian-readwise` plugin folder (`.obsidian/plugins/obsidian-readwise`). There you can define mappings for Readwise author's field value. **This will be applied only during the creation of new notes** 88 | 89 | Example mapping: 90 | 91 | ```json 92 | { 93 | "perell.com": "David Perell", 94 | "charity.wtf": "Charity Majors", 95 | "@david_perell on Twitter": "David Perell", 96 | "@mipsytipsy on Twitter": "Charity Majors" 97 | } 98 | ``` 99 | 100 | The above mapping will be applied during the sync process for highlights from a new source (e.g a new article, book, tweets). 101 | 102 | ### Settings 103 | 104 | - `Readwise API Token`: Add/update your Readwise API token. 105 | - `Sync on Startup`: If enabled, will sync highlights from Readwise when Obsidian starts 106 | - `Sync on Interval`: If configured with a value greater than 0, it will sync highlights from Readwise every `X` hours. Useful for folks that leave their Obsidian app open all the time. 107 | - `Highlights Storage Path`: Path to location where new highlights/notes will be stored. 108 | - `Custom Header Template Path`: Path to template note that overrides how the note header is written 109 | - `Custom Highlight Template Path`: Path to template note that overrides how the highlights are written 110 | - `Disable Notifications`: Toggle for pop-up notifications 111 | 112 | ## How the sync process work 113 | 114 | The plugin will sync from Readwise only the new highlights since the last time it was executed (or since it was installed). The process works as follows: 115 | 116 | 1. Check if there is a file with the same name (it checks for notes in top level of the vault only. Issue [#22](https://github.com/renehernandez/obsidian-readwise/issues/22) tracks expanding support for customizing the location. 117 | 1. If not, it creates a new file using the template from `Custom Note Header Template` or the default template. 118 | 2. Read the content of the note, and add the highlights if they are not found. The search for highlight is based on the `highlight_id` from Readwise and not the text of the highlight. The exact match the plugin looks for is of the form `highlight_id: ` where is the actual id of the current highlight being rendered. 119 | 120 | ### Alternatives 121 | 122 | In addition to this plugin, there is also another Readwise community plugin for Obsidian named **Readwise Mirror**, which can be found at: [https://github.com/jsonMartin/readwise-mirror](https://github.com/jsonMartin/readwise-mirror). Both plugins exist for different use cases, so please read below to determine which best suits your needs. 123 | 124 | - Download the **Readwise Mirror** plugin if you want to mirror your entire Readwise Library into Obsidian and sync modifications to previous highlights 125 | - Download this plugin to import highlights (new highlights only for now) to your library with full control over the ability to modify and format your notes 126 | 127 | ## Roadmap 128 | 129 | You can check the project Roadmap [here](https://github.com/renehernandez/obsidian-readwise/projects/1) 130 | 131 | ## Installation 132 | 133 | ### From within Obsidian 134 | 135 | You can install this plugin from `Settings > Community Plugins > Readwise`. 136 | 137 | ### Manual installation 138 | 139 | Download zip archive from GitHub releases page. Extract the archive into `/.obsidian/plugins`. 140 | 141 | ### Limitations 142 | 143 | * It can only pull the most recent 1000 highlights from Readwise (should be solved eventually as part of the implementation for this issue: [issues/7](https://github.com/renehernandez/obsidian-readwise/issues/7) 144 | 145 | ### Compatibility 146 | 147 | To check for the compatibility of different versions, check [versions.json](https://github.com/renehernandez/obsidian-readwise/blob/main/versions.json). All plugin versions newer than the highest specified in the `versions.json` file should be compatible with the same Obsidian version and newer. 148 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-readwise", 3 | "name": "Readwise Community", 4 | "version": "0.0.9", 5 | "minAppVersion": "0.11.9", 6 | "description": "Sync Readwise highlights into your notes", 7 | "author": "Rene Hernandez", 8 | "authorUrl": "https://renehernandez.io", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-readwise", 3 | "version": "0.0.9", 4 | "description": "Sync Readwise highlights into your notes", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/renehernandez/obsidian-readwise" 8 | }, 9 | "main": "src/index.js", 10 | "scripts": { 11 | "build": "svelte-check && rollup --config rollup.config.js", 12 | "test": "mocha -r ts-node/register tests/*.ts", 13 | "format": "prettier --write src/**/*", 14 | "lint": "prettier --check src/**/*" 15 | }, 16 | "keywords": [ 17 | "obsidian", 18 | "readwise", 19 | "plugin" 20 | ], 21 | "author": "Rene Hernandez", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@rollup/plugin-commonjs": "^15.1.0", 25 | "@rollup/plugin-node-resolve": "^9.0.0", 26 | "@rollup/plugin-typescript": "^6.0.0", 27 | "@tsconfig/svelte": "^1.0.10", 28 | "@types/chai": "^4.2.15", 29 | "@types/mocha": "^8.2.2", 30 | "@types/node": "^14.14.2", 31 | "@types/nunjucks": "^3.1.4", 32 | "chai": "^4.3.4", 33 | "mocha": "^8.3.2", 34 | "moment": "^2.29.1", 35 | "obsidian": "^0.11.13", 36 | "prettier": "^2.2.1", 37 | "prettier-plugin-svelte": "^2.2.0", 38 | "rollup": "^2.32.1", 39 | "rollup-plugin-copy": "^3.3.0", 40 | "rollup-plugin-svelte": "^7.1.0", 41 | "standard-version": "^9.1.0", 42 | "svelte": "^3.32.2", 43 | "svelte-check": "^1.1.33", 44 | "svelte-preprocess": "^4.6.6", 45 | "ts-node": "^9.1.1", 46 | "tslib": "^2.0.3", 47 | "typescript": "^4.0.3" 48 | }, 49 | "dependencies": { 50 | "nunjucks": "^3.2.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import svelte from "rollup-plugin-svelte"; 5 | import autoPreprocess from "svelte-preprocess"; 6 | import copy from "rollup-plugin-copy"; 7 | 8 | export default { 9 | input: 'src/index.ts', 10 | output: { 11 | file: 'dist/main.js', 12 | format: 'cjs', 13 | exports: 'default', 14 | sourcemap: process.env.BUILD === "development" ? "inline" : false 15 | }, 16 | external: ['obsidian'], 17 | plugins: [ 18 | typescript({ sourceMap: process.env.BUILD === "development" }), 19 | resolve({ 20 | browser: true, 21 | dedupe: ["svelte"], 22 | }), 23 | commonjs({ 24 | include: "node_modules/**" 25 | }), 26 | svelte({ 27 | preprocess: autoPreprocess(), 28 | }), 29 | copy({ 30 | targets: [ 31 | { 32 | src: "manifest.json", 33 | dest: "dist/", 34 | }, 35 | ], 36 | }), 37 | ] 38 | }; -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "../result"; 2 | import Log from "../log"; 3 | import type { IDocument, IHighlight } from "./raw_models"; 4 | import { Document, Highlight } from "./models"; 5 | import type { IDateFactory } from "src/date"; 6 | 7 | export class ReadwiseApi { 8 | private token: string; 9 | private dateFactory: IDateFactory; 10 | 11 | constructor(token: string, factory: IDateFactory) { 12 | this.token = token; 13 | this.dateFactory = factory; 14 | } 15 | 16 | async getDocumentsWithHighlights( 17 | since?: number, 18 | to?: number 19 | ): Promise> { 20 | const documentsResult = await this.getUpdatedDocuments(since, to); 21 | if (documentsResult.isErr()) { 22 | return documentsResult.intoErr(); 23 | } 24 | 25 | const documents = documentsResult.unwrap(); 26 | 27 | const highlightsResult = await this.getNewHighlightsInDocuments( 28 | since, 29 | to 30 | ); 31 | 32 | if (highlightsResult.isErr()) { 33 | return highlightsResult.intoErr(); 34 | } 35 | 36 | const highlights = highlightsResult.unwrap(); 37 | 38 | documents.forEach((doc) => { 39 | doc.highlights = highlights.filter( 40 | (high) => high.book_id == doc.id 41 | ); 42 | doc.highlights.sort((a, b) => a.location - b.location); 43 | }); 44 | 45 | return Result.Ok(documents); 46 | } 47 | 48 | async getUpdatedDocuments( 49 | since?: number, 50 | to?: number 51 | ): Promise> { 52 | let url = `https://readwise.io/api/v2/books/`; 53 | const params = { 54 | page_size: "1000", 55 | }; 56 | 57 | if (this.isValidTimestamp(since)) { 58 | Object.assign(params, { 59 | updated__gt: this.dateFactory 60 | .createHandler(since) 61 | .utc() 62 | .format(), 63 | }); 64 | } 65 | 66 | if (this.isValidTimestamp(to)) { 67 | Object.assign(params, { 68 | updated__lt: this.dateFactory.createHandler(to).utc().format(), 69 | }); 70 | } 71 | 72 | url += "?" + new URLSearchParams(params); 73 | 74 | try { 75 | const response = await fetch(url, { 76 | headers: new Headers({ 77 | "Content-Type": "application/json", 78 | Authorization: `Token ${this.token}`, 79 | }), 80 | }); 81 | 82 | if (response.ok) { 83 | const content = await response.json(); 84 | const documents = Document.Parse( 85 | content.results as IDocument[], 86 | this.dateFactory 87 | ); 88 | if (documents.length > 0) { 89 | Log.debug( 90 | `Found ${documents.length} docs with new highlights` 91 | ); 92 | } else { 93 | Log.debug("No updated documents"); 94 | } 95 | return Result.Ok(documents); 96 | } else { 97 | Log.debug(`The documents API call at ${url} failed`); 98 | } 99 | 100 | return Result.Err(new Error(await response.text())); 101 | } catch (e) { 102 | return Result.Err(e); 103 | } 104 | } 105 | 106 | async getNewHighlightsInDocuments( 107 | since?: number, 108 | to?: number 109 | ): Promise> { 110 | let url = "https://readwise.io/api/v2/highlights/"; 111 | 112 | const params = { 113 | page_size: "1000", 114 | }; 115 | 116 | if (this.isValidTimestamp(since)) { 117 | Object.assign(params, { 118 | updated__gt: this.dateFactory 119 | .createHandler(since) 120 | .utc() 121 | .format(), 122 | }); 123 | } 124 | 125 | if (this.isValidTimestamp(to)) { 126 | Object.assign(params, { 127 | updated__lt: this.dateFactory.createHandler(to).utc().format(), 128 | }); 129 | } 130 | 131 | url += "?" + new URLSearchParams(params); 132 | 133 | Log.debug(`Requesting ${url}`); 134 | 135 | try { 136 | const response = await fetch(url, { 137 | headers: new Headers({ 138 | "Content-Type": "application/json", 139 | Authorization: `Token ${this.token}`, 140 | }), 141 | }); 142 | 143 | if (response.ok) { 144 | const content = await response.json(); 145 | const highlights = Highlight.Parse( 146 | content.results as IHighlight[] 147 | ); 148 | if (highlights.length > 0) { 149 | Log.debug(`Found ${highlights.length} new highlights`); 150 | } else { 151 | Log.debug("No new highlights found"); 152 | } 153 | return Result.Ok(highlights); 154 | } else { 155 | Log.debug(`The highlights API call at ${url} failed`); 156 | } 157 | 158 | return Result.Err(new Error(await response.text())); 159 | } catch (e) { 160 | return Result.Err(e); 161 | } 162 | } 163 | 164 | isValidTimestamp(timestamp?: number): boolean { 165 | return timestamp !== undefined && timestamp > 0; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/api/models.ts: -------------------------------------------------------------------------------- 1 | import type { IDateFactory } from "src/date"; 2 | import type { IDocument, IHighlight } from "./raw_models"; 3 | 4 | export class Document { 5 | public id: number; 6 | public title: string; 7 | public author: string; 8 | public num_highlights: number; 9 | public updated: string; 10 | public highlights_url: string; 11 | public source_url: string; 12 | public category: string; 13 | 14 | public highlights: Highlight[]; 15 | 16 | constructor(raw: IDocument, factory: IDateFactory) { 17 | this.id = raw.id; 18 | this.title = raw.title; 19 | this.author = raw.author; 20 | this.num_highlights = raw.num_highlights; 21 | this.updated = factory.createHandler(raw.updated).format("YYYY-MM-DD"); 22 | this.highlights_url = raw.highlights_url; 23 | this.source_url = raw.source_url; 24 | this.category = raw.category; 25 | } 26 | 27 | static Parse(idocs: IDocument[], factory: IDateFactory): Document[] { 28 | return Array.from(idocs).map((idoc) => new Document(idoc, factory)); 29 | } 30 | } 31 | 32 | export class Highlight { 33 | public id: number; 34 | public book_id: number; 35 | public text: string; 36 | public note: string; 37 | public url: string; 38 | public location: number; 39 | public updated: string; 40 | 41 | constructor(raw: IHighlight) { 42 | this.id = raw.id; 43 | this.book_id = raw.book_id; 44 | this.note = raw.note; 45 | this.text = raw.text; 46 | this.url = raw.url; 47 | this.location = raw.location; 48 | this.updated = raw.updated; 49 | } 50 | 51 | static Parse(ihighs: IHighlight[]): Highlight[] { 52 | return Array.from(ihighs).map((ihigh) => new Highlight(ihigh)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/api/raw_models.ts: -------------------------------------------------------------------------------- 1 | export interface IDocument { 2 | id: number; 3 | title: string; 4 | author: string; 5 | num_highlights: number; 6 | updated: string; 7 | highlights_url: string; 8 | source_url: string; 9 | category: string; 10 | } 11 | 12 | export interface IHighlight { 13 | id: number; 14 | book_id: number; 15 | text: string; 16 | note: string; 17 | url: string; 18 | location: number; 19 | updated: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/authorsMapping.ts: -------------------------------------------------------------------------------- 1 | import type { IFileSystemHandler } from "./fileSystem"; 2 | import Log from "./log"; 3 | 4 | 5 | export class AuthorsMapping { 6 | 7 | fsHandler: IFileSystemHandler; 8 | filename: string 9 | path: string 10 | 11 | constructor(filename: string, fsHandler: IFileSystemHandler) { 12 | this.filename = filename; 13 | this.fsHandler = fsHandler; 14 | } 15 | 16 | public async initialize(): Promise { 17 | this.path = this.fsHandler.normalizePath(`${this.fsHandler.pluginsDir()}/obsidian-readwise/${this.filename}`); 18 | 19 | if (!(await this.fsHandler.exists(this.path))) { 20 | Log.debug(`Creating authors mapping at ${this.path}`); 21 | await this.fsHandler.write(this.path, "{}"); 22 | } 23 | } 24 | 25 | public async load(): Promise> { 26 | let content = JSON.parse(await (this.fsHandler.read(this.path))); 27 | 28 | return new Map(Object.entries(content)); 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/date/handler.ts: -------------------------------------------------------------------------------- 1 | import type { IDateFactory, IDateHandler } from "./interface"; 2 | 3 | export class DateHandler implements IDateHandler { 4 | private moment: any; 5 | private date: any; 6 | 7 | constructor(date: any) { 8 | this.moment = window.moment; 9 | this.date = date; 10 | } 11 | 12 | fromNow(): string { 13 | return this.moment(this.date).fromNow(); 14 | } 15 | 16 | format(format?: string): string { 17 | return this.moment(this.date).format(format); 18 | } 19 | 20 | utc(): IDateHandler { 21 | return new DateHandler(this.moment(this.date).utc()); 22 | } 23 | } 24 | 25 | export class DateFactory implements IDateFactory { 26 | createHandler(date: any): IDateHandler { 27 | return new DateHandler(date); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/date/index.ts: -------------------------------------------------------------------------------- 1 | export type { IDateFactory, IDateHandler } from "./interface"; 2 | export { DateHandler, DateFactory } from "./handler"; 3 | -------------------------------------------------------------------------------- /src/date/interface.ts: -------------------------------------------------------------------------------- 1 | export interface IDateHandler { 2 | fromNow(): string; 3 | 4 | format(format?: string): string; 5 | 6 | utc(): IDateHandler; 7 | } 8 | 9 | export interface IDateFactory { 10 | createHandler(date: any): IDateHandler; 11 | } 12 | -------------------------------------------------------------------------------- /src/fileDoc.ts: -------------------------------------------------------------------------------- 1 | import type nunjucks from "nunjucks"; 2 | 3 | import type { Document } from './api/models'; 4 | import Log from "./log"; 5 | import type { IFileSystemHandler } from './fileSystem'; 6 | import type { HeaderTemplateRenderer, HighlightTemplateRenderer } from "./template"; 7 | 8 | export class FileDoc { 9 | 10 | doc: Document; 11 | headerRenderer: HeaderTemplateRenderer 12 | highlightRenderer: HighlightTemplateRenderer 13 | fsHandler: IFileSystemHandler 14 | 15 | constructor(doc: Document, header: HeaderTemplateRenderer, highlight: HighlightTemplateRenderer, handler: IFileSystemHandler) { 16 | this.doc = doc; 17 | this.headerRenderer = header; 18 | this.highlightRenderer = highlight; 19 | this.fsHandler = handler; 20 | } 21 | 22 | public async createOrUpdate(storagePath: string) { 23 | const file = this.fsHandler.normalizePath(this.preparePath(storagePath)); 24 | 25 | var content = ''; 26 | 27 | if (!(await this.fsHandler.exists(file))) { 28 | Log.debug(`Document ${file} not found. Will be created`); 29 | 30 | content = await this.headerRenderer.render(this.doc); 31 | } 32 | else { 33 | Log.debug(`Document ${file} found. Loading content and updating highlights`); 34 | content = await this.fsHandler.read(file); 35 | } 36 | 37 | this.doc.highlights.forEach(hl => { 38 | if (!content.includes(`highlight_id: ${hl.id}`)) { 39 | content += `\n${this.highlightRenderer.render(hl)}\n` 40 | } 41 | }); 42 | 43 | await this.fsHandler.write(file, content); 44 | } 45 | 46 | public preparePath(storagePath: string = ''): string { 47 | if (storagePath.length > 0 && storagePath.slice(-1) !== '/') { 48 | storagePath = storagePath + '/'; 49 | } 50 | return `${storagePath}${this.sanitizeName()}.md` 51 | } 52 | 53 | public sanitizeName(): string { 54 | console.log(`Sanitizing ${this.doc.title}`); 55 | return this.doc.title 56 | .replace(/(http[s]?\:\/\/)/, '') 57 | .replace(/(\?.*)/, '') // Remove query params 58 | .replace(/\./g, '_') 59 | .replace(/[\/\\\:\|]/g, '-') 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/fileSystem/handler.ts: -------------------------------------------------------------------------------- 1 | import * as obsidian from "obsidian"; 2 | import type { IFileSystemHandler } from "./interface"; 3 | 4 | export class FileSystemHandler implements IFileSystemHandler { 5 | adapter: obsidian.DataAdapter; 6 | vault: obsidian.Vault; 7 | 8 | constructor(vault: obsidian.Vault) { 9 | this.vault = vault; 10 | this.adapter = vault.adapter; 11 | } 12 | 13 | public normalizePath(path: string): string { 14 | return obsidian.normalizePath(path); 15 | } 16 | 17 | public async read(path: string): Promise { 18 | return await this.adapter.read(path); 19 | } 20 | 21 | public async write(path: string, data: string): Promise { 22 | await this.adapter.write(path, data); 23 | } 24 | 25 | public async exists(path: string): Promise { 26 | return await this.adapter.exists(path); 27 | } 28 | 29 | public pluginsDir(): string { 30 | return this.normalizePath(`${this.vault.configDir}/plugins`); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/fileSystem/index.ts: -------------------------------------------------------------------------------- 1 | export type { IFileSystemHandler } from "./interface"; 2 | export { FileSystemHandler } from "./handler"; 3 | -------------------------------------------------------------------------------- /src/fileSystem/interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFileSystemHandler { 2 | normalizePath(path: string): string; 3 | 4 | read(path: string): Promise; 5 | 6 | write(path: string, data: string): Promise; 7 | 8 | exists(path: string): Promise; 9 | 10 | pluginsDir(): string; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, FileSystemAdapter } from "obsidian"; 2 | import { ObsidianReadwiseSettings, ObsidianReadwiseSettingsGenerator } from './settings'; 3 | import { ObsidianReadwiseSettingsTab } from './settingsTab'; 4 | import { PluginState, StatusBar } from './status'; 5 | import { ReadwiseApi } from './api/api'; 6 | import type { Document } from './api/models'; 7 | import ReadwiseApiTokenModal from "./modals/enterApiToken/tokenModal"; 8 | import Log from "./log"; 9 | import type { Result } from "./result"; 10 | import { HeaderTemplateRenderer, HighlightTemplateRenderer } from "./template"; 11 | import { FileDoc } from "./fileDoc"; 12 | import { TokenManager } from "./tokenManager"; 13 | import { FileSystemHandler } from "./fileSystem"; 14 | import { DateFactory } from "./date"; 15 | import PromiseQueue from "./promiseQueue"; 16 | import { AuthorsMapping } from "./authorsMapping"; 17 | 18 | 19 | export default class ObsidianReadwisePlugin extends Plugin { 20 | public settings: ObsidianReadwiseSettings; 21 | public tokenManager: TokenManager; 22 | public timeoutIdSync: number; 23 | 24 | private state: PluginState = PluginState.idle; 25 | private api: ReadwiseApi; 26 | private mode: AppMode; 27 | private promiseQueue: PromiseQueue; 28 | private authorsMapping: AuthorsMapping; 29 | 30 | 31 | setState(state: PluginState) { 32 | this.state = state; 33 | this.mode.display(); 34 | } 35 | 36 | getState(): PluginState { 37 | return this.state; 38 | } 39 | 40 | async onload() { 41 | this.tokenManager = new TokenManager(); 42 | this.mode = this.app.isMobile ? new MobileMode(this) : new DesktopMode(this); 43 | this.mode.onload(); 44 | this.promiseQueue = new PromiseQueue(); 45 | 46 | await this.loadSettings(); 47 | 48 | this.setState(PluginState.idle); 49 | this.addSettingTab(new ObsidianReadwiseSettingsTab(this.app, this)); 50 | 51 | this.addCommand({ 52 | id: "sync", 53 | name: "Sync highlights", 54 | callback: () => this.promiseQueue.addTask(() => this.syncReadwise(this.settings.lastUpdate)), 55 | }); 56 | 57 | this.authorsMapping = new AuthorsMapping(this.settings.authorsMappingFilename, new FileSystemHandler(this.app.vault)); 58 | await this.authorsMapping.initialize(); 59 | 60 | if (this.settings.syncOnBoot) { 61 | await this.syncReadwise(this.settings.lastUpdate); 62 | } 63 | 64 | if (this.settings.autoSyncInterval > 0) { 65 | const now = new Date(); 66 | const diff = this.settings.autoSyncInterval - (Math.round(((now.getTime() - this.settings.lastUpdate) / 1000) / 3600 )); 67 | 68 | this.startAutoSync(diff <= 0 && !this.settings.syncOnBoot ? 0 : diff); 69 | } 70 | } 71 | 72 | async onunload() { 73 | window.clearTimeout(this.timeoutIdSync); 74 | await this.saveSettings(); 75 | } 76 | 77 | async loadSettings() { 78 | this.settings = ObsidianReadwiseSettingsGenerator.withData(await this.loadData()); 79 | } 80 | 81 | async saveSettings() { 82 | await this.saveData(this.settings); 83 | } 84 | 85 | async syncReadwise(since?: number, to?: number) { 86 | if (!(await this.initializeApi())) { 87 | return; 88 | } 89 | const documentsResults = await this.getNewHighlightsInDocuments(since, to) 90 | 91 | if (documentsResults.isErr()) { 92 | const error = documentsResults.unwrapErr(); 93 | 94 | Log.error({message: error.message, context: error}); 95 | this.mode.displayError(`Unexpected error: ${error.message}`); 96 | return 0; 97 | } 98 | 99 | const documents = documentsResults.unwrap(); 100 | 101 | if (documents.length > 0) { 102 | await this.updateNotes(documents); 103 | } 104 | 105 | this.settings.lastUpdate = Date.now(); 106 | 107 | await this.saveSettings(); 108 | 109 | this.setState(PluginState.idle); 110 | let message = documents.length > 0 111 | ? `Readwise: Synced new changes. ${documents.length} files synced` 112 | : `Readwise: Everything up-to-date`; 113 | this.mode.displayMessage(message); 114 | } 115 | 116 | async getNewHighlightsInDocuments(since?: number, to?: number): Promise> { 117 | this.setState(PluginState.checking) 118 | 119 | return await this.api.getDocumentsWithHighlights(since, to); 120 | } 121 | 122 | async updateNotes(documents: Document[]) { 123 | this.setState(PluginState.syncing) 124 | const handler = new FileSystemHandler(this.app.vault); 125 | const header = await HeaderTemplateRenderer.create(this.settings.headerTemplatePath, handler); 126 | const highlight = await HighlightTemplateRenderer.create(this.settings.highlightTemplatePath, handler); 127 | const mapping = await this.authorsMapping.load(); 128 | 129 | documents.forEach(doc => { 130 | if (mapping.has(doc.author)) { 131 | doc.author = mapping.get(doc.author); 132 | } 133 | const fileDoc = new FileDoc(doc, header, highlight, handler); 134 | 135 | fileDoc.createOrUpdate(this.settings.highlightStoragePath); 136 | }); 137 | 138 | } 139 | 140 | async initializeApi(): Promise { 141 | let token = this.tokenManager.get() 142 | 143 | if (token === null) { 144 | Log.debug("Starting Modal to ask for token") 145 | const tokenModal = new ReadwiseApiTokenModal(this.app, this.tokenManager); 146 | await tokenModal.waitForClose; 147 | 148 | token = this.tokenManager.get(); 149 | 150 | if (token === null) { 151 | alert( 152 | "Token was empty or was not provided, please configure it in the settings to sync with Readwise" 153 | ); 154 | return false; 155 | } 156 | } 157 | 158 | this.api = new ReadwiseApi(token, new DateFactory()); 159 | return true 160 | } 161 | 162 | startAutoSync(hours?: number) { 163 | this.timeoutIdSync = window.setTimeout( 164 | () => { 165 | this.promiseQueue.addTask(() => this.syncReadwise(this.settings.lastUpdate)); 166 | this.startAutoSync(); 167 | }, 168 | (hours ?? this.settings.autoSyncInterval) * 3_600_000 169 | ); 170 | } 171 | 172 | clearAutoSync(): boolean { 173 | if (this.timeoutIdSync) { 174 | window.clearTimeout(this.timeoutIdSync); 175 | return true; 176 | } 177 | return false; 178 | } 179 | } 180 | 181 | interface AppMode { 182 | onload(): void; 183 | display(): void; 184 | displayMessage(message: string): void; 185 | displayError(message: string): void; 186 | } 187 | 188 | class DesktopMode implements AppMode { 189 | private statusBar: StatusBar; 190 | private plugin: ObsidianReadwisePlugin; 191 | 192 | constructor(plugin: ObsidianReadwisePlugin) { 193 | this.plugin = plugin; 194 | } 195 | 196 | onload() { 197 | this.statusBar = new StatusBar(this.plugin.addStatusBarItem(), this.plugin, new DateFactory()); 198 | this.plugin.registerInterval( 199 | window.setInterval(() => this.statusBar.display(), 1000) 200 | ); 201 | } 202 | 203 | display(): void { 204 | this.statusBar.display(); 205 | } 206 | 207 | displayMessage(message: string): void { 208 | if (!this.plugin.settings.disableNotifications) { 209 | new Notice(message); 210 | } 211 | 212 | this.statusBar.displayMessage(message.toLowerCase(), 4 * 1000); 213 | 214 | Log.debug(message); 215 | } 216 | 217 | displayError(message: string): void { 218 | new Notice(message); 219 | 220 | this.statusBar.displayMessage(message.toLowerCase(), 0); 221 | 222 | Log.debug(message) 223 | } 224 | } 225 | 226 | 227 | class MobileMode implements AppMode { 228 | private plugin: ObsidianReadwisePlugin; 229 | 230 | constructor(plugin: ObsidianReadwisePlugin) { 231 | this.plugin = plugin; 232 | } 233 | 234 | onload() { 235 | return; 236 | } 237 | 238 | display(): void { 239 | return; 240 | } 241 | 242 | displayMessage(message: string): void { 243 | if (!this.plugin.settings.disableNotifications) { 244 | new Notice(message); 245 | } 246 | 247 | Log.debug(message); 248 | } 249 | 250 | displayError(message: string): void { 251 | new Notice(message); 252 | 253 | Log.debug(message) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | interface LogMessage { 2 | message: string 3 | context: object 4 | } 5 | 6 | export default class Log { 7 | 8 | static debug(log: string | LogMessage) { 9 | if (isComplexLog(log)) { 10 | printComplexLog("debug", log) 11 | } 12 | else { 13 | print("debug", log); 14 | } 15 | } 16 | 17 | static error(log: string | LogMessage) { 18 | if (isComplexLog(log)) { 19 | printComplexLog("error", log); 20 | } 21 | else { 22 | print("error", log); 23 | } 24 | } 25 | } 26 | 27 | function isComplexLog(log: string | LogMessage): log is LogMessage { 28 | return (log as LogMessage).message !== undefined; 29 | } 30 | 31 | function printComplexLog(prefix: string, log: LogMessage) { 32 | print(prefix, log.message); 33 | print(prefix, log.context); 34 | } 35 | 36 | function print(prefix: string, message: any) { 37 | console.log(`obsidian-readwise|${prefix}: ${message}`); 38 | } -------------------------------------------------------------------------------- /src/modals/enterApiToken/enterApiTokenModalContent.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
Readwise Token
9 |
10 | You can find the token 12 | here! 14 |
15 |
16 |
17 | 18 |
19 |
20 | 27 | -------------------------------------------------------------------------------- /src/modals/enterApiToken/tokenModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | import type { TokenManager } from "src/tokenManager"; 3 | import EnterApiTokenModalContent from "./enterApiTokenModalContent.svelte"; 4 | 5 | export default class ReadwiseApiTokenModal extends Modal { 6 | public waitForClose: Promise; 7 | private resolvePromise: () => void; 8 | private modalContent: EnterApiTokenModalContent; 9 | private tokenManager: TokenManager; 10 | 11 | constructor(app: App, tokenManager: TokenManager) { 12 | super(app); 13 | 14 | this.tokenManager = tokenManager; 15 | this.waitForClose = new Promise( 16 | (resolve) => (this.resolvePromise = resolve) 17 | ); 18 | 19 | this.titleEl.innerText = "Setup Readwise API token"; 20 | 21 | this.modalContent = new EnterApiTokenModalContent({ 22 | target: this.contentEl, 23 | props: { 24 | onSubmit: (value: string) => { 25 | this.tokenManager.upsert(value); 26 | this.close(); 27 | }, 28 | }, 29 | }); 30 | 31 | this.open(); 32 | } 33 | 34 | onClose() { 35 | super.onClose(); 36 | this.modalContent.$destroy(); 37 | this.resolvePromise(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/obsidianTypes.ts: -------------------------------------------------------------------------------- 1 | declare module "obsidian" { 2 | interface App { 3 | isMobile: boolean; 4 | } 5 | } 6 | 7 | export {} -------------------------------------------------------------------------------- /src/promiseQueue.ts: -------------------------------------------------------------------------------- 1 | export default class PromiseQueue { 2 | tasks: (() => Promise)[] = []; 3 | 4 | addTask(task: () => Promise) { 5 | this.tasks.push(task); 6 | if (this.tasks.length === 1) { 7 | this.handleTask(); 8 | } 9 | } 10 | 11 | async handleTask() { 12 | if (this.tasks.length > 0) { 13 | this.tasks[0]().finally(() => { 14 | this.tasks.shift(); 15 | this.handleTask(); 16 | }); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | export class Result { 2 | private ok?: T; 3 | private error?: E; 4 | 5 | static Capture(closure: () => T): Result { 6 | try { 7 | return Result.Ok(closure()); 8 | } catch (e) { 9 | return Result.Err(e); 10 | } 11 | } 12 | 13 | static Ok(value: T): Result { 14 | let result = new Result(); 15 | result.ok = value; 16 | return result; 17 | } 18 | 19 | static Err(err: E): Result { 20 | let result = new Result(); 21 | result.error = err; 22 | return result; 23 | } 24 | 25 | static All(...results: Result[]): Result { 26 | results.forEach(result => { 27 | if (result.isErr()) { 28 | return result.intoErr() 29 | } 30 | }); 31 | 32 | let merged: T[] = results.flatMap(result => result.unwrap()); 33 | 34 | return Result.Ok(merged); 35 | } 36 | 37 | public isOk(): boolean { 38 | return this.ok != null; 39 | } 40 | 41 | public isErr(): boolean { 42 | return this.error != null; 43 | } 44 | 45 | public unwrap(): T { 46 | if (!this.isOk()) { 47 | throw new Error("Called 'unwrap' on a Result with an error."); 48 | } 49 | 50 | return this.ok; 51 | } 52 | 53 | public unwrapErr(): E { 54 | if (!this.isErr()) { 55 | throw new Error("Called 'unwrapErr' on a Result with a value."); 56 | } 57 | 58 | return this.error; 59 | } 60 | 61 | public map(func: (ok: T) => U): Result { 62 | if (this.isOk()) { 63 | return Result.Ok(func(this.ok)); 64 | } else { 65 | return this.intoErr(); 66 | } 67 | } 68 | 69 | public unwrapOr(val: T): T { 70 | return this.isOk() ? this.ok : val; 71 | } 72 | 73 | public intoErr(): Result { 74 | return Result.Err(this.error); 75 | } 76 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export interface ObsidianReadwiseSettings { 2 | syncOnBoot: boolean; 3 | autoSyncInterval: number; 4 | lastUpdate: number; 5 | disableNotifications: boolean; 6 | headerTemplatePath: string; 7 | highlightStoragePath: string; 8 | highlightTemplatePath: string; 9 | authorsMappingFilename: string; 10 | } 11 | 12 | export class ObsidianReadwiseSettingsGenerator { 13 | 14 | static withData(data: any): ObsidianReadwiseSettings { 15 | return Object.assign({}, ObsidianReadwiseSettingsGenerator.defaultSettings(), data); 16 | } 17 | 18 | static defaultSettings(): ObsidianReadwiseSettings { 19 | return { 20 | syncOnBoot: false, 21 | autoSyncInterval: 0, 22 | disableNotifications: false, 23 | headerTemplatePath: "", 24 | highlightTemplatePath: "", 25 | highlightStoragePath: "", 26 | authorsMappingFilename: "authors.json", 27 | lastUpdate: Date.now() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/settingsTab.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, PluginSettingTab, Setting } from "obsidian"; 2 | import type ObsidianReadwisePlugin from '.'; 3 | import type { TokenManager } from "./tokenManager"; 4 | 5 | 6 | export class ObsidianReadwiseSettingsTab extends PluginSettingTab { 7 | private tokenManager: TokenManager; 8 | private plugin: ObsidianReadwisePlugin; 9 | 10 | constructor(app: App, plugin: ObsidianReadwisePlugin) { 11 | super(app, plugin); 12 | this.plugin = plugin; 13 | this.tokenManager = plugin.tokenManager; 14 | } 15 | 16 | display(): void { 17 | let {containerEl} = this; 18 | 19 | containerEl.empty(); 20 | 21 | containerEl.createEl('h2', {text: 'Readwise Community Settings'}); 22 | 23 | this.apiTokenSetting(); 24 | this.syncOnBoot(); 25 | this.syncOnInterval(); 26 | this.highlightStoragePath(); 27 | this.headerTemplatePath(); 28 | this.highlightTemplatePath(); 29 | this.notificationSettings(); 30 | } 31 | 32 | apiTokenSetting() { 33 | const desc = document.createDocumentFragment(); 34 | desc.createEl("span", null, (span) => { 35 | span.innerText = 36 | "Specify API Token to download highlights from Readwise. You can find the token "; 37 | 38 | span.createEl("a", null, (link) => { 39 | link.href = "https://readwise.io/access_token"; 40 | link.innerText = "here!"; 41 | }); 42 | }); 43 | 44 | new Setting(this.containerEl) 45 | .setName('Readwise API Token') 46 | .setDesc(desc) 47 | .addText(text => { 48 | const token = this.tokenManager.get(); 49 | 50 | if (token !== null) { 51 | text.setValue(token); 52 | } 53 | 54 | text 55 | .setPlaceholder('') 56 | .onChange(token => { 57 | this.tokenManager.upsert(token); 58 | }); 59 | }); 60 | } 61 | 62 | syncOnBoot() { 63 | new Setting(this.containerEl) 64 | .setName('Sync on Startup') 65 | .setDesc('Automatically sync updated highlights when Obsidian starts') 66 | .addToggle(toggle => toggle 67 | .setValue(this.plugin.settings.syncOnBoot) 68 | .onChange(async (value) => { 69 | this.plugin.settings.syncOnBoot = value; 70 | await this.plugin.saveSettings(); 71 | })); 72 | } 73 | 74 | highlightStoragePath() { 75 | new Setting(this.containerEl) 76 | .setName('Highlights Storage Path') 77 | .setDesc('Path to the directory used to store the notes') 78 | .addText(text => text 79 | .setValue(this.plugin.settings.highlightStoragePath) 80 | .onChange(async (value) => { 81 | this.plugin.settings.highlightStoragePath = value; 82 | await this.plugin.saveSettings(); 83 | })) 84 | } 85 | 86 | syncOnInterval() { 87 | new Setting(this.containerEl) 88 | .setName('Sync on Interval') 89 | .setDesc('Sync updated highlights on interval (hours). To disable automatic sync specify a negative value or zero (default)') 90 | .addText(text => text 91 | .setValue(String(this.plugin.settings.autoSyncInterval)) 92 | .onChange(async value => { 93 | if (!isNaN(Number(value))) { 94 | this.plugin.settings.autoSyncInterval = Number(value); 95 | await this.plugin.saveSettings(); 96 | 97 | if (this.plugin.settings.autoSyncInterval > 0) { 98 | this.plugin.clearAutoSync(); 99 | this.plugin.startAutoSync(this.plugin.settings.autoSyncInterval); 100 | new Notice( 101 | `Automatic sync enabled! Every ${this.plugin.settings.autoSyncInterval} hours.` 102 | ) 103 | } 104 | else if (this.plugin.settings.autoSyncInterval <= 0 && this.plugin.timeoutIdSync) { 105 | this.plugin.clearAutoSync(); 106 | new Notice( 107 | "Automatic sync disabled!" 108 | ) 109 | } 110 | } 111 | else { 112 | new Notice("Please specify a valid number.") 113 | } 114 | }) 115 | ); 116 | } 117 | 118 | headerTemplatePath() { 119 | new Setting(this.containerEl) 120 | .setName('Custom Header Template Path') 121 | .setDesc('Path to template note that overrides how the note header is written') 122 | .addText(text => text 123 | .setValue(this.plugin.settings.headerTemplatePath) 124 | .onChange(async (value) => { 125 | this.plugin.settings.headerTemplatePath = value; 126 | await this.plugin.saveSettings(); 127 | })); 128 | } 129 | 130 | highlightTemplatePath() { 131 | new Setting(this.containerEl) 132 | .setName('Custom Highlight Template Path') 133 | .setDesc('Path to template note that overrides how the highlights are written') 134 | .addText(text => text 135 | .setValue(this.plugin.settings.highlightTemplatePath) 136 | .onChange(async (value) => { 137 | this.plugin.settings.highlightTemplatePath = value; 138 | await this.plugin.saveSettings(); 139 | })); 140 | } 141 | 142 | notificationSettings() { 143 | new Setting(this.containerEl) 144 | .setName('Disable Notifications') 145 | .setDesc('Disable notifications for plugin operations to minimize distraction (refer to status bar for updates)') 146 | .addToggle(toggle => toggle 147 | .setValue(this.plugin.settings.disableNotifications) 148 | .onChange(async (value) => { 149 | this.plugin.settings.disableNotifications = value; 150 | await this.plugin.saveSettings(); 151 | })); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/status.ts: -------------------------------------------------------------------------------- 1 | import type ObsidianReadwisePlugin from '.'; 2 | import type { IDateFactory } from './date'; 3 | 4 | export enum PluginState { 5 | idle, 6 | syncing, 7 | checking, 8 | done, 9 | } 10 | 11 | export class StatusBar { 12 | private messages: StatusBarMessage[] = []; 13 | private currentMessage: StatusBarMessage; 14 | private lastMessageTimestamp: number; 15 | private statusBarEl: HTMLElement; 16 | private plugin: ObsidianReadwisePlugin; 17 | private dateFactory: IDateFactory; 18 | 19 | constructor(statusBarEl: HTMLElement, plugin: ObsidianReadwisePlugin, factory: IDateFactory) { 20 | this.statusBarEl = statusBarEl; 21 | this.plugin = plugin; 22 | this.dateFactory = factory; 23 | } 24 | 25 | displayMessage(message: string, timeout: number) { 26 | this.messages.push({ 27 | message: `readwise: ${message.slice(0, 100)}`, 28 | timeout: timeout, 29 | }); 30 | this.display(); 31 | } 32 | 33 | display() { 34 | if (this.messages.length > 0 && !this.currentMessage) { 35 | this.currentMessage = this.messages.shift(); 36 | this.statusBarEl.setText(this.currentMessage.message); 37 | this.lastMessageTimestamp = Date.now(); 38 | } else if (this.currentMessage) { 39 | let messageAge = Date.now() - this.lastMessageTimestamp; 40 | if (messageAge >= this.currentMessage.timeout) { 41 | this.currentMessage = null; 42 | this.lastMessageTimestamp = null; 43 | } 44 | } else { 45 | this.displayState(); 46 | } 47 | } 48 | 49 | private displayState() { 50 | let state = this.plugin.getState(); 51 | 52 | switch (state) { 53 | case PluginState.idle: 54 | this.displayFromNow(this.plugin.settings.lastUpdate); 55 | break; 56 | case PluginState.checking: 57 | this.statusBarEl.setText("readwise: checking if there are new highlights to sync"); 58 | break; 59 | case PluginState.syncing: 60 | this.statusBarEl.setText("readwise: syncing new highlights"); 61 | break; 62 | case PluginState.done: 63 | this.statusBarEl.setText("readwise: sync finished"); 64 | break; 65 | } 66 | } 67 | 68 | private displayFromNow(timestamp: number): void { 69 | if (timestamp) { 70 | let fromNow = this.dateFactory.createHandler(timestamp).fromNow(); 71 | this.statusBarEl.setText(`readwise: last update ${fromNow}..`); 72 | } else { 73 | this.statusBarEl.setText(`readwise: ready`); 74 | } 75 | } 76 | } 77 | 78 | export interface StatusBarMessage { 79 | message: string; 80 | timeout: number; 81 | } -------------------------------------------------------------------------------- /src/template/index.ts: -------------------------------------------------------------------------------- 1 | export { HighlightTemplateRenderer, HeaderTemplateRenderer } from "./renderers"; 2 | -------------------------------------------------------------------------------- /src/template/loader.ts: -------------------------------------------------------------------------------- 1 | import type { IFileSystemHandler } from "src/fileSystem"; 2 | import nunjucks from "nunjucks"; 3 | import Log from "../log"; 4 | import type { ITemplateType } from "./templateTypes"; 5 | 6 | export class TemplateLoader { 7 | private path: string; 8 | private fsHandler: IFileSystemHandler; 9 | private templateType: ITemplateType; 10 | 11 | constructor( 12 | path: string, 13 | fsHandler: IFileSystemHandler, 14 | templateType: ITemplateType 15 | ) { 16 | this.path = path; 17 | this.fsHandler = fsHandler; 18 | this.templateType = templateType; 19 | } 20 | 21 | async load(): Promise { 22 | let content = await this.selectTemplate(); 23 | 24 | let env = nunjucks.configure({ autoescape: false }); 25 | 26 | return nunjucks.compile(content, env); 27 | } 28 | 29 | async selectTemplate(): Promise { 30 | let content: string = this.templateType.defaultTemplate(); 31 | 32 | if (this.path !== null && this.path !== "") { 33 | if (!this.path.endsWith(".md")) { 34 | this.path += ".md"; 35 | } 36 | Log.debug(`Loading template content from ${this.path}`); 37 | content = await this.fsHandler.read(this.path); 38 | } else { 39 | Log.debug(`Using default ${this.templateType.type()}`); 40 | } 41 | 42 | return content; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/template/renderers.ts: -------------------------------------------------------------------------------- 1 | import type nunjucks from "nunjucks"; 2 | 3 | import type { IFileSystemHandler } from "src/fileSystem"; 4 | import type { Document, Highlight } from "../api/models"; 5 | import { TemplateLoader } from "./loader"; 6 | import { HeaderTemplateType, HighlightTemplateType } from "./templateTypes"; 7 | 8 | abstract class BaseTemplateRenderer { 9 | private fsHandler: IFileSystemHandler; 10 | protected template: nunjucks.Template; 11 | 12 | constructor(template: nunjucks.Template, handler: IFileSystemHandler) { 13 | this.template = template; 14 | this.fsHandler = handler; 15 | } 16 | 17 | abstract render(resource: T): string; 18 | } 19 | 20 | export class HeaderTemplateRenderer extends BaseTemplateRenderer { 21 | protected constructor( 22 | template: nunjucks.Template, 23 | handler: IFileSystemHandler 24 | ) { 25 | super(template, handler); 26 | } 27 | 28 | render(doc: Document): string { 29 | return this.template.render(doc); 30 | } 31 | 32 | static async create( 33 | path: string, 34 | handler: IFileSystemHandler 35 | ): Promise { 36 | let template = await new TemplateLoader( 37 | path, 38 | handler, 39 | new HeaderTemplateType() 40 | ).load(); 41 | return new HeaderTemplateRenderer(template, handler); 42 | } 43 | } 44 | 45 | export class HighlightTemplateRenderer extends BaseTemplateRenderer { 46 | protected constructor( 47 | template: nunjucks.Template, 48 | handler: IFileSystemHandler 49 | ) { 50 | super(template, handler); 51 | } 52 | 53 | render(highlight: Highlight): string { 54 | let renderedContent = this.template.render(highlight); 55 | 56 | if (!renderedContent.includes(`highlight_id: ${highlight.id}`)) { 57 | renderedContent += `%% highlight_id: ${highlight.id} %%\n`; 58 | } 59 | 60 | return renderedContent; 61 | } 62 | 63 | static async create( 64 | path: string, 65 | handler: IFileSystemHandler 66 | ): Promise { 67 | let template = await new TemplateLoader( 68 | path, 69 | handler, 70 | new HighlightTemplateType() 71 | ).load(); 72 | return new HighlightTemplateRenderer(template, handler); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/template/templateTypes.ts: -------------------------------------------------------------------------------- 1 | export interface ITemplateType { 2 | defaultTemplate(): string; 3 | 4 | type(): string; 5 | } 6 | 7 | export class HeaderTemplateType implements ITemplateType { 8 | type(): string { 9 | return "header template"; 10 | } 11 | 12 | defaultTemplate(): string { 13 | return `- **URL:** {{ source_url }} 14 | - **Author:** {{ author }} 15 | - **Tags:** #{{ category }} 16 | - **Date:** [[{{ updated }}]] 17 | --- 18 | `; 19 | } 20 | } 21 | 22 | export class HighlightTemplateType implements ITemplateType { 23 | type(): string { 24 | return "highlight template"; 25 | } 26 | 27 | defaultTemplate(): string { 28 | return `{{ text }} %% highlight_id: {{ id }} %% 29 | {%- if note %} 30 | Note: {{ note }} 31 | {%- endif %} 32 | `; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/tokenManager.ts: -------------------------------------------------------------------------------- 1 | export class TokenManager { 2 | 3 | localStorage: any; 4 | 5 | constructor(localStorage?: any) { 6 | this.localStorage = localStorage || window.localStorage; 7 | } 8 | 9 | get(): string { 10 | const token = this.localStorage.getItem('readwise_token'); 11 | 12 | if (token === null || token.length == 0) { 13 | return null; 14 | } 15 | 16 | return token; 17 | } 18 | 19 | upsert(token: string) { 20 | this.localStorage.setItem('readwise_token', token); 21 | } 22 | } -------------------------------------------------------------------------------- /tests/authorsMapping.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { assert } from "chai"; 3 | import { AuthorsMapping } from "../src/authorsMapping"; 4 | import { fileSystemHandler, resolvePathToData } from "./helpers"; 5 | 6 | describe("AuthorsMapping", () => { 7 | const handler = fileSystemHandler(); 8 | 9 | context("load", () => { 10 | it('loads the authors data into object', async () => { 11 | const authorsMapping = new AuthorsMapping('authors.json', handler); 12 | await authorsMapping.initialize(); 13 | const mapping = await authorsMapping.load(); 14 | 15 | assert.equal(mapping.get("perell.com"), "David Perell"); 16 | }); 17 | }); 18 | }); -------------------------------------------------------------------------------- /tests/data/plugins/obsidian-readwise/authors.json: -------------------------------------------------------------------------------- 1 | { 2 | "perell.com": "David Perell", 3 | "charity.wtf": "Charity Majors", 4 | "paulgraham.com": "Paul Graham", 5 | "fortelabs.co": "Tiago Forte", 6 | "danluu.com": "Dan Luu", 7 | "jamesclear.com": "James Clear", 8 | "scottyoung.com": "Scott Young", 9 | "@sama on Twitter": "Sam Altman", 10 | "@GeePawHill on Twitter": "GeePaw Hill", 11 | "@mipsytipsy on Twitter": "Charity Majors", 12 | "@RealGeneKim on Twitter": "Gene Kim", 13 | "@jbeda on Twitter": "Joe Beda", 14 | "@david_perell on Twitter": "David Perell", 15 | "@dollarsanddata on Twitter": "Nick Maggiulli", 16 | "@cgst on Twitter": "Cristian Strat" 17 | } -------------------------------------------------------------------------------- /tests/data/templates/headers/Header.md: -------------------------------------------------------------------------------- 1 | - **URL:** {{ source_url }} 2 | - **Author:** {{ author }} 3 | - **Tags:** #{{ category | title }} #Inbox 4 | - **Date:** [[{{ updated }}]] 5 | --- 6 | -------------------------------------------------------------------------------- /tests/data/templates/headers/Num Highlights.md: -------------------------------------------------------------------------------- 1 | - URL:: {{ source_url }} 2 | - Author:: {{ author }} 3 | - Tags:: #{{ category | title }} 4 | - Date:: [[{{ updated }}]] 5 | - Highlights Total:: {{ num_highlights }} 6 | --- 7 | -------------------------------------------------------------------------------- /tests/data/templates/highlights/Highlight.md: -------------------------------------------------------------------------------- 1 | {{ text }} `highlight_id: {{ id }}` %% location: {{ location }} %% 2 | {% if note -%} 3 | Note: {{ note }} 4 | {%- endif %} 5 | -------------------------------------------------------------------------------- /tests/data/templates/highlights/Missing Id.md: -------------------------------------------------------------------------------- 1 | {{ text }} %% location: {{ location }} %% 2 | {% if note -%} 3 | Note: {{ note }} 4 | {%- endif %} 5 | -------------------------------------------------------------------------------- /tests/data/templates/highlights/Updated Field.md: -------------------------------------------------------------------------------- 1 | {{ text }} `highlight_id: {{ id }}` %% location: {{ location }} %% 2 | {% if note -%} 3 | Note: {{ note }} 4 | {%- endif %} %% {{ updated }} %% 5 | -------------------------------------------------------------------------------- /tests/fileDoc.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { assert } from "chai"; 3 | import { FileDoc } from '../src/fileDoc'; 4 | import { HeaderTemplateRenderer, HighlightTemplateRenderer } from "../src/template"; 5 | import { fileSystemHandler } from "./helpers"; 6 | 7 | describe("File Doc", () => { 8 | const handler = fileSystemHandler(); 9 | 10 | context("sanitizeName", () => { 11 | var fileDoc: FileDoc; 12 | 13 | beforeEach(async () => { 14 | fileDoc = new FileDoc({ 15 | id: 1, 16 | title: "Hello_Worl'd-", 17 | author: 'Rene Hernandez', 18 | num_highlights: 2, 19 | highlights: null, 20 | source_url: '', 21 | updated: "2021-03-18", 22 | highlights_url: '', 23 | category: 'article' 24 | }, 25 | await HeaderTemplateRenderer.create(null, handler), 26 | await HighlightTemplateRenderer.create(null, handler), 27 | handler 28 | ); 29 | }); 30 | 31 | it("value is not modified", () => { 32 | assert.equal(fileDoc.sanitizeName(), "Hello_Worl'd-"); 33 | }); 34 | 35 | it("replaces :", () => { 36 | fileDoc.doc.title = "Hello: World"; 37 | 38 | assert.equal(fileDoc.sanitizeName(), 'Hello- World') 39 | }); 40 | 41 | it("replaces \\", () => { 42 | fileDoc.doc.title = "Hello 1\\ World"; 43 | 44 | assert.equal(fileDoc.sanitizeName(), 'Hello 1- World') 45 | }); 46 | 47 | it("replaces /", () => { 48 | fileDoc.doc.title = "Hello/World"; 49 | 50 | assert.equal(fileDoc.sanitizeName(), 'Hello-World') 51 | }); 52 | 53 | it("replaces |", () => { 54 | fileDoc.doc.title = "Hello|World"; 55 | 56 | assert.equal(fileDoc.sanitizeName(), 'Hello-World') 57 | }) 58 | 59 | it("replaces .", () => { 60 | fileDoc.doc.title = "Hello.World"; 61 | 62 | assert.equal(fileDoc.sanitizeName(), 'Hello_World') 63 | }) 64 | 65 | it("Removes query params, slashes and protocol from URL (http and https)", () => { 66 | fileDoc.doc.title = "https://example.com/2021-04-26/article-name-12?foo=bar&key=value"; 67 | 68 | assert.equal(fileDoc.sanitizeName(), "example_com-2021-04-26-article-name-12"); 69 | 70 | fileDoc.doc.title = "http://example.com/2021-04-26/article-name-13?foo=bar&key=value"; 71 | 72 | assert.equal(fileDoc.sanitizeName(), "example_com-2021-04-26-article-name-13"); 73 | }); 74 | }); 75 | 76 | context('filePath', () => { 77 | let fileDoc: FileDoc; 78 | 79 | beforeEach(async () => { 80 | fileDoc = new FileDoc({ 81 | id: 1, 82 | title: "Hello World", 83 | author: 'Rene Hernandez', 84 | num_highlights: 2, 85 | highlights: null, 86 | source_url: '', 87 | updated: "2021-03-18", 88 | highlights_url: '', 89 | category: 'article' 90 | }, 91 | await HeaderTemplateRenderer.create(null, handler), 92 | await HighlightTemplateRenderer.create(null, handler), 93 | handler 94 | ); 95 | }); 96 | 97 | it("generates the fileDoc path if unspecified", () => { 98 | assert.equal(fileDoc.preparePath(), "Hello World.md"); 99 | }); 100 | 101 | it("generates a specified fileDoc path", () => { 102 | assert.equal(fileDoc.preparePath('foo/bar'), "foo/bar/Hello World.md"); 103 | }); 104 | 105 | it("Handles trailing slash in a specified fileDoc path", () => { 106 | assert.equal(fileDoc.preparePath('foo/bar/'), "foo/bar/Hello World.md"); 107 | }); 108 | 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { IFileSystemHandler } from "../src/fileSystem"; 2 | import type { IDateFactory, IDateHandler } from "../src/date"; 3 | 4 | const moment = require("moment"); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | export function fileSystemHandler(): IFileSystemHandler { 9 | return { 10 | normalizePath: (path: string) => path, 11 | read: async (path: string) => { 12 | return fs.readFileSync(path).toString(); 13 | }, 14 | write: async (path: string) => {}, 15 | exists: async (path: string) => true, 16 | pluginsDir: () => resolvePathToData("plugins") 17 | } 18 | } 19 | 20 | export function resolvePathToData(filePath: string): string { 21 | return path.resolve(path.join("tests/data", filePath)); 22 | } 23 | 24 | export class TestDateHandler implements IDateHandler { 25 | private date: any; 26 | 27 | constructor(date: any) { 28 | this.date = date; 29 | } 30 | 31 | fromNow(): string { 32 | return moment(this.date).fromNow(); 33 | } 34 | 35 | format(format?: string): string { 36 | return moment(this.date).format(format); 37 | } 38 | 39 | utc(): IDateHandler { 40 | return new TestDateHandler(moment(this.date).utc()); 41 | } 42 | } 43 | 44 | export class TestDateFactory implements IDateFactory { 45 | createHandler(date: any): IDateHandler { 46 | return new TestDateHandler(date); 47 | } 48 | } -------------------------------------------------------------------------------- /tests/settings.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { assert } from "chai"; 3 | import { ObsidianReadwiseSettings, ObsidianReadwiseSettingsGenerator } from '../src/settings'; 4 | import { before } from "mocha"; 5 | 6 | describe("Settings", () => { 7 | context("defaultSettings", () => { 8 | var settings: ObsidianReadwiseSettings; 9 | 10 | before(() => { 11 | settings = ObsidianReadwiseSettingsGenerator.defaultSettings(); 12 | }); 13 | 14 | it('sets the lastUpdate to now timestamp', () => { 15 | assert.isNumber(settings.lastUpdate); 16 | assert.isAbove(settings.lastUpdate, 0); 17 | }); 18 | 19 | it('syncOnBoot is disabled', () => { 20 | assert.isFalse(settings.syncOnBoot); 21 | }); 22 | 23 | it('notifications are enabled', () => { 24 | assert.isFalse(settings.disableNotifications); 25 | }); 26 | 27 | it('header template path is empty', () => { 28 | assert.isEmpty(settings.headerTemplatePath); 29 | }); 30 | 31 | it('highlight template path is empty', () => { 32 | assert.isEmpty(settings.highlightTemplatePath); 33 | }); 34 | 35 | it('intervalSync is set to zero', () => { 36 | assert.equal(settings.autoSyncInterval, 0); 37 | }) 38 | }); 39 | 40 | context("withData", () => { 41 | var settings: ObsidianReadwiseSettings; 42 | before(() => { 43 | settings = ObsidianReadwiseSettingsGenerator.withData({ 44 | lastUpdate: 10, 45 | autoSyncInterval: 3, 46 | syncOnBoot: true, 47 | headerTemplatePath: 'Hello World', 48 | highlightTemplatePath: 'Good Bye', 49 | disableNotifications: true 50 | }); 51 | }); 52 | 53 | it('overrides the lastUpdate field', () => { 54 | assert.equal(settings.lastUpdate, 10); 55 | }); 56 | 57 | it('overrides the syncOnBoot field', () => { 58 | assert.isTrue(settings.syncOnBoot); 59 | }); 60 | 61 | it('overrides the headerTemplatePath field', () => { 62 | assert.equal(settings.headerTemplatePath, "Hello World"); 63 | }); 64 | 65 | it('overrides the highlightTemplatePath field', () => { 66 | assert.equal(settings.highlightTemplatePath, "Good Bye"); 67 | }); 68 | 69 | it('overrides the disableNotifications field', () => { 70 | assert.isTrue(settings.disableNotifications); 71 | }); 72 | 73 | it('overrides the autoSyncInterval field', () => { 74 | assert.equal(settings.autoSyncInterval, 3); 75 | }) 76 | }); 77 | }) -------------------------------------------------------------------------------- /tests/templates.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { assert } from "chai"; 3 | import { before } from "mocha"; 4 | import { HeaderTemplateRenderer, HighlightTemplateRenderer } from "../src/template"; 5 | import { fileSystemHandler, resolvePathToData, TestDateFactory } from "./helpers"; 6 | import { Document, Highlight } from "../src/api/models"; 7 | import { HeaderTemplateType, HighlightTemplateType } from "../src/template/templateTypes"; 8 | import { TemplateLoader } from "../src/template/loader"; 9 | 10 | describe("HeaderTemplateType", () => { 11 | it('verifies the default template', () => { 12 | assert.equal(new HeaderTemplateType().defaultTemplate(), `- **URL:** {{ source_url }} 13 | - **Author:** {{ author }} 14 | - **Tags:** #{{ category }} 15 | - **Date:** [[{{ updated }}]] 16 | --- 17 | `); 18 | }); 19 | }) 20 | 21 | describe("HighlightTemplateType", () => { 22 | it('verifies the default template', () => { 23 | assert.equal(new HighlightTemplateType().defaultTemplate(), `{{ text }} %% highlight_id: {{ id }} %% 24 | {%- if note %} 25 | Note: {{ note }} 26 | {%- endif %} 27 | `); 28 | }); 29 | }) 30 | 31 | describe('TemplateLoader', () => { 32 | it('loads template without the md extension', async () => { 33 | const loader = new TemplateLoader( 34 | resolvePathToData('templates/highlights/Highlight'), 35 | fileSystemHandler(), 36 | new HighlightTemplateType() 37 | ); 38 | 39 | assert.equal(await loader.selectTemplate(), `{{ text }} \`highlight_id: {{ id }}\` %% location: {{ location }} %% 40 | {% if note -%} 41 | Note: {{ note }} 42 | {%- endif %} 43 | `); 44 | }); 45 | 46 | it('loads template with the md extension', async () => { 47 | const loader = new TemplateLoader( 48 | resolvePathToData('templates/highlights/Highlight.md'), 49 | fileSystemHandler(), 50 | new HighlightTemplateType() 51 | ); 52 | 53 | assert.equal(await loader.selectTemplate(), `{{ text }} \`highlight_id: {{ id }}\` %% location: {{ location }} %% 54 | {% if note -%} 55 | Note: {{ note }} 56 | {%- endif %} 57 | `); 58 | }); 59 | }); 60 | 61 | 62 | describe("HeaderTemplateRenderer", () => { 63 | const handler = fileSystemHandler(); 64 | let templateRenderer: HeaderTemplateRenderer; 65 | let customTemplateRenderer: HeaderTemplateRenderer; 66 | 67 | before(async () => { 68 | templateRenderer = await HeaderTemplateRenderer.create(null, handler); 69 | customTemplateRenderer = await HeaderTemplateRenderer.create(resolvePathToData('templates/headers/Header'), handler); 70 | }) 71 | 72 | context('render', () => { 73 | let doc: Document; 74 | before(() => { 75 | doc = new Document({ 76 | id: 10, 77 | title: 'Welcome to my note', 78 | author: 'renehernandez.io', 79 | num_highlights: 5, 80 | updated: '2021-04-14', 81 | highlights_url: 'https://readwise.io', 82 | source_url: 'https://readwise.io', 83 | category: 'article' 84 | }, 85 | new TestDateFactory() 86 | ); 87 | }); 88 | 89 | it('renders default template with doc', async () => { 90 | assert.equal(await templateRenderer.render(doc), `- **URL:** https://readwise.io 91 | - **Author:** renehernandez.io 92 | - **Tags:** #article 93 | - **Date:** [[2021-04-14]] 94 | --- 95 | `); 96 | }); 97 | 98 | it('renders custom template with doc', async () => { 99 | assert.equal(await customTemplateRenderer.render(doc), `- **URL:** https://readwise.io 100 | - **Author:** renehernandez.io 101 | - **Tags:** #Article #Inbox 102 | - **Date:** [[2021-04-14]] 103 | --- 104 | `); 105 | }); 106 | 107 | it('renders custom template using num_highlights', async () => { 108 | customTemplateRenderer = await HeaderTemplateRenderer.create(resolvePathToData('templates/headers/Num Highlights'), handler); 109 | 110 | assert.equal(await customTemplateRenderer.render(doc), `- URL:: https://readwise.io 111 | - Author:: renehernandez.io 112 | - Tags:: #Article 113 | - Date:: [[2021-04-14]] 114 | - Highlights Total:: 5 115 | --- 116 | `); 117 | }); 118 | }); 119 | }); 120 | 121 | describe("HighlightTemplateRenderer", () => { 122 | const handler = fileSystemHandler(); 123 | let highlight: Highlight; 124 | 125 | before(() => { 126 | highlight = new Highlight({ 127 | id: 10, 128 | book_id: 5, 129 | text: "Looks important. It's super ", 130 | note: "It really looks important. Can't wait for it", 131 | url: 'https://readwise.io', 132 | location: 1, 133 | updated: "2020-04-06T12:30:52.318552Z" 134 | }); 135 | }); 136 | 137 | 138 | it('renders default highlight template', async () => { 139 | let templateRenderer = await HighlightTemplateRenderer.create(null, handler); 140 | 141 | assert.equal(await templateRenderer.render(highlight), `Looks important. It's super %% highlight_id: 10 %% 142 | Note: It really looks important. Can't wait for it 143 | `); 144 | }); 145 | 146 | it('renders highlight template passed as parameter', async () => { 147 | let templateRenderer = await HighlightTemplateRenderer.create(resolvePathToData('templates/highlights/Highlight'), handler); 148 | 149 | assert.equal(await templateRenderer.render(highlight), `Looks important. It's super \`highlight_id: 10\` %% location: 1 %% 150 | Note: It really looks important. Can't wait for it 151 | `); 152 | }); 153 | 154 | it('adds highlight_id if not present on template', async () => { 155 | let templateRenderer = await HighlightTemplateRenderer.create(resolvePathToData('templates/highlights/Missing Id'), handler); 156 | 157 | assert.equal(await templateRenderer.render(highlight), `Looks important. It's super %% location: 1 %% 158 | Note: It really looks important. Can't wait for it 159 | %% highlight_id: 10 %% 160 | `); 161 | }); 162 | 163 | it('prints the updated field as part of the highlight', async () => { 164 | let templateRenderer = await HighlightTemplateRenderer.create(resolvePathToData('templates/highlights/Updated Field'), handler); 165 | 166 | assert.equal(await templateRenderer.render(highlight), `Looks important. It's super \`highlight_id: 10\` %% location: 1 %% 167 | Note: It really looks important. Can't wait for it %% 2020-04-06T12:30:52.318552Z %% 168 | `); 169 | }); 170 | }); -------------------------------------------------------------------------------- /tests/tokenManager.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { assert } from "chai"; 3 | import { TokenManager } from '../src/tokenManager'; 4 | import { before } from "mocha"; 5 | 6 | class LocalStorageMock { 7 | returnsValue: boolean; 8 | constructor(returnsValue: boolean) { 9 | this.returnsValue = returnsValue; 10 | } 11 | 12 | getItem(name: string): string { 13 | if (this.returnsValue) { 14 | return "Hello World" 15 | } 16 | 17 | return null; 18 | } 19 | } 20 | 21 | describe("TokenManager", () => { 22 | var tokenManager: TokenManager; 23 | 24 | context('get returning value', () => { 25 | before(() => { 26 | tokenManager = new TokenManager(new LocalStorageMock(true)); 27 | }); 28 | 29 | it('it returns the stored value', () => { 30 | const token = tokenManager.get() 31 | 32 | assert.equal(token, "Hello World"); 33 | }); 34 | }); 35 | 36 | context('get returning null', () => { 37 | before(() => { 38 | tokenManager = new TokenManager(new LocalStorageMock(false)); 39 | }); 40 | 41 | it('it returns the null', () => { 42 | const token = tokenManager.get() 43 | 44 | assert.equal(token, null); 45 | }); 46 | }); 47 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "target": "es6", 6 | "allowJs": true, 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "importHelpers": true, 10 | "lib": [ 11 | "dom", 12 | "es6", 13 | "scripthost", 14 | "es2019" 15 | ], 16 | "types": ["node", "svelte"] 17 | }, 18 | "include": [ 19 | "src/**/*.ts" 20 | ], 21 | "exclude": ["node_modules/*"] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.11.9" 3 | } 4 | --------------------------------------------------------------------------------