├── .nvmrc ├── .eslintignore ├── .prettierrc ├── .editorconfig ├── styles.css ├── .eslintrc ├── .gitignore ├── manifest.json ├── versions.json ├── tsconfig.json ├── .github └── FUNDING.yml ├── src ├── types.d.ts ├── koreader-metadata.ts └── main.ts ├── LICENSE ├── esbuild.config.mjs ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.2 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "tabWidth": 2 5 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | tab_width = 2 10 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* This stile hide the title of embedded notes only if the container has a frontmatter like this 2 | --- 3 | cssclass: koreader-sync 4 | --- 5 | */ 6 | .koreader-sync-dataview .markdown-embed-title, .koreader-sync-dataview h2, .koreader-sync-dataview h3 { 7 | display: none; 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "prettier" 6 | ], 7 | "extends": ["airbnb", "prettier", "plugin:node/recommended"], 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "prettier/prettier": "error" 13 | } 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # test data 22 | test/ -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-koreader-plugin", 3 | "name": "KOReader Highlights", 4 | "version": "0.6.1", 5 | "minAppVersion": "0.13.19", 6 | "description": "This is a plugin for Obsidian. This plugin syncs highlights and notes taken in KOReader.", 7 | "author": "Federico \"Edo\" Granata", 8 | "authorUrl": "https://federicogranata.dev", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.12.0", 3 | "0.0.2": "0.12.0", 4 | "0.0.3": "0.12.0", 5 | "0.0.4": "0.12.0", 6 | "0.0.5": "0.12.0", 7 | "0.0.6": "0.12.0", 8 | "0.1.0": "0.12.0", 9 | "0.1.1": "0.12.0", 10 | "0.1.2": "0.12.0", 11 | "0.2.0": "0.12.0", 12 | "0.2.1": "0.12.0", 13 | "0.2.2": "0.12.0", 14 | "0.3.0": "0.13.19", 15 | "0.4.0": "0.13.19", 16 | "0.4.1": "0.13.19", 17 | "0.5.0": "0.13.19", 18 | "0.6.0": "0.13.19", 19 | "0.6.1": "0.13.19" 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: Edo78 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/Edo78', 'https://paypal.me/FedericoEdoGranata'] 14 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface Bookmark { 2 | chapter: string; 3 | text: string; 4 | datetime: string; 5 | notes: string; 6 | highlighted: boolean; 7 | pos0: string; 8 | pos1: string; 9 | page: string; 10 | } 11 | 12 | export interface Bookmarks { 13 | [key: number]: Bookmark; 14 | } 15 | 16 | export interface Book { 17 | title: string; 18 | authors: string; 19 | bookmarks: Bookmarks; 20 | highlight: any; 21 | percent_finished: number; 22 | } 23 | 24 | export interface Books { 25 | [fullTitle: string]: Book; 26 | } 27 | 28 | export interface FrontMatterData { 29 | title: string; 30 | authors: string; 31 | chapter: string; 32 | page: number; 33 | highlight: string; 34 | datetime: string; 35 | text: string; 36 | } 37 | 38 | export interface FrontMatterMetadata { 39 | body_hash: string; 40 | keep_in_sync: boolean; 41 | yet_to_be_edited: boolean; 42 | managed_book_title: string; 43 | } 44 | 45 | export interface FrontMatter { 46 | type: string; 47 | uniqueId: string; 48 | data: FrontMatterData, 49 | metadata: FrontMatterMetadata, 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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. -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins], 45 | format: 'cjs', 46 | watch: !prod, 47 | target: 'es2016', 48 | logLevel: "info", 49 | sourcemap: prod ? false : 'inline', 50 | treeShaking: true, 51 | outfile: 'main.js', 52 | }).catch(() => process.exit(1)); 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-koreader-plugin", 3 | "version": "0.6.1", 4 | "description": "This is a plugin for Obsidian. This plugin syncs highlights and notes taken in KOReader.", 5 | "main": "main.js", 6 | "scripts": { 7 | "lint": "eslint", 8 | "dev": "node esbuild.config.mjs", 9 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" 10 | }, 11 | "keywords": [], 12 | "author": "Federico \"Edo\" Granata", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/diff": "^5.0.2", 16 | "@types/lua-json": "^1.0.1", 17 | "@types/node": "^16.11.21", 18 | "@typescript-eslint/eslint-plugin": "^5.2.0", 19 | "@typescript-eslint/parser": "^5.2.0", 20 | "builtin-modules": "^3.2.0", 21 | "esbuild": "0.13.12", 22 | "eslint": "^8.7.0", 23 | "eslint-config-airbnb": "^19.0.4", 24 | "eslint-config-node": "^4.1.0", 25 | "eslint-config-prettier": "^8.3.0", 26 | "eslint-plugin-import": "^2.25.4", 27 | "eslint-plugin-jsx-a11y": "^6.5.1", 28 | "eslint-plugin-node": "^11.1.0", 29 | "eslint-plugin-prettier": "^4.0.0", 30 | "eslint-plugin-react": "^7.28.0", 31 | "eslint-plugin-react-hooks": "^4.3.0", 32 | "obsidian": "^1.1.1", 33 | "prettier": "^2.5.1", 34 | "tslib": "2.3.1", 35 | "typescript": "4.4.4" 36 | }, 37 | "dependencies": { 38 | "diff": "^5.0.0", 39 | "eta": "^2.0.0", 40 | "gray-matter": "^4.0.3", 41 | "lua-json": "^1.0.0", 42 | "node-find-files": "^1.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/koreader-metadata.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | import finder from 'node-find-files'; 5 | import { parse } from 'lua-json'; 6 | import { Books } from './types'; 7 | 8 | export class KOReaderMetadata { 9 | koreaderBasePath: string; 10 | 11 | constructor(koreaderBasePath: string) { 12 | this.koreaderBasePath = koreaderBasePath; 13 | } 14 | 15 | public async scan(): Promise { 16 | const metadatas: any = {}; 17 | return new Promise((resolve, reject) => { 18 | const find = new finder({ 19 | rootFolder: this.koreaderBasePath, 20 | }); 21 | find.on('match', (file: string) => { 22 | const filename = path.parse(file).base; 23 | if (filename.match(/metadata\..*\.lua$/)) { 24 | const content = fs.readFileSync(file, 'utf8'); 25 | const jsonMetadata: any = parse(content); 26 | const { 27 | highlight, 28 | bookmarks, 29 | doc_props: { title }, 30 | doc_props: { authors }, 31 | percent_finished, 32 | } = jsonMetadata; 33 | if (Object.keys(highlight).length && Object.keys(bookmarks).length) { 34 | metadatas[`${title} - ${authors}`] = { 35 | title, 36 | authors, 37 | // highlight, 38 | bookmarks, 39 | percent_finished: percent_finished * 100, 40 | }; 41 | } 42 | } 43 | }); 44 | find.on('error', (err: any) => { 45 | console.log(err); 46 | reject(err); 47 | }); 48 | find.on('complete', () => { 49 | resolve(metadatas); 50 | }); 51 | find.startSearch(); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian KOReader Plugin 2 | 3 | Sync [KOReader][1] notes in your [Obsidian][2] vault. The KOReader device must be connected to the device running obsidian to let the plugin scan through it's files. 4 | 5 | In the beginning of each note there a series of YAML data knwon as Frontmatter. Those data are mainly used by the plugin itself (you can use them as shown in [dataview examples](#dataview-examples)) but messing with them will cause unexpected behaviour so use the provided [commands](#commands) to properly interact with them. 6 | 7 | When you're comfy reading your notes in obsidian think about how useful is this plugin to you and express your gratitude with a tweet or with a coffee :coffee: 8 | 9 | [![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Fintent%2Ftweet%3Ftext%3DI%2527m%2520enjoying%2520%2540Edo78%2527s%2520%2523Obsidian%2520plugin%2520to%2520sync%2520my%2520%2523KOReader%2520notes.%250AThank%2520you%2520for%2520your%2520great%2520work.%250A%250Ahttps%253A%252F%252Fgithub.com%252FEdo78%252Fobsidian-koreader-sync)](https://twitter.com/intent/tweet?text=I%27m%20enjoying%20%40Edo78%27s%20%23Obsidian%20plugin%20to%20sync%20my%20%23KOReader%20notes.%0AThank%20you%20for%20your%20great%20work.%0A%0Ahttps%3A%2F%2Fgithub.com%2FEdo78%2Fobsidian-koreader-sync) 10 | Buy Me A Coffee 11 | 12 | ## Configuration 13 | 14 | There ara four main settings: 15 | - `KOReader mounted path` that **MUST** be set correctly to the path where KOReader is mounted 16 | - `Highlights folder location` that can be let as the default `/` (or you can create a folder and select it from the dropdown) 17 | - `Keep in sync` that define if the plugin **should** keep the notes in sync with KOReader importing them again (see [sync](#sync)) 18 | - `Create a folder for each book` if you are a fan of folders enabling this setting the **new notes** will be created in a subfolder named as the book itself 19 | 20 | ### Danger Zone 21 | 22 | This area contains settings that can be useful in a very few edga cases and can be dangerous in a day to day usage. 23 | 24 | - `Enable reset of imported notes` enable a one shot execution of the [command](#commands) `Reset Sync List` 25 | 26 | ### View configuration 27 | The plugin use [Eta.js](https://eta.js.org/) as template engine to create the body of the note (the same used from the plugin [Templater](https://github.com/SilentVoid13/Templater)). 28 | The default template is pretty minimal 29 | ``` 30 | ## Title: [[<%= it.bookPath %>|<%= it.title %>]] 31 | 32 | ### by: [[<%= it.authors %>]] 33 | 34 | ### Chapter: <%= it.chapter %> 35 | 36 | Page: <%= it.page %> 37 | 38 | **==<%= it.highlight %>==** 39 | 40 | <%= it.text %> 41 | ``` 42 | In the `View settings` section you can found the the option to use a custom template. If you chose to do so you must create a `.md` file in the vault and write your template in it (I suggest to copy the default in it as a starting point) and write the path in `Template file` 43 | 44 | The template receive the following arguments: 45 | - `bookPath`: koreader/(book) How to Take Smart Notes_... {book suffix}-Sönke Ahrens 46 | - `title`: How to Take Smart Notes: One Simple Technique to Boost Writing, Learning and Thinking - for Students, Academics and Nonfiction Book Writers 47 | - `authors`: Sönke Ahrens 48 | - `chapter`: 1.1 Good Solutions are Simple – and Unexpected 49 | - `highlight`: Clance and Imes 1978; Brems et al. 1994 50 | - `text`: Clance (1978) first identified the Impostor Phenomenon in therapeutic sessions with highly successful women who attributed achievements to external factors 51 | - `datetime`: 2022-01-22 09:57:29 52 | - `page`: 19 53 | 54 | ### Book view configuration 55 | The default template is minimal but complex 56 | ~~~markdown 57 | # Title: <%= it.data.title %> 58 | 59 | 60 | ```dataviewjs 61 | const title = dv.current()['koreader-sync'].metadata.managed_title 62 | dv.pages().where(n => { 63 | return n['koreader-sync'] && n['koreader-sync'].type == 'koreader-sync-note' && n['koreader-sync'].metadata.managed_book_title == title 64 | }).sort(p => p['koreader-sync'].data.page).forEach(p => dv.paragraph(dv.fileLink(p.file.name, true), {style: 'test-css'})) 65 | ``` 66 | ~~~ 67 | The core of this template is a js [dataview embedded](#dataview-embedded) query. Don't mess with it if you don't know what you are doing (I don't because I barely know Dataview). 68 | 69 | The template receive exactly the same data you can see in the frontmatter. If it's not there you can't use it but you can create an issue asking for it. 70 | 71 | #### Dataview embedded 72 | Besides a native support for [Dataview](https://github.com/blacksmithgu/obsidian-dataview) (look at the [example](#dataview-examples)) the plugin let the user chose to automatically create a note for each book with a dataview query inside. 73 | The note is created in the same folder of the notes of the book but can be moved and renamed and Obsidian will take care of updating the links. 74 | To use this feature Dataview needs to be installed and its `Enable JavaScript Queries` must be enabled. 75 | The query itself will embed the single notes and a CSS will hide every `h2` and `h3` tags (with the default template this will hide the title, the author and the chapter). 76 | 77 | **ATTENTION**: this feature require at least Obsidian v0.13.19 but there is a glitch that sometimes show only the filename of the notes instead of their contents. Try to close the note and open it again (sorry, not my fault) 78 | 79 | ## Usage 80 | Once the plugin is configured properly you can plug the device with KOReader and click on the icon with two documents and the tooltip `Sync your KOReader highlights`. The plugin should propmplty create a single file for each note. 81 | The plugin should take care of automatically detect when you update the text of the note itself and update the frontmatter properties accordingly. 82 | 83 | ### Commands 84 | **NOTE:** if a command is suppose to set a frontmatter property equal to a certain value then it will be shown only if the open note has such property with a different value. 85 | 86 | There are five commands: 87 | - `Sync` it's the same as clicking on the plugin's icon, it's trigger the sync of the notes 88 | - `Reset Sync List` empty the list of imported notes (see [Danger Zone](#danger-zone)). Always try to retrieve the deleted notes from trash before using this command because all the rightfully discarded notes will be imported again. This command will also disable itself so you have to enable in the settings again if you wish to use it again. 89 | - `Mark this note as Edited` set the frontmatter propery `yet_to_be_edited` to `false` (see [Note editing](#note-editing)) 90 | - `Mark this note as NOT Edited` set the frontmatter propery `yet_to_be_edited` to `true` (see [Note editing](#note-editing)) 91 | - `Enable Sync for this note` set the frontmatter propery `keep_in_sync` to `true` (see [sync](#sync)) 92 | - `Disable Sync for this note` set the frontmatter propery `keep_in_sync` to `false` (see [sync](#sync)) 93 | 94 | ### Note editing 95 | When you edit a note you should avoit to change the frontmatter at all. Since version 0.6.0 the plugin itself should be able detect any changes to the note and to update: 96 | 97 | * the `yet_to_be_edited` value from `true` to `false` so the plugin know that you altered something and to avoid any loss in case of [sync](#sync) 98 | * the `text` value to mirror your edit 99 | 100 | If you want to discard your manual edits you can use the command `Mark this note as NOT Edited` and overwrite it at the next sync. 101 | If you change something beside the text itself (eg. the chapter, the title of the book, etc) you must use the `Mark this note as Edited` to made the plugin aware of your changes (this should not be necessary in future releases) 102 | 103 | It's easier/safer to use the proper [commands](#commands) instead of manually editing the frontmatter 104 | 105 | ### Sync 106 | **WARNING** Sync works by deleting a note and creating it again from KOReader. Anything added or updated (in Obsidian) will be lost _like tears in rain_. Consider yourself warned. 107 | 108 | The syncing process rely on two property defined in the frontmatter metadata: 109 | 110 | - `keep_in_sync` 111 | - `yet_to_be_edited` 112 | 113 | Both needs to be `true` for the note to be synced. 114 | 115 | `keep_in_sync` can be controlled at global level through the setting `Keep in sync` or in each note while `yet_to_be_edited` is set to `true` when the note is imported from KOReader and can only be manually changed in the note itself. 116 | 117 | The default value for `keep_in_sync` is `false` so the default behaviour is that once a note is in obsidian it will never be synced again. 118 | 119 | If you modify your notes in KOReader and want them to be synced in obsidian you have to enable the `Keep in sync` setting **OR** use the proper [commands](#commands) to change the `keep_in_sync` frontmatter of a specific note from `false` to `true` and if the `yet_to_be_edited` of that note is `true` then the note will be deleted and recreated. 120 | 121 | ## Dataview examples 122 | Thanks to the frontmatter data in each note you can use Dataview to easily query your notes 123 | 124 | ### Books 125 | ~~~markdown 126 | ```dataview 127 | list 128 | where koreader-sync 129 | group by koreader-sync.data.title 130 | ``` 131 | ~~~ 132 | 133 | ### Chapters of a specific book (with notes in them) 134 | ~~~markdown 135 | ```dataview 136 | list 137 | where koreader-sync.data.title = "How to Take Smart Notes: One Simple Technique to Boost Writing, Learning and Thinking - for Students, Academics and Nonfiction Book Writers" 138 | group by koreader-sync.data.chapter 139 | ``` 140 | ~~~ 141 | 142 | ### Notes of a specific chapter of a specific book 143 | ~~~markdown 144 | ```dataview 145 | list 146 | where koreader-sync.data.title = "How to Take Smart Notes: One Simple Technique to Boost Writing, Learning and Thinking - for Students, Academics and Nonfiction Book Writers" and koreader-sync.data.chapter = "Introduction" 147 | ``` 148 | ~~~ 149 | 150 | ### Text of notes of a specific book (without a link to the note and only where text is present) 151 | ~~~markdown 152 | ```dataview 153 | list without id koreader-sync.data.text 154 | where koreader-sync.data.title = "How to Take Smart Notes: One Simple Technique to Boost Writing, Learning and Thinking - for Students, Academics and Nonfiction Book Writers" 155 | where koreader-sync.data.text 156 | ``` 157 | ~~~ 158 | 159 | ### List of notes yet to be edited 160 | ~~~markdown 161 | ```dataview 162 | list 163 | where koreader-sync.metadata.yet_to_be_edited 164 | ``` 165 | ~~~ 166 | 167 | ### List of notes that should be kept in sync 168 | ~~~markdown 169 | ```dataview 170 | list 171 | where koreader-sync.metadata.keep_in_sync 172 | ``` 173 | ~~~ 174 | 175 | ### List of notes that will be kept in sync 176 | ~~~markdown 177 | ```dataview 178 | list 179 | where koreader-sync.metadata.keep_in_sync and koreader-sync.metadata.yet_to_be_edited 180 | ``` 181 | ~~~ 182 | 183 | [1]: https://koreader.rocks/ 184 | [2]: https://obsidian.md -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import * as eta from 'eta'; 3 | 4 | import { 5 | App, 6 | Editor, 7 | MarkdownView, 8 | Plugin, 9 | PluginSettingTab, 10 | Setting, 11 | TAbstractFile, 12 | TFile, 13 | normalizePath, 14 | Notice, 15 | } from 'obsidian'; 16 | import matter from 'gray-matter'; 17 | import * as Diff from 'diff'; 18 | import { Book, Bookmark, Books, FrontMatter } from './types'; 19 | 20 | import { KOReaderMetadata } from './koreader-metadata'; 21 | 22 | enum ErrorType { 23 | NO_PLACEHOLDER_FOUND = 'NO_PLACEHOLDER_FOUND', 24 | NO_PLACEHOLDER_NOTE_CREATED = 'NO_PLACEHOLDER_NOTE_CREATED', 25 | } 26 | 27 | enum NoteType { 28 | SINGLE_NOTE = 'koreader-sync-note', 29 | BOOK_NOTE = 'koreader-sync-dataview', 30 | } 31 | 32 | interface KOReaderSettings { 33 | koreaderBasePath: string; 34 | obsidianNoteFolder: string; 35 | noteTitleOptions: TitleOptions; 36 | bookTitleOptions: TitleOptions; 37 | keepInSync: boolean; 38 | aFolderForEachBook: boolean; 39 | customTemplate: boolean; 40 | customDataviewTemplate: boolean; 41 | templatePath?: string; 42 | dataviewTemplatePath?: string; 43 | createDataviewQuery: boolean; 44 | importedNotes: { [key: string]: boolean }; 45 | enbleResetImportedNotes: boolean; 46 | } 47 | 48 | const DEFAULT_SETTINGS: KOReaderSettings = { 49 | importedNotes: {}, 50 | enbleResetImportedNotes: false, 51 | keepInSync: false, 52 | aFolderForEachBook: false, 53 | customTemplate: false, 54 | customDataviewTemplate: false, 55 | createDataviewQuery: false, 56 | koreaderBasePath: '/media/user/KOBOeReader', 57 | obsidianNoteFolder: '/', 58 | noteTitleOptions: { 59 | maxWords: 5, 60 | maxLength: 25, 61 | }, 62 | bookTitleOptions: { 63 | maxWords: 5, 64 | maxLength: 25, 65 | prefix: '(book) ', 66 | }, 67 | }; 68 | 69 | interface TitleOptions { 70 | prefix?: string; 71 | suffix?: string; 72 | maxLength?: number; 73 | maxWords?: number; 74 | } 75 | 76 | const KOREADERKEY = 'koreader-sync'; 77 | const NOTE_TEXT_PLACEHOLDER = 'placeholder'; 78 | 79 | export default class KOReader extends Plugin { 80 | settings: KOReaderSettings; 81 | 82 | private manageTitle(title: string, options: TitleOptions = {}): string { 83 | // replace \ / and : with _ 84 | title = title.replace(/\\|\/|:/g, '_'); 85 | // replace multiple underscores with one underscore 86 | title = title.replace(/_+/g, '_'); 87 | // remove leading and trailing whitespace 88 | title = title.trim(); 89 | // remove leading and trailing underscores 90 | title = title.replace(/^_+|_+$/g, ''); 91 | // replace multiple spaces with one space 92 | title = title.replace(/\s+/g, ' '); 93 | // if options.maxLength is set, trim the title to that length and add '...' 94 | if (options.maxLength && title.length > options.maxLength) { 95 | title = `${title.substring(0, options.maxLength)}...`; 96 | } 97 | // if options.maxWords is set, trim the title to that number of words and add '...' 98 | if (options.maxWords && title.split(' ').length > options.maxWords) { 99 | title = `${title.split(' ').slice(0, options.maxWords).join(' ')}...`; 100 | } 101 | 102 | return `${options.prefix || ''}${title}${options.suffix || ''}`; 103 | } 104 | 105 | async onload() { 106 | eta.configure({ 107 | cache: true, // Make Eta cache templates 108 | autoEscape: false, 109 | }); 110 | await this.loadSettings(); 111 | 112 | // listen for note changes to update the frontmatter 113 | this.app.metadataCache.on('changed', async (file: TAbstractFile) => { 114 | try { 115 | await this.updateMetadataText(file as TFile); 116 | } catch (e) { 117 | console.error(e); 118 | new Notice(`Error updating metadata text: ${e.message}`); 119 | } 120 | }); 121 | 122 | const ribbonIconEl = this.addRibbonIcon( 123 | 'documents', 124 | 'Sync your KOReader highlights', 125 | this.importNotes.bind(this) 126 | ); 127 | 128 | this.addCommand({ 129 | id: 'obsidian-koreader-plugin-sync', 130 | name: 'Sync', 131 | callback: () => { 132 | this.importNotes(); 133 | }, 134 | }); 135 | 136 | this.addCommand({ 137 | id: 'obsidian-koreader-plugin-set-edit', 138 | name: 'Mark this note as Edited', 139 | editorCheckCallback: ( 140 | checking: boolean, 141 | editor: Editor, 142 | view: MarkdownView 143 | ) => { 144 | const propertyPath = `${[KOREADERKEY]}.metadata.yet_to_be_edited`; 145 | if (checking) { 146 | if (this.getFrontmatterProperty(propertyPath, view) === true) { 147 | return true; 148 | } 149 | return false; 150 | } 151 | this.setFrontmatterProperty(propertyPath, false, view); 152 | }, 153 | }); 154 | 155 | this.addCommand({ 156 | id: 'obsidian-koreader-plugin-clear-edit', 157 | name: 'Mark this note as NOT Edited', 158 | editorCheckCallback: ( 159 | checking: boolean, 160 | editor: Editor, 161 | view: MarkdownView 162 | ) => { 163 | const propertyPath = `${[KOREADERKEY]}.metadata.yet_to_be_edited`; 164 | if (checking) { 165 | if (this.getFrontmatterProperty(propertyPath, view) === false) { 166 | return true; 167 | } 168 | return false; 169 | } 170 | this.setFrontmatterProperty(propertyPath, true, view); 171 | }, 172 | }); 173 | 174 | this.addCommand({ 175 | id: 'obsidian-koreader-plugin-set-sync', 176 | name: 'Enable Sync for this note', 177 | editorCheckCallback: ( 178 | checking: boolean, 179 | editor: Editor, 180 | view: MarkdownView 181 | ) => { 182 | const propertyPath = `${[KOREADERKEY]}.metadata.keep_in_sync`; 183 | if (checking) { 184 | if (this.getFrontmatterProperty(propertyPath, view) === false) { 185 | return true; 186 | } 187 | return false; 188 | } 189 | this.setFrontmatterProperty(propertyPath, true, view); 190 | }, 191 | }); 192 | 193 | this.addCommand({ 194 | id: 'obsidian-koreader-plugin-clear-sync', 195 | name: 'Disable Sync for this note', 196 | editorCheckCallback: ( 197 | checking: boolean, 198 | editor: Editor, 199 | view: MarkdownView 200 | ) => { 201 | const propertyPath = `${[KOREADERKEY]}.metadata.keep_in_sync`; 202 | if (checking) { 203 | if (this.getFrontmatterProperty(propertyPath, view) === true) { 204 | return true; 205 | } 206 | return false; 207 | } 208 | this.setFrontmatterProperty(propertyPath, false, view); 209 | }, 210 | }); 211 | 212 | this.addCommand({ 213 | id: 'obsidian-koreader-plugin-reset-sync-list', 214 | name: 'Reset Sync List', 215 | checkCallback: (checking: boolean) => { 216 | if (this.settings.enbleResetImportedNotes) { 217 | if (!checking) { 218 | this.settings.importedNotes = {}; 219 | this.settings.enbleResetImportedNotes = false; 220 | this.saveSettings(); 221 | } 222 | return true; 223 | } 224 | return false; 225 | }, 226 | }); 227 | 228 | this.addSettingTab(new KoreaderSettingTab(this.app, this)); 229 | } 230 | 231 | onunload() {} 232 | 233 | async loadSettings() { 234 | this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) }; 235 | } 236 | 237 | async saveSettings() { 238 | await this.saveData(this.settings); 239 | } 240 | 241 | private async updateMetadataText(file: TFile) { 242 | const originalNote = await this.app.vault.cachedRead(file); 243 | const text = await this.extractTextFromNote(originalNote); 244 | if (text) { 245 | // new Notice(`Text extracted: ${text}`); 246 | const { data, content } = matter(originalNote, {}); 247 | const propertyPath = `${[KOREADERKEY]}.data.text`; 248 | this.setObjectProperty(data, propertyPath, text); 249 | const yetToBeEditedPropertyPath = `${[ 250 | KOREADERKEY, 251 | ]}.metadata.yet_to_be_edited`; 252 | this.setObjectProperty(data, yetToBeEditedPropertyPath, false); 253 | this.app.vault.modify(file as TFile, matter.stringify(content, data, {})); 254 | } else { 255 | // new Notice('Text extraction failed'); 256 | } 257 | } 258 | 259 | // to detect where the note's text is in the whole document 260 | // I'll create a new document with a 'placeholder' text and compare the two notes 261 | // the text added where the 'placeholder' text is removed is the new text of the note 262 | private async extractTextFromNote(note: string): Promise { 263 | const { data, content: originalContent } = matter(note, {}) as unknown as { 264 | data: { [key: string]: FrontMatter }; 265 | content: string; 266 | }; 267 | // create a new note with the same frontmatter and content created with the same template 268 | // and noteItself equal to 'placeholder' 269 | const frontMatter = data[KOREADERKEY]; 270 | // exit if it's not a koreader note 271 | if (!frontMatter || frontMatter.type !== NoteType.SINGLE_NOTE) { 272 | return; 273 | } 274 | const path = this.settings.aFolderForEachBook 275 | ? `${this.settings.obsidianNoteFolder}/${frontMatter.metadata.managed_book_title}` 276 | : this.settings.obsidianNoteFolder; 277 | // this is one of the worste things I've ever done and I'm sorry 278 | // please don't judge me, I'm going to refactor this 279 | // ideally using the same object as argument of the createNote function, 280 | // for the template and in the frontmatter 281 | let diff; 282 | try { 283 | const { 284 | content: newContent, 285 | frontmatterData, 286 | notePath, 287 | } = await this.createNote({ 288 | path, 289 | uniqueId: '', 290 | bookmark: { 291 | chapter: frontMatter.data.chapter, 292 | datetime: frontMatter.data.datetime, 293 | notes: frontMatter.data.highlight, 294 | highlighted: true, 295 | pos0: 'pos0', 296 | pos1: 'pos1', 297 | page: `${frontMatter.data.page}`, 298 | text: `Pagina ${frontMatter.data.page} ${frontMatter.data.highlight} @ ${frontMatter.data.datetime} ${NOTE_TEXT_PLACEHOLDER}`, 299 | }, 300 | managedBookTitle: frontMatter.metadata.managed_book_title, 301 | book: { 302 | title: frontMatter.data.title, 303 | authors: frontMatter.data.authors, 304 | percent_finished: 1, 305 | bookmarks: [], 306 | highlight: frontMatter.data.highlight, 307 | }, 308 | keepInSync: frontMatter.metadata.keep_in_sync, 309 | }); 310 | diff = Diff.diffTrimmedLines(originalContent, newContent); 311 | } catch (e) { 312 | console.error(e); 313 | throw new Error(ErrorType.NO_PLACEHOLDER_NOTE_CREATED); 314 | } 315 | 316 | // extract from 'diff' the new text of the note 317 | // in the array is the element before the one whit 'added' to true and 'value' is 'placeholder' 318 | const placeholderIndex = diff.findIndex( 319 | (element) => element.added && element.value === NOTE_TEXT_PLACEHOLDER 320 | ); 321 | if (placeholderIndex === -1) { 322 | throw new Error(ErrorType.NO_PLACEHOLDER_FOUND); 323 | } 324 | // the new text is the value of the element before the placeholder index 325 | const newText = diff[placeholderIndex - 1].value; 326 | // exit if the new text is the same as the text in the frontmatter 327 | if (newText === frontMatter.data.text) { 328 | return; 329 | } 330 | return newText; 331 | } 332 | 333 | private getObjectProperty(object: { [x: string]: any }, path: string) { 334 | if (path === undefined || path === null) { 335 | return object; 336 | } 337 | const parts = path.split('.'); 338 | for (let i = 0; i < parts.length; ++i) { 339 | if (object === undefined || object === null) { 340 | return undefined; 341 | } 342 | const key = parts[i]; 343 | object = object[key]; 344 | } 345 | return object; 346 | } 347 | 348 | private setObjectProperty( 349 | object: { [x: string]: any }, 350 | path: string, 351 | value: any 352 | ) { 353 | const parts = path.split('.'); 354 | const limit = parts.length - 1; 355 | for (let i = 0; i < limit; ++i) { 356 | const key = parts[i]; 357 | object = object[key] ?? (object[key] = {}); 358 | } 359 | const key = parts[limit]; 360 | object[key] = value; 361 | } 362 | 363 | setFrontmatterProperty(property: string, value: any, view: MarkdownView) { 364 | const { data, content } = matter(view.data, {}); 365 | this.setObjectProperty(data, property, value); 366 | const note = matter.stringify(content, data); 367 | view.setViewData(note, false); 368 | view.requestSave(); 369 | } 370 | 371 | getFrontmatterProperty(property: string, view: MarkdownView): any { 372 | const { data, content } = matter(view.data, {}); 373 | return this.getObjectProperty(data, property); 374 | } 375 | 376 | private async createNote(note: { 377 | path: string; 378 | uniqueId: string; 379 | bookmark: Bookmark; 380 | managedBookTitle: string; 381 | book: Book; 382 | keepInSync?: boolean; 383 | }) { 384 | const { path, uniqueId, bookmark, managedBookTitle, book, keepInSync } = 385 | note; 386 | // the page is always the first number in the bookmark's text (eg. 'Pagina 12 foo bar') 387 | const page = bookmark.text ? parseInt(bookmark.text.match(/\d+/g)[0]) : -1; 388 | const noteItself = bookmark.text 389 | ? bookmark.text.split(bookmark.datetime)[1].replace(/^\s+|\s+$/g, '') 390 | : ''; 391 | const noteTitle = noteItself 392 | ? this.manageTitle(noteItself, this.settings.noteTitleOptions) 393 | : `${this.manageTitle( 394 | bookmark.notes, 395 | this.settings.noteTitleOptions 396 | )} - ${book.authors}`; 397 | const notePath = normalizePath(`${path}/${noteTitle}`); 398 | 399 | const defaultTemplate = `## Title: [[<%= it.bookPath %>|<%= it.title %>]] 400 | 401 | ### by: [[<%= it.authors %>]] 402 | 403 | ### Chapter: <%= it.chapter %> 404 | 405 | Page: <%= it.page %> 406 | 407 | **==<%= it.highlight %>==** 408 | 409 | <%= it.text %>`; 410 | 411 | const templateFile = this.settings.customTemplate 412 | ? this.app.vault.getAbstractFileByPath(this.settings.templatePath) 413 | : null; 414 | const template = templateFile 415 | ? await this.app.vault.read(templateFile as TFile) 416 | : defaultTemplate; 417 | const bookPath = normalizePath(`${path}/${managedBookTitle}`); 418 | const content = (await eta.render(template, { 419 | bookPath, 420 | title: book.title, 421 | authors: book.authors, 422 | chapter: bookmark.chapter, 423 | highlight: bookmark.notes, 424 | text: noteItself, 425 | datetime: bookmark.datetime, 426 | page, 427 | })) as string; 428 | 429 | const frontmatterData: { [key: string]: FrontMatter } = { 430 | [KOREADERKEY]: { 431 | type: NoteType.SINGLE_NOTE, 432 | uniqueId, 433 | data: { 434 | title: book.title, 435 | authors: book.authors, 436 | chapter: bookmark.chapter, 437 | page, 438 | highlight: bookmark.notes, 439 | datetime: bookmark.datetime, 440 | text: noteItself, 441 | }, 442 | metadata: { 443 | body_hash: crypto.createHash('md5').update(content).digest('hex'), 444 | keep_in_sync: keepInSync || this.settings.keepInSync, 445 | yet_to_be_edited: true, 446 | managed_book_title: managedBookTitle, 447 | }, 448 | }, 449 | }; 450 | 451 | return { content, frontmatterData, notePath }; 452 | } 453 | 454 | async createDataviewQueryPerBook( 455 | dataview: { 456 | path: string; 457 | managedBookTitle: string; 458 | book: Book; 459 | }, 460 | updateNote?: TFile 461 | ) { 462 | const { path, book, managedBookTitle } = dataview; 463 | let { keepInSync } = this.settings; 464 | if (updateNote) { 465 | const { data, content } = matter( 466 | await this.app.vault.read(updateNote), 467 | {} 468 | ); 469 | keepInSync = data[KOREADERKEY].metadata.keep_in_sync; 470 | const yetToBeEdited = data[KOREADERKEY].metadata.yet_to_be_edited; 471 | if (!keepInSync || !yetToBeEdited) { 472 | return; 473 | } 474 | } 475 | const frontMatter = { 476 | cssclass: NoteType.BOOK_NOTE, 477 | [KOREADERKEY]: { 478 | uniqueId: crypto 479 | .createHash('md5') 480 | .update(`${book.title} - ${book.authors}}`) 481 | .digest('hex'), 482 | type: NoteType.BOOK_NOTE, 483 | data: { 484 | title: book.title, 485 | authors: book.authors, 486 | }, 487 | metadata: { 488 | percent_finished: book.percent_finished, 489 | managed_title: managedBookTitle, 490 | keep_in_sync: keepInSync, 491 | yet_to_be_edited: true, 492 | }, 493 | }, 494 | }; 495 | 496 | const defaultTemplate = `# Title: <%= it.data.title %> 497 | 498 | 499 | \`\`\`dataviewjs 500 | const title = dv.current()['koreader-sync'].metadata.managed_title 501 | dv.pages().where(n => { 502 | return n['koreader-sync'] && n['koreader-sync'].type == '${NoteType.SINGLE_NOTE}' && n['koreader-sync'].metadata.managed_book_title == title 503 | }).sort(p => p['koreader-sync'].data.page).forEach(p => dv.paragraph(dv.fileLink(p.file.name, true), {style: 'test-css'})) 504 | \`\`\` 505 | `; 506 | 507 | const templateFile = this.settings.customDataviewTemplate 508 | ? this.app.vault.getAbstractFileByPath(this.settings.dataviewTemplatePath) 509 | : null; 510 | const template = templateFile 511 | ? await this.app.vault.read(templateFile as TFile) 512 | : defaultTemplate; 513 | const content = (await eta.render( 514 | template, 515 | frontMatter[KOREADERKEY] 516 | )) as string; 517 | if (updateNote) { 518 | this.app.vault.modify(updateNote, matter.stringify(content, frontMatter)); 519 | } else { 520 | this.app.vault.create( 521 | `${path}/${managedBookTitle}.md`, 522 | matter.stringify(content, frontMatter) 523 | ); 524 | } 525 | } 526 | 527 | async importNotes() { 528 | const metadata = new KOReaderMetadata(this.settings.koreaderBasePath); 529 | const data: Books = await metadata.scan(); 530 | 531 | // create a list of notes already imported in obsidian 532 | const existingNotes: { 533 | [key: string]: { 534 | keep_in_sync: boolean; 535 | yet_to_be_edited: boolean; 536 | note: TAbstractFile; 537 | }; 538 | } = {}; 539 | this.app.vault.getMarkdownFiles().forEach((f) => { 540 | const fm = this.app.metadataCache.getFileCache(f)?.frontmatter; 541 | if (fm?.[KOREADERKEY]?.uniqueId) { 542 | existingNotes[fm[KOREADERKEY].uniqueId] = { 543 | keep_in_sync: fm[KOREADERKEY].metadata.keep_in_sync, 544 | yet_to_be_edited: fm[KOREADERKEY].metadata.yet_to_be_edited, 545 | note: f, 546 | }; 547 | } 548 | }); 549 | 550 | for (const book in data) { 551 | const managedBookTitle = `${this.manageTitle( 552 | data[book].title, 553 | this.settings.bookTitleOptions 554 | )}-${data[book].authors}`; 555 | // if the setting aFolderForEachBook is true, we add the managedBookTitle to the path specified in obsidianNoteFolder 556 | const path = this.settings.aFolderForEachBook 557 | ? `${this.settings.obsidianNoteFolder}/${managedBookTitle}` 558 | : this.settings.obsidianNoteFolder; 559 | // if aFolderForEachBook is set, create a folder for each book 560 | if (this.settings.aFolderForEachBook) { 561 | if (!this.app.vault.getAbstractFileByPath(path)) { 562 | this.app.vault.createFolder(path); 563 | } 564 | } 565 | // if createDataviewQuery is set, create a dataview query, for each book, with the book's managed title (if it doesn't exist) 566 | if (this.settings.createDataviewQuery) { 567 | this.createDataviewQueryPerBook( 568 | { 569 | path, 570 | managedBookTitle, 571 | book: data[book], 572 | }, 573 | this.app.vault.getAbstractFileByPath( 574 | `${path}/${managedBookTitle}.md` 575 | ) as TFile 576 | ); 577 | } 578 | 579 | for (const bookmark in data[book].bookmarks) { 580 | const updateNote: boolean = false; 581 | const uniqueId = crypto 582 | .createHash('md5') 583 | .update( 584 | `${data[book].title} - ${data[book].authors} - ${data[book].bookmarks[bookmark].pos0} - ${data[book].bookmarks[bookmark].pos1}` 585 | ) 586 | .digest('hex'); 587 | 588 | // if the note is not yet imported, we create it 589 | if (!Object.keys(this.settings.importedNotes).includes(uniqueId)) { 590 | if (!Object.keys(existingNotes).includes(uniqueId)) { 591 | const { content, frontmatterData, notePath } = 592 | await this.createNote({ 593 | path, 594 | uniqueId, 595 | bookmark: data[book].bookmarks[bookmark], 596 | managedBookTitle, 597 | book: data[book], 598 | keepInSync: this.settings.keepInSync, 599 | }); 600 | 601 | this.app.vault.create( 602 | `${notePath}.md`, 603 | matter.stringify(content, frontmatterData) 604 | ); 605 | } 606 | this.settings.importedNotes[uniqueId] = true; 607 | // else if the note exists and keep_in_sync is true and yet_to_be_edited is false, we update it 608 | } else if ( 609 | Object.keys(existingNotes).includes(uniqueId) && 610 | existingNotes[uniqueId].keep_in_sync && 611 | !existingNotes[uniqueId].yet_to_be_edited 612 | ) { 613 | const note = existingNotes[uniqueId].note as TFile; 614 | const { content, frontmatterData, notePath } = await this.createNote({ 615 | path, 616 | uniqueId, 617 | bookmark: data[book].bookmarks[bookmark], 618 | managedBookTitle, 619 | book: data[book], 620 | keepInSync: existingNotes[uniqueId]?.keep_in_sync, 621 | }); 622 | 623 | this.app.vault.modify( 624 | note, 625 | matter.stringify(content, frontmatterData) 626 | ); 627 | } 628 | } 629 | } 630 | await this.saveSettings(); 631 | } 632 | } 633 | 634 | class KoreaderSettingTab extends PluginSettingTab { 635 | plugin: KOReader; 636 | 637 | constructor(app: App, plugin: KOReader) { 638 | super(app, plugin); 639 | this.plugin = plugin; 640 | } 641 | 642 | display(): void { 643 | const { containerEl } = this; 644 | 645 | containerEl.empty(); 646 | 647 | containerEl.createEl('h2', { text: 'KOReader general settings' }); 648 | 649 | new Setting(containerEl) 650 | .setName('KOReader mounted path') 651 | .setDesc('Eg. /media//KOBOeReader') 652 | .addText((text) => 653 | text 654 | .setPlaceholder('Enter the path wher KOReader is mounted') 655 | .setValue(this.plugin.settings.koreaderBasePath) 656 | .onChange(async (value) => { 657 | this.plugin.settings.koreaderBasePath = value; 658 | await this.plugin.saveSettings(); 659 | }) 660 | ); 661 | 662 | new Setting(containerEl) 663 | .setName('Highlights folder location') 664 | .setDesc('Vault folder to use for writing book highlight notes') 665 | .addDropdown((dropdown) => { 666 | const { files } = this.app.vault.adapter as any; 667 | const folders = Object.keys(files).filter( 668 | (key) => files[key].type === 'folder' 669 | ); 670 | folders.forEach((val) => { 671 | dropdown.addOption(val, val); 672 | }); 673 | return dropdown 674 | .setValue(this.plugin.settings.obsidianNoteFolder) 675 | .onChange(async (value) => { 676 | this.plugin.settings.obsidianNoteFolder = value; 677 | await this.plugin.saveSettings(); 678 | }); 679 | }); 680 | 681 | new Setting(containerEl) 682 | .setName('Keep in sync') 683 | .setDesc( 684 | createFragment((frag) => { 685 | frag.appendText('Keep notes in sync with KOReader (read the '); 686 | frag.createEl( 687 | 'a', 688 | { 689 | text: 'documentation', 690 | href: 'https://github.com/Edo78/obsidian-koreader-sync#sync', 691 | }, 692 | (a) => { 693 | a.setAttr('target', '_blank'); 694 | } 695 | ); 696 | frag.appendText(')'); 697 | }) 698 | ) 699 | .addToggle((toggle) => 700 | toggle 701 | .setValue(this.plugin.settings.keepInSync) 702 | .onChange(async (value) => { 703 | this.plugin.settings.keepInSync = value; 704 | await this.plugin.saveSettings(); 705 | }) 706 | ); 707 | 708 | new Setting(containerEl) 709 | .setName('Create a folder for each book') 710 | .setDesc( 711 | 'All the notes from a book will be saved in a folder named after the book' 712 | ) 713 | .addToggle((toggle) => 714 | toggle 715 | .setValue(this.plugin.settings.aFolderForEachBook) 716 | .onChange(async (value) => { 717 | this.plugin.settings.aFolderForEachBook = value; 718 | await this.plugin.saveSettings(); 719 | }) 720 | ); 721 | 722 | containerEl.createEl('h2', { text: 'View settings' }); 723 | 724 | new Setting(containerEl) 725 | .setName('Custom template') 726 | .setDesc('Use a custom template for the notes') 727 | .addToggle((toggle) => 728 | toggle 729 | .setValue(this.plugin.settings.customTemplate) 730 | .onChange(async (value) => { 731 | this.plugin.settings.customTemplate = value; 732 | await this.plugin.saveSettings(); 733 | }) 734 | ); 735 | 736 | new Setting(containerEl) 737 | .setName('Template file') 738 | .setDesc('The template file to use. Remember to add the ".md" extension') 739 | .addText((text) => 740 | text 741 | .setPlaceholder('templates/note.md') 742 | .setValue(this.plugin.settings.templatePath) 743 | .onChange(async (value) => { 744 | this.plugin.settings.templatePath = value; 745 | await this.plugin.saveSettings(); 746 | }) 747 | ); 748 | 749 | new Setting(containerEl) 750 | .setName('Custom book template') 751 | .setDesc('Use a custom template for the dataview') 752 | .addToggle((toggle) => 753 | toggle 754 | .setValue(this.plugin.settings.customDataviewTemplate) 755 | .onChange(async (value) => { 756 | this.plugin.settings.customDataviewTemplate = value; 757 | await this.plugin.saveSettings(); 758 | }) 759 | ); 760 | 761 | new Setting(containerEl) 762 | .setName('Book template file') 763 | .setDesc('The template file to use. Remember to add the ".md" extension') 764 | .addText((text) => 765 | text 766 | .setPlaceholder('templates/template-book.md') 767 | .setValue(this.plugin.settings.dataviewTemplatePath) 768 | .onChange(async (value) => { 769 | this.plugin.settings.dataviewTemplatePath = value; 770 | await this.plugin.saveSettings(); 771 | }) 772 | ); 773 | 774 | new Setting(containerEl) 775 | .setName('Create a dataview query') 776 | .setDesc( 777 | createFragment((frag) => { 778 | frag.appendText( 779 | 'Create a note (for each book) with a dataview query (read the ' 780 | ); 781 | frag.createEl( 782 | 'a', 783 | { 784 | text: 'documentation', 785 | href: 'https://github.com/Edo78/obsidian-koreader-sync#dateview-embedded', 786 | }, 787 | (a) => { 788 | a.setAttr('target', '_blank'); 789 | } 790 | ); 791 | frag.appendText(')'); 792 | }) 793 | ) 794 | .addToggle((toggle) => 795 | toggle 796 | .setValue(this.plugin.settings.createDataviewQuery) 797 | .onChange(async (value) => { 798 | this.plugin.settings.createDataviewQuery = value; 799 | await this.plugin.saveSettings(); 800 | }) 801 | ); 802 | 803 | containerEl.createEl('h2', { text: 'Note title settings' }); 804 | 805 | new Setting(containerEl).setName('Prefix').addText((text) => 806 | text 807 | .setPlaceholder('Enter the prefix') 808 | .setValue(this.plugin.settings.noteTitleOptions.prefix) 809 | .onChange(async (value) => { 810 | this.plugin.settings.noteTitleOptions.prefix = value; 811 | await this.plugin.saveSettings(); 812 | }) 813 | ); 814 | new Setting(containerEl).setName('Suffix').addText((text) => 815 | text 816 | .setPlaceholder('Enter the suffix') 817 | .setValue(this.plugin.settings.noteTitleOptions.suffix) 818 | .onChange(async (value) => { 819 | this.plugin.settings.noteTitleOptions.suffix = value; 820 | await this.plugin.saveSettings(); 821 | }) 822 | ); 823 | new Setting(containerEl) 824 | .setName('Max words') 825 | .setDesc( 826 | 'If is longer than this number of words, it will be truncated and "..." will be appended before the optional suffix' 827 | ) 828 | .addSlider((number) => 829 | number 830 | .setDynamicTooltip() 831 | .setLimits(0, 10, 1) 832 | .setValue(this.plugin.settings.noteTitleOptions.maxWords) 833 | .onChange(async (value) => { 834 | this.plugin.settings.noteTitleOptions.maxWords = value; 835 | await this.plugin.saveSettings(); 836 | }) 837 | ); 838 | new Setting(containerEl) 839 | .setName('Max length') 840 | .setDesc( 841 | 'If is longer than this number of characters, it will be truncated and "..." will be appended before the optional suffix' 842 | ) 843 | .addSlider((number) => 844 | number 845 | .setDynamicTooltip() 846 | .setLimits(0, 50, 1) 847 | .setValue(this.plugin.settings.noteTitleOptions.maxLength) 848 | .onChange(async (value) => { 849 | this.plugin.settings.noteTitleOptions.maxLength = value; 850 | await this.plugin.saveSettings(); 851 | }) 852 | ); 853 | 854 | containerEl.createEl('h2', { text: 'Book title settings' }); 855 | 856 | new Setting(containerEl).setName('Prefix').addText((text) => 857 | text 858 | .setPlaceholder('Enter the prefix') 859 | .setValue(this.plugin.settings.bookTitleOptions.prefix) 860 | .onChange(async (value) => { 861 | this.plugin.settings.bookTitleOptions.prefix = value; 862 | await this.plugin.saveSettings(); 863 | }) 864 | ); 865 | new Setting(containerEl).setName('Suffix').addText((text) => 866 | text 867 | .setPlaceholder('Enter the suffix') 868 | .setValue(this.plugin.settings.bookTitleOptions.suffix) 869 | .onChange(async (value) => { 870 | this.plugin.settings.bookTitleOptions.suffix = value; 871 | await this.plugin.saveSettings(); 872 | }) 873 | ); 874 | new Setting(containerEl) 875 | .setName('Max words') 876 | .setDesc( 877 | 'If is longer than this number of words, it will be truncated and "..." will be appended before the optional suffix' 878 | ) 879 | .addSlider((number) => 880 | number 881 | .setDynamicTooltip() 882 | .setLimits(0, 10, 1) 883 | .setValue(this.plugin.settings.bookTitleOptions.maxWords) 884 | .onChange(async (value) => { 885 | this.plugin.settings.bookTitleOptions.maxWords = value; 886 | await this.plugin.saveSettings(); 887 | }) 888 | ); 889 | new Setting(containerEl) 890 | .setName('Max length') 891 | .setDesc( 892 | 'If is longer than this number of characters, it will be truncated and "..." will be appended before the optional suffix' 893 | ) 894 | .addSlider((number) => 895 | number 896 | .setDynamicTooltip() 897 | .setLimits(0, 50, 1) 898 | .setValue(this.plugin.settings.bookTitleOptions.maxLength) 899 | .onChange(async (value) => { 900 | this.plugin.settings.bookTitleOptions.maxLength = value; 901 | await this.plugin.saveSettings(); 902 | }) 903 | ); 904 | 905 | containerEl.createEl('h2', { text: 'DANGER ZONE' }); 906 | 907 | new Setting(containerEl) 908 | .setName('Enable reset of imported notes') 909 | .setDesc( 910 | "Enable the command to empty the list of imported notes in case you can't recover from the trash one or more notes" 911 | ) 912 | .addToggle((toggle) => 913 | toggle 914 | .setValue(this.plugin.settings.enbleResetImportedNotes) 915 | .onChange(async (value) => { 916 | this.plugin.settings.enbleResetImportedNotes = value; 917 | await this.plugin.saveSettings(); 918 | }) 919 | ); 920 | } 921 | } 922 | --------------------------------------------------------------------------------