├── .npmrc ├── .eslintignore ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── const ├── frontmatter.ts ├── goodreads.ts ├── rssParser.ts └── settings.ts ├── .prettierrc ├── styles.css ├── .editorconfig ├── manifest.json ├── jest.config.js ├── .gitignore ├── tsconfig.json ├── src ├── Body.ts ├── settings │ ├── suggesters │ │ ├── FolderSuggester.ts │ │ └── suggest.ts │ └── Settings.ts ├── helpers.ts ├── Frontmatter.ts ├── Shelf.ts └── Book.ts ├── versions.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE.md ├── esbuild.config.mjs ├── main.ts ├── README.md └── test └── BookTest.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: michabrugger 2 | -------------------------------------------------------------------------------- /const/frontmatter.ts: -------------------------------------------------------------------------------- 1 | export const FRONTMATTER_LINES = "---"; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": false, 6 | "tabWidth": 4 7 | } 8 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .booksidian-plugin__settings .search-input-container { 2 | width: 100%; 3 | } 4 | 5 | .booksidian-plugin__settings input, textarea, select { 6 | min-width: 200px; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "booksidian-plugin", 3 | "name": "Booksidian", 4 | "version": "0.10.0", 5 | "minAppVersion": "0.12.0", 6 | "description": "Connect Obsidian to your Goodreads.", 7 | "author": "Micha Brugger and Zachary Wright", 8 | "authorUrl": "https://github.com/MichaBrugger", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: {'^.+\\.ts?$': 'ts-jest'}, 3 | testEnvironment: 'node', 4 | testRegex: 'test/.*Test.ts', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | moduleDirectories: ['node_modules', 'src', 'const', 'test'], 7 | modulePaths: [''], 8 | moduleNameMapper: { 9 | "^@/(.*)$": "/src/" 10 | } 11 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | .devcontainer 4 | 5 | # Intellij 6 | *.iml 7 | .idea 8 | 9 | # npm 10 | node_modules 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021.String"] 14 | }, 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /src/Body.ts: -------------------------------------------------------------------------------- 1 | import { Book } from "src/Book"; 2 | 3 | // Following rssParser example to avoid issue with: import * as Mustache from 'mustache'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const Mustache = require("mustache"); 7 | 8 | export class Body { 9 | constructor( 10 | public currentBody: string, 11 | public book: Book, 12 | ) {} 13 | 14 | public getBody(): string { 15 | const render = Mustache.render(this.currentBody, this.book) as string; 16 | 17 | return render.replaceAll("/", "/"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.12.0", 3 | "0.1.1": "0.12.0", 4 | "0.1.2": "0.12.0", 5 | "0.1.3": "0.12.0", 6 | "0.2.0": "0.12.0", 7 | "0.3.0": "0.12.0", 8 | "0.3.1": "0.12.0", 9 | "0.3.2": "0.12.0", 10 | "0.3.3": "0.12.0", 11 | "0.3.4": "0.12.0", 12 | "0.3.5": "0.12.0", 13 | "0.3.6": "0.12.0", 14 | "0.3.7": "0.12.0", 15 | "0.4.0": "0.12.0", 16 | "0.4.1": "0.12.0", 17 | "0.5.0": "0.12.0", 18 | "0.5.1": "0.12.0", 19 | "0.5.2": "0.12.0", 20 | "0.6.0": "0.12.0", 21 | "0.6.1": "0.12.0", 22 | "0.7.0": "0.12.0", 23 | "0.8.0": "0.12.0", 24 | "0.8.1": "0.12.0", 25 | "0.9.0": "0.12.0", 26 | "0.9.1": "0.12.0", 27 | "0.9.2": "0.12.0", 28 | "0.9.3": "0.12.0", 29 | "0.10.0": "0.12.0" 30 | } -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /const/goodreads.ts: -------------------------------------------------------------------------------- 1 | export interface GoodreadsBook { 2 | author: string; 3 | title: string; 4 | link: string; 5 | pubDate: string; 6 | isbn: string; 7 | user_review: string | undefined; 8 | book_description: string; 9 | user_rating: string; 10 | average_rating: string; 11 | user_read_at: string; 12 | user_date_added: string; 13 | user_date_created: string; 14 | book_published: string; 15 | identifiers: Identifiers; 16 | content: string; 17 | contentSnippet: string; 18 | guid: string; 19 | user_shelves: string; 20 | image_url: string; 21 | image_path: string; 22 | } 23 | 24 | export interface Identifiers { 25 | $: Book_id; 26 | num_pages: string[]; 27 | } 28 | 29 | export interface Book_id { 30 | id: string; 31 | } 32 | -------------------------------------------------------------------------------- /const/rssParser.ts: -------------------------------------------------------------------------------- 1 | // there seems to be an issue with "import Parser from 'rss-parser';" 2 | // I've decided to stick with the current way, even though it's not ideal 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const Parser = require("rss-parser"); 6 | 7 | // making small changes to the returned keys 8 | export const rssParser = new Parser({ 9 | customFields: { 10 | item: [ 11 | ["author_name", "author"], 12 | "isbn", 13 | "user_rating", 14 | "user_review", 15 | "book_description", 16 | "average_rating", 17 | "user_read_at", 18 | "user_date_added", 19 | "user_date_created", 20 | "book_published", 21 | ["book", "identifiers"], 22 | "user_shelves", 23 | ["book_large_image_url", "image_url"], 24 | ], 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /const/settings.ts: -------------------------------------------------------------------------------- 1 | export interface BooksidianSettings { 2 | targetFolderPath: string; 3 | goodreadsBaseUrl: string; 4 | goodreadsShelves: string; 5 | fileName: string; 6 | frontmatterDictionary: CurrentYAML; 7 | bodyString: string; 8 | frequency: string; 9 | overwrite: boolean; 10 | coverDownload: boolean; 11 | coverDownloadLocation: string; 12 | } 13 | 14 | export interface CurrentYAML { 15 | [key: string]: string; 16 | } 17 | 18 | export const DEFAULT_SETTINGS: BooksidianSettings = { 19 | targetFolderPath: "", 20 | fileName: "{{title}}", 21 | goodreadsBaseUrl: "https://www.goodreads.com/review/list_rss/...", 22 | goodreadsShelves: "currently-reading", 23 | frontmatterDictionary: {}, 24 | bodyString: "# {{title}}\n\nauthor::[[{{author}}]]", 25 | frequency: "0", // manual 26 | overwrite: false, 27 | coverDownload: false, 28 | coverDownloadLocation: "", 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "booksidian", 3 | "author": "Micha Brugger", 4 | "version": "0.10.0", 5 | "description": "Connect Obsidian to your Goodreads.", 6 | "main": "main.js", 7 | "scripts": { 8 | "dev": "node esbuild.config.mjs", 9 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@popperjs/core": "^2.11.8", 16 | "@types/jest": "^29.4.0", 17 | "@types/mustache": "^4.2.2", 18 | "@types/node": "^16.11.6", 19 | "@typescript-eslint/eslint-plugin": "^5.2.0", 20 | "@typescript-eslint/parser": "^5.2.0", 21 | "builtin-modules": "^3.2.0", 22 | "esbuild": "^0.13.12", 23 | "jest": "^29.4.0", 24 | "obsidian": "^1.5.7-1", 25 | "prettier": "^3.2.5", 26 | "tslib": "^2.3.1", 27 | "typescript": "^4.4.4" 28 | }, 29 | "dependencies": { 30 | "js-yaml": "^4.1.0", 31 | "mustache": "^4.2.0", 32 | "rss-parser": "^3.12.0", 33 | "ts-jest": "^29.0.5", 34 | "turndown": "^7.1.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MichaBrugger 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. 22 | -------------------------------------------------------------------------------- /src/settings/suggesters/FolderSuggester.ts: -------------------------------------------------------------------------------- 1 | // copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/FolderSuggester.ts 2 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 3 | 4 | import { TAbstractFile, TFolder } from "obsidian"; 5 | import { TextInputSuggest } from "./suggest"; 6 | 7 | export class FolderSuggest extends TextInputSuggest { 8 | getSuggestions(inputStr: string): TFolder[] { 9 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 10 | const folders: TFolder[] = []; 11 | const lowerCaseInputStr = inputStr.toLowerCase(); 12 | 13 | abstractFiles.forEach((folder: TAbstractFile) => { 14 | if ( 15 | folder instanceof TFolder && 16 | folder.path.toLowerCase().contains(lowerCaseInputStr) 17 | ) { 18 | folders.push(folder); 19 | } 20 | }); 21 | 22 | return folders; 23 | } 24 | 25 | renderSuggestion(file: TFolder, el: HTMLElement): void { 26 | el.setText(file.path); 27 | } 28 | 29 | selectSuggestion(file: TFolder): void { 30 | this.inputEl.value = file.path; 31 | this.inputEl.trigger("input"); 32 | this.close(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, dirname } from "path"; 2 | import * as nodeFs from "fs"; 3 | import { App } from "obsidian"; 4 | 5 | export async function writeFile(path: string, content: string, app: App) { 6 | if (isAbsolute(path)) { 7 | nodeFs.writeFile(path, content, (error) => { 8 | if (error) console.log(`Error writing ${path}`, error); 9 | }); 10 | } else { 11 | try { 12 | const fs = app.vault.adapter; 13 | await fs.write(path, content); 14 | } catch (error) { 15 | console.log(`Error writing ${path}`, error); 16 | } 17 | } 18 | } 19 | 20 | export async function writeBinaryFile(path: string, content: Uint16Array) { 21 | const filePath = isAbsolute(path) 22 | ? path 23 | : `${this.app.vault.adapter.basePath}/${path}`; 24 | 25 | const directory = dirname(filePath); 26 | if (!nodeFs.existsSync(directory)) nodeFs.mkdirSync(directory); 27 | 28 | try { 29 | nodeFs.writeFileSync(filePath, content, { encoding: "binary" }); 30 | } catch (error) { 31 | console.log(`Error writing ${filePath}`, error); 32 | } 33 | } 34 | 35 | export function pathExist(path: string) { 36 | const filePath = isAbsolute(path) 37 | ? path 38 | : `${this.app.vault.adapter.basePath}/${path}`; 39 | 40 | return nodeFs.existsSync(filePath); 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: booksidian-plugin 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v5 20 | 21 | - name: Build 22 | id: build 23 | run: | 24 | npm install 25 | npm run build 26 | 27 | echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT 28 | 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: artifacts 32 | path: | 33 | ./main.js 34 | ./manifest.json 35 | ./styles.css 36 | 37 | release: 38 | runs-on: ubuntu-latest 39 | 40 | needs: build 41 | 42 | permissions: 43 | contents: write 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: actions/download-artifact@v4 49 | with: 50 | name: artifacts 51 | 52 | - name: Create release 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: | 56 | gh release create ${{github.ref}} --generate-notes \ 57 | main.js \ 58 | manifest.json 59 | -------------------------------------------------------------------------------- /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: ['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 | -------------------------------------------------------------------------------- /src/Frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { FRONTMATTER_LINES } from "const/frontmatter"; 2 | import { CurrentYAML } from "const/settings"; 3 | import { Book } from "src/Book"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const yaml = require("js-yaml"); 7 | 8 | export class Frontmatter { 9 | constructor( 10 | public currentYAML: CurrentYAML, 11 | public book: Book, 12 | ) {} 13 | 14 | public getFrontmatter(): string { 15 | return ( 16 | FRONTMATTER_LINES + 17 | "\n" + 18 | this.getFrontmatterLines() + 19 | FRONTMATTER_LINES + 20 | "\n" 21 | ); 22 | } 23 | 24 | private getFrontmatterLines(): string { 25 | const output: { [key: string]: number | string | string[] } = {}; 26 | 27 | Object.keys(this.currentYAML).forEach((key: string) => { 28 | const value = this.currentYAML[key]; 29 | const [prefix, postfix] = value.split(key); 30 | 31 | if (key === "shelves") { 32 | output[key] = this.book.shelves.sort().map((shelf) => { 33 | return `${prefix}${shelf}${postfix}`; 34 | }); 35 | } else { 36 | // If this a simple link, and the value of the string is empty, don't insert [[]] 37 | 38 | if ( 39 | value == `[[${key}]]` && 40 | this.book[key as keyof Book] == "" 41 | ) { 42 | output[key] = ""; 43 | } else { 44 | output[key] = 45 | `${prefix}${this.book[key as keyof Book]}${postfix}`; 46 | } 47 | } 48 | }); 49 | 50 | return yaml.dump(output); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import { Shelf } from "src/Shelf"; 3 | import { Settings } from "src/settings/Settings"; 4 | import { BooksidianSettings, DEFAULT_SETTINGS } from "const/settings"; 5 | 6 | export default class Booksidian extends Plugin { 7 | settings: BooksidianSettings; 8 | scheduleInterval: null | number = null; 9 | 10 | async onload() { 11 | await this.loadSettings(); 12 | 13 | // This creates an icon in the left ribbon. 14 | this.addRibbonIcon( 15 | "bold-glyph", 16 | "Booksidian Sync", 17 | (evt: MouseEvent) => { 18 | this.updateLibrary(); 19 | }, 20 | ); 21 | 22 | // This adds a simple command that can be triggered anywhere 23 | this.addCommand({ 24 | id: "booksidian-sync", 25 | name: "Booksidian Sync", 26 | callback: () => { 27 | this.updateLibrary(); 28 | }, 29 | }); 30 | 31 | // This adds a settings tab so the user can configure various aspects of the plugin 32 | this.addSettingTab(new Settings(this.app, this)); 33 | } 34 | 35 | updateLibrary() { 36 | this.settings.goodreadsShelves.split(",").forEach(async (_shelf) => { 37 | const shelf = new Shelf(this, _shelf.trim()); 38 | await shelf.createFolder(); 39 | await shelf.fetchGoodreadsFeed(); 40 | await shelf.createBookFiles(); 41 | }); 42 | } 43 | 44 | async loadSettings() { 45 | this.settings = Object.assign( 46 | {}, 47 | DEFAULT_SETTINGS, 48 | await this.loadData(), 49 | ); 50 | } 51 | 52 | async saveSettings() { 53 | await this.saveData(this.settings); 54 | } 55 | 56 | async configureSchedule() { 57 | const minutes = parseInt(this.settings.frequency); 58 | const milliseconds = minutes * 60 * 1000; // minutes * seconds * milliseconds 59 | console.log( 60 | "Booksidian plugin: setting interval to ", 61 | milliseconds, 62 | "milliseconds", 63 | ); 64 | window.clearInterval(this.scheduleInterval); 65 | this.scheduleInterval = null; 66 | if (!milliseconds) { 67 | // we got manual option 68 | return; 69 | } 70 | this.scheduleInterval = window.setInterval( 71 | () => this.updateLibrary(), 72 | milliseconds, 73 | ); 74 | this.registerInterval(this.scheduleInterval); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Shelf.ts: -------------------------------------------------------------------------------- 1 | import { rssParser } from "const/rssParser"; 2 | import { GoodreadsBook } from "const/goodreads"; 3 | import { Book } from "./Book"; 4 | import Booksidian from "main"; 5 | import { Notice } from "obsidian"; 6 | import * as nodeFs from "fs"; 7 | import { isAbsolute } from "path"; 8 | import { pathExist, writeBinaryFile } from "./helpers"; 9 | import { get } from "https"; 10 | 11 | export class Shelf { 12 | path: string; 13 | url: string; 14 | books: Book[] = []; 15 | 16 | constructor( 17 | public plugin: Booksidian, 18 | public shelfName: string, 19 | ) { 20 | const targetFolder = plugin.settings.targetFolderPath; 21 | this.path = targetFolder === "" ? "./" : targetFolder; 22 | 23 | this.url = `${plugin.settings.goodreadsBaseUrl}${shelfName.toLocaleLowerCase()}`; 24 | } 25 | 26 | private setBook(book: Book): void { 27 | this.books.push(book); 28 | } 29 | 30 | public getBooks(): Book[] { 31 | return this.books; 32 | } 33 | 34 | // create folder for each shelf (based on targetFolderPath) 35 | public async createFolder(): Promise { 36 | if (isAbsolute(this.path)) { 37 | nodeFs.mkdir(this.path, { recursive: true }, (err) => { 38 | if (err) console.log(err); 39 | }); 40 | } else { 41 | try { 42 | await this.plugin.app.vault.createFolder(this.path); 43 | } catch (e) { 44 | if (e.message.includes("already exists")) return; 45 | console.warn(e); 46 | } 47 | } 48 | } 49 | 50 | public async fetchGoodreadsFeed(): Promise { 51 | try { 52 | let page = 1; 53 | while (true) { 54 | const pagedUrl = `${this.url}&page=${page}&per_page=100`; 55 | const feed = await rssParser.parseURL(pagedUrl); 56 | 57 | if (!feed.items) break; 58 | 59 | for (const _book of feed.items as GoodreadsBook[]) { 60 | const book = new Book(this.plugin, _book); 61 | book.coverImage = await this.fetchCoverImage( 62 | book.cover, 63 | book.id, 64 | ); 65 | this.setBook(book); 66 | } 67 | page++; 68 | if (!feed.items.length) break; 69 | } 70 | } catch (e) { 71 | console.warn(e); 72 | } 73 | } 74 | 75 | private async fetchCoverImage(url: string, title: string) { 76 | if (!this.plugin.settings.coverDownload) return; 77 | 78 | let coverDownloadLocation = this.plugin.settings.coverDownloadLocation; 79 | 80 | if (coverDownloadLocation === "") 81 | coverDownloadLocation = `${this.plugin.settings.targetFolderPath || "."}/cover`; 82 | 83 | const fullPath = `${coverDownloadLocation}/${title}.jpg`; 84 | 85 | if (pathExist(fullPath)) return fullPath; 86 | 87 | get(url, (response) => { 88 | response.setEncoding("binary"); 89 | 90 | let rawData = new Uint16Array(); 91 | response.on("data", (chunk) => (rawData += chunk)); 92 | response.on("end", () => writeBinaryFile(fullPath, rawData)); 93 | }); 94 | 95 | return fullPath; 96 | } 97 | 98 | public async createBookFiles(): Promise { 99 | await Promise.all([ 100 | this.getBooks().map((book) => book.createFile(book, this.path)), 101 | ]); 102 | this.createNotice(); 103 | } 104 | 105 | private createNotice() { 106 | const syncCount: number = this.getBooks().length; 107 | 108 | if (syncCount === 0) { 109 | return; 110 | } 111 | 112 | const firstTitle = this.getBooks()[0].rawTitle; 113 | let noticeMsg = ""; 114 | 115 | if (syncCount === 1) { 116 | noticeMsg = `${firstTitle} synced from Goodreads!`; 117 | } else { 118 | noticeMsg = `${this.getBooks().length} books, including ${firstTitle}, synced from Goodreads!`; 119 | } 120 | 121 | new Notice(noticeMsg, 5000); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Booksidian 2 | 3 | Booksidian brings your Goodreads data to Obsidian. 4 | 5 | You can set both the body and the frontmatter for your book-note by choosing from the list of parameters available over the Goodreads RSS feed (+ some extra that can be deduced from them like subtitle or series). 6 | 7 | ![image](https://user-images.githubusercontent.com/46029522/152006018-bfab5d8a-e829-4dbd-b19e-84a9af19e258.png) 8 | 9 | ## Setup Instructions 10 | 11 | Please note that the way Goodreads handles their RSS feed, only the first 100 items of a shelf are added to the respective RSS feed. So if you have more than 100 books you'd like to export from one shelf, you have to split them into multiple shelves. 12 | 13 | #### Creating Shelves 14 | You can create those in Goodreads und `My Books` and then `Add shelf` in the left-side menu: 15 | ![image](https://user-images.githubusercontent.com/46029522/152001408-87c88a68-b161-4dfd-9845-d6036a05992b.png) 16 | 17 | #### Getting the Feed Base-URL 18 | You get the RSS Base URL by setting the items loaded per page to `infinite scroll` and then click the orange `RSS` button in the bottom right. 19 | 20 | ![image](https://user-images.githubusercontent.com/46029522/152004240-2580c551-d603-4119-9dd5-95a3bf68b764.png) 21 | 22 | 23 | This will open a new page. You can now copy that URL and remove everything after the last "=". This is your RSS Base URL. After setting this, you can add all the shelves you'd like to download by just adding their names (separated by comma) in the settings. 24 | 25 | ![image](https://user-images.githubusercontent.com/46029522/152002763-444c05e1-3a5f-426b-9493-beb99deb9aa3.png) 26 | 27 | ### Running Booksidian 28 | You can run the Booksidian sync by executing the "Booksidian Sync" command or by pressing the "B" in your menu bar. 29 | 30 | Alternatively, you can set Booksidian to sync automatically by updating the `frequency` in the plugin settings. 31 | 32 | ### Overwriting Notes 33 | 34 | By default, once Booksidian has synced a book from your RSS feed and created a note, that note will never be updated or changed, even if the data related to that book changes within your feed. For example if you sync a book, then give it a rating and sync again, that rating will not be synced to the note. 35 | 36 | To have Booksidian overwrite old notes, toggle the `overwrite` plugin setting on. This will cause Booksidian to always replace existing notes for books with new ones. Be careful though - if you've made your own updates to the notes files, they'll be lost on the next sync. 37 | 38 | ## Output 39 | 40 | In the end it's completely up to you how you style your book-notes. One thing I personally love is combining it with the `dataview plugin` and the new cards system in the `minimal theme`, which enables you to create beautiful little libraries like this: 41 | 42 | ![image](https://user-images.githubusercontent.com/46029522/151970426-377a5997-7c15-4670-b423-17bb04b3720a.png) 43 | 44 | You can achieve this look here by adding `cssClasses: cards` to the frontmatter of the file you'd like to have your library in and then pasting this code here: 45 | 46 | ```dataview 47 | table without id ("![](" + cover +")") as Cover, author as Author 48 | where cover != null 49 | sort rating desc 50 | ``` 51 | 52 | Please check out the amazing work of these two [here](https://github.com/blacksmithgu/obsidian-dataview) and [here](https://github.com/kepano/obsidian-minimal). 53 | 54 | ### Linking back to Goodreads 55 | 56 | The Goodreads book `id` is provided as part of the available data in the plugin. You can create a link back to the Goodreads page for a book by doing: 57 | 58 | ``` 59 | https://www.goodreads.com/book/show/{{id}} 60 | ``` 61 | 62 | -------------------------------------------------------------------------------- /src/Book.ts: -------------------------------------------------------------------------------- 1 | import { CurrentYAML } from "const/settings"; 2 | import { GoodreadsBook } from "const/goodreads"; 3 | import Booksidian from "main"; 4 | import { Body } from "./Body"; 5 | import { Frontmatter } from "./Frontmatter"; 6 | import { writeFile } from "./helpers"; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | const TurndownService = require("turndown"); 10 | 11 | export class Book { 12 | id: string; 13 | pages: number; 14 | title: string; 15 | rawTitle: string; 16 | fullTitle: string; 17 | series: string; 18 | seriesName: string; 19 | seriesNumber: number; 20 | subtitle: string; 21 | description: string; 22 | author: string; 23 | isbn: string; 24 | review: string; 25 | rating: number; 26 | avgRating: number; 27 | shelves: string[]; 28 | dateAdded: string; 29 | dateCreated: string; 30 | dateRead: string; 31 | datePublished: string; 32 | cover: string; 33 | coverImage: string; 34 | bookPage: string; 35 | 36 | constructor( 37 | public plugin: Booksidian, 38 | book: GoodreadsBook, 39 | ) { 40 | this.id = book.identifiers.$.id; 41 | this.pages = parseInt(book.identifiers.num_pages[0]) || undefined; 42 | this.title = this.cleanTitle(book.title, false); 43 | this.rawTitle = book.title; 44 | this.fullTitle = this.cleanTitle(book.title, true); 45 | this.description = this.htmlToMarkdown(book.book_description); 46 | this.author = book.author; 47 | this.isbn = book.isbn; 48 | this.review = this.htmlToMarkdown(book.user_review || ""); 49 | this.rating = parseInt(book.user_rating) || 0; 50 | this.avgRating = parseFloat(book.average_rating) || 0; 51 | this.dateAdded = this.parseDate(book.user_date_added); 52 | this.dateCreated = this.parseDate(book.user_date_created); 53 | this.dateRead = this.parseDate(book.user_read_at); 54 | this.datePublished = this.parseDate(book.book_published); 55 | this.cover = book.image_url; 56 | this.coverImage = book.image_path; 57 | this.shelves = this.getShelves(book.user_shelves, this.dateRead); 58 | this.bookPage = `https://www.goodreads.com/book/show/${this.id}`; 59 | } 60 | 61 | public getTitle(): string { 62 | return this.title; 63 | } 64 | 65 | public getContent(): string { 66 | const set = this.plugin.settings; 67 | try { 68 | return ( 69 | this.getFrontMatter(set.frontmatterDictionary) + 70 | this.getBody(set.bodyString) 71 | ); 72 | } catch (error) { 73 | console.log(error); 74 | } 75 | } 76 | 77 | private htmlToMarkdown(html: string) { 78 | const turndownService = new TurndownService(); 79 | return turndownService.turndown(html); 80 | } 81 | 82 | private getShelves(shelves: string, dateRead: string): string[] { 83 | // Goodreads doesn't send a shelf value for books on the read shelf. 84 | // Infer from either a missing shelf value, or a set dateRead. 85 | // Check for presence of read first in case Goodreads decides to include it. 86 | const outputShelves = shelves 87 | .split(",") 88 | .map((shelf) => shelf.trim()) // trim shelf names 89 | .filter((shelf) => shelf); // filter out empty shelf names 90 | 91 | // If the book has a read date and the `read` shelf is missing, we add it 92 | if (dateRead && !outputShelves.includes("read")) 93 | outputShelves.push("read"); 94 | 95 | return outputShelves; 96 | } 97 | 98 | private getBody(currentBody: string): string { 99 | return new Body(currentBody, this).getBody(); 100 | } 101 | 102 | private getFrontMatter(currentYAML: CurrentYAML): string { 103 | if (Object.keys(currentYAML).length > 0) { 104 | return new Frontmatter(currentYAML, this).getFrontmatter(); 105 | } 106 | return ""; 107 | } 108 | 109 | public async createFile(book: Book, path: string): Promise { 110 | const fileName = this.getBody(this.plugin.settings.fileName); 111 | const fullPath = `${path}/${fileName}.md`; 112 | 113 | const file = this.plugin.app.vault.getFileByPath(fullPath); 114 | if (file && !this.plugin.settings.overwrite) return; 115 | 116 | const bookContent = book.getContent(); 117 | 118 | writeFile(fullPath, bookContent, this.plugin.app); 119 | } 120 | 121 | private cleanTitle(title: string, full: boolean) { 122 | this.series = ""; 123 | this.seriesName = ""; 124 | this.seriesNumber = 0; 125 | this.subtitle = ""; 126 | let series = ""; 127 | 128 | if (title.includes("(") && title.includes("#")) { 129 | series = this.getSeries(title); 130 | } 131 | 132 | title = title.replace(series, ""); 133 | 134 | if (title.includes(":")) { 135 | this.getSubTitle(title); 136 | } 137 | 138 | if (!full) { 139 | title = title.split(":")[0]; 140 | } 141 | 142 | // replace remaining special characters with an empty character 143 | title = title.replace(/[&\/\\#,+()$~%.'":*?<>{}|]/g, ""); 144 | 145 | return title.trim(); 146 | } 147 | 148 | private getSeries(title: string): string { 149 | // only calculate once per book 150 | if (this.series) { 151 | return this.series; 152 | } 153 | let match = title.match(/.+ \(((.+?),? #(\d+))\)/); 154 | 155 | if (match) { 156 | this.series = match[1].trim(); 157 | this.seriesName = match[2].trim(); 158 | this.seriesNumber = parseInt(match[3].trim(), 10); 159 | return `(${match[1]})`; 160 | } 161 | 162 | console.log( 163 | `New get series parser failed for "${title}", falling back to legacy parser.`, 164 | ); 165 | 166 | // fallback to old method, this is mostly for backwards compatibility in case of edge cases 167 | match = title.match(/\((.*?)\)/); 168 | if (match && match[1].contains("#")) { 169 | this.series = match[1].trim(); 170 | return match[0]; 171 | } 172 | return ""; 173 | } 174 | 175 | private getSubTitle(title: string) { 176 | this.subtitle = title.split(":")[1].trim(); 177 | } 178 | 179 | private parseDate(inputDate: string) { 180 | if (inputDate == "") { 181 | return ""; 182 | } 183 | const date = new Date(inputDate); 184 | return date.toISOString().substring(0, 10); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/settings/suggesters/suggest.ts: -------------------------------------------------------------------------------- 1 | // copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/suggest.ts 2 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 3 | 4 | import { App, ISuggestOwner, Scope } from "obsidian"; 5 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 6 | 7 | const wrapAround = (value: number, size: number): number => { 8 | return ((value % size) + size) % size; 9 | }; 10 | 11 | class Suggest { 12 | private owner: ISuggestOwner; 13 | private values: T[]; 14 | private suggestions: HTMLDivElement[]; 15 | private selectedItem: number; 16 | private containerEl: HTMLElement; 17 | 18 | constructor( 19 | owner: ISuggestOwner, 20 | containerEl: HTMLElement, 21 | scope: Scope, 22 | ) { 23 | this.owner = owner; 24 | this.containerEl = containerEl; 25 | 26 | containerEl.on( 27 | "click", 28 | ".suggestion-item", 29 | this.onSuggestionClick.bind(this), 30 | ); 31 | containerEl.on( 32 | "mousemove", 33 | ".suggestion-item", 34 | this.onSuggestionMouseover.bind(this), 35 | ); 36 | 37 | scope.register([], "ArrowUp", (event) => { 38 | if (!event.isComposing) { 39 | this.setSelectedItem(this.selectedItem - 1, true); 40 | return false; 41 | } 42 | }); 43 | 44 | scope.register([], "ArrowDown", (event) => { 45 | if (!event.isComposing) { 46 | this.setSelectedItem(this.selectedItem + 1, true); 47 | return false; 48 | } 49 | }); 50 | 51 | scope.register([], "Enter", (event) => { 52 | if (!event.isComposing) { 53 | this.useSelectedItem(event); 54 | return false; 55 | } 56 | }); 57 | } 58 | 59 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 60 | event.preventDefault(); 61 | 62 | const item = this.suggestions.indexOf(el); 63 | this.setSelectedItem(item, false); 64 | this.useSelectedItem(event); 65 | } 66 | 67 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 68 | const item = this.suggestions.indexOf(el); 69 | this.setSelectedItem(item, false); 70 | } 71 | 72 | setSuggestions(values: T[]) { 73 | this.containerEl.empty(); 74 | const suggestionEls: HTMLDivElement[] = []; 75 | 76 | values.forEach((value) => { 77 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 78 | this.owner.renderSuggestion(value, suggestionEl); 79 | suggestionEls.push(suggestionEl); 80 | }); 81 | 82 | this.values = values; 83 | this.suggestions = suggestionEls; 84 | this.setSelectedItem(0, false); 85 | } 86 | 87 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 88 | const currentValue = this.values[this.selectedItem]; 89 | if (currentValue) { 90 | this.owner.selectSuggestion(currentValue, event); 91 | } 92 | } 93 | 94 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 95 | const normalizedIndex = wrapAround( 96 | selectedIndex, 97 | this.suggestions.length, 98 | ); 99 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 100 | const selectedSuggestion = this.suggestions[normalizedIndex]; 101 | 102 | prevSelectedSuggestion?.removeClass("is-selected"); 103 | selectedSuggestion?.addClass("is-selected"); 104 | 105 | this.selectedItem = normalizedIndex; 106 | 107 | if (scrollIntoView) { 108 | selectedSuggestion.scrollIntoView(false); 109 | } 110 | } 111 | } 112 | 113 | export abstract class TextInputSuggest implements ISuggestOwner { 114 | private popper: PopperInstance; 115 | private scope: Scope; 116 | private suggestEl: HTMLElement; 117 | private suggest: Suggest; 118 | 119 | constructor( 120 | protected app: App, 121 | protected inputEl: HTMLInputElement | HTMLTextAreaElement, 122 | ) { 123 | this.scope = new Scope(); 124 | 125 | this.suggestEl = createDiv("suggestion-container"); 126 | const suggestion = this.suggestEl.createDiv("suggestion"); 127 | this.suggest = new Suggest(this, suggestion, this.scope); 128 | 129 | this.scope.register([], "Escape", this.close.bind(this)); 130 | 131 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 132 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 133 | this.inputEl.addEventListener("blur", this.close.bind(this)); 134 | this.suggestEl.on( 135 | "mousedown", 136 | ".suggestion-container", 137 | (event: MouseEvent) => { 138 | event.preventDefault(); 139 | }, 140 | ); 141 | } 142 | 143 | onInputChanged(): void { 144 | const inputStr = this.inputEl.value; 145 | const suggestions = this.getSuggestions(inputStr); 146 | 147 | if (!suggestions) { 148 | this.close(); 149 | return; 150 | } 151 | 152 | if (suggestions.length > 0) { 153 | this.suggest.setSuggestions(suggestions); 154 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 155 | this.open((this.app).dom.appContainerEl, this.inputEl); 156 | } else { 157 | this.close(); 158 | } 159 | } 160 | 161 | open(container: HTMLElement, inputEl: HTMLElement): void { 162 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 163 | (this.app).keymap.pushScope(this.scope); 164 | 165 | container.appendChild(this.suggestEl); 166 | this.popper = createPopper(inputEl, this.suggestEl, { 167 | placement: "bottom-start", 168 | modifiers: [ 169 | { 170 | name: "sameWidth", 171 | enabled: true, 172 | fn: ({ state, instance }) => { 173 | // Note: positioning needs to be calculated twice - 174 | // first pass - positioning it according to the width of the popper 175 | // second pass - position it with the width bound to the reference element 176 | // we need to early exit to avoid an infinite loop 177 | const targetWidth = `${state.rects.reference.width}px`; 178 | if (state.styles.popper.width === targetWidth) { 179 | return; 180 | } 181 | state.styles.popper.width = targetWidth; 182 | instance.update(); 183 | }, 184 | phase: "beforeWrite", 185 | requires: ["computeStyles"], 186 | }, 187 | ], 188 | }); 189 | } 190 | 191 | close(): void { 192 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 193 | (this.app).keymap.popScope(this.scope); 194 | 195 | this.suggest.setSuggestions([]); 196 | if (this.popper) this.popper.destroy(); 197 | this.suggestEl.detach(); 198 | } 199 | 200 | abstract getSuggestions(inputStr: string): T[]; 201 | abstract renderSuggestion(item: T, el: HTMLElement): void; 202 | abstract selectSuggestion(item: T): void; 203 | } 204 | -------------------------------------------------------------------------------- /test/BookTest.ts: -------------------------------------------------------------------------------- 1 | import { Book } from "src/Book"; 2 | import { GoodreadsBook } from "const/goodreads"; 3 | import { Book_id } from "const/goodreads"; 4 | import { Identifiers } from "const/goodreads"; 5 | 6 | const test_book_id: Book_id = { 7 | id: "sample_id", 8 | }; 9 | 10 | const test_identifier: Identifiers = { 11 | $: test_book_id, 12 | num_pages: ["123"], 13 | }; 14 | 15 | const test_book: GoodreadsBook = { 16 | author: "test_author", 17 | title: "test_title", 18 | link: "test_link", 19 | pubDate: "01/01/1970", 20 | isbn: "0123456789", 21 | user_rating: "5", 22 | user_review: "Test review", 23 | book_description: "Test description", 24 | average_rating: "3", 25 | user_read_at: "01/01/1970", 26 | user_date_added: "01/01/1970", 27 | user_date_created: "01/01/1970", 28 | book_published: "01/01/1970", 29 | identifiers: test_identifier, 30 | content: "test_content", 31 | contentSnippet: "test_content_snippet", 32 | guid: "test_guid", 33 | user_shelves: "test_shelf", 34 | image_url: "test_image_url", 35 | image_path: "test_image_url", 36 | }; 37 | 38 | describe("Empty title", () => { 39 | test("empty title should result in empty_string", () => { 40 | // Given 41 | test_book.title = ""; 42 | // When 43 | const unit = new Book(null, test_book); 44 | // Then 45 | expect(unit.title).toBe(""); 46 | }); 47 | }); 48 | 49 | describe("No special character title", () => { 50 | test("No special character title should result in title", () => { 51 | // Given 52 | test_book.title = "My wonderful book"; 53 | // When 54 | const unit = new Book(null, test_book); 55 | // Then 56 | expect(unit.title).toBe("My wonderful book"); 57 | }); 58 | }); 59 | 60 | describe("Title with '?' character", () => { 61 | test("Title with '?' character should have it replaced with empty char", () => { 62 | // Given 63 | test_book.title = "My wonderful book?"; 64 | // When 65 | const unit = new Book(null, test_book); 66 | // Then 67 | expect(unit.title).toBe("My wonderful book"); 68 | }); 69 | }); 70 | 71 | describe("Title with '#' character", () => { 72 | test("Title with '#' character should have it replaced with empty char", () => { 73 | // Given 74 | test_book.title = "My wonderful book#"; 75 | // When 76 | const unit = new Book(null, test_book); 77 | // Then 78 | expect(unit.title).toBe("My wonderful book"); 79 | }); 80 | }); 81 | 82 | describe("Title with '&' character", () => { 83 | test("Title with '&' character should have it replaced with empty char", () => { 84 | // Given 85 | test_book.title = "My wonderful book&"; 86 | // When 87 | const unit = new Book(null, test_book); 88 | // Then 89 | expect(unit.title).toBe("My wonderful book"); 90 | }); 91 | }); 92 | 93 | describe("Title with '{' character", () => { 94 | test("Title with '{' character should have it replaced with empty char", () => { 95 | // Given 96 | test_book.title = "My wonderful book{"; 97 | // When 98 | const unit = new Book(null, test_book); 99 | // Then 100 | expect(unit.title).toBe("My wonderful book"); 101 | }); 102 | }); 103 | 104 | describe("Title with '}' character", () => { 105 | test("Title with '}' character should have it replaced with empty char", () => { 106 | // Given 107 | test_book.title = "My wonderful book}"; 108 | // When 109 | const unit = new Book(null, test_book); 110 | // Then 111 | expect(unit.title).toBe("My wonderful book"); 112 | }); 113 | }); 114 | 115 | describe("Title with '%' character", () => { 116 | test("Title with '%' character should have it replaced with empty char", () => { 117 | // Given 118 | test_book.title = "My wonderful book%"; 119 | // When 120 | const unit = new Book(null, test_book); 121 | // Then 122 | expect(unit.title).toBe("My wonderful book"); 123 | }); 124 | }); 125 | 126 | describe("Title with '<' character", () => { 127 | test("Title with '<' character should have it replaced with empty char", () => { 128 | // Given 129 | test_book.title = "My wonderful book<"; 130 | // When 131 | const unit = new Book(null, test_book); 132 | // Then 133 | expect(unit.title).toBe("My wonderful book"); 134 | }); 135 | }); 136 | 137 | describe("Title with '>' character", () => { 138 | test("Title with '>' character should have it replaced with empty char", () => { 139 | // Given 140 | test_book.title = "My wonderful book>"; 141 | // When 142 | const unit = new Book(null, test_book); 143 | // Then 144 | expect(unit.title).toBe("My wonderful book"); 145 | }); 146 | }); 147 | 148 | describe("Title with '$' character", () => { 149 | test("Title with '$' character should have it replaced with empty char", () => { 150 | // Given 151 | test_book.title = "My wonderful book$"; 152 | // When 153 | const unit = new Book(null, test_book); 154 | // Then 155 | expect(unit.title).toBe("My wonderful book"); 156 | }); 157 | }); 158 | 159 | describe("Title with '*' character", () => { 160 | test("Title with '*' character should have it replaced with empty char", () => { 161 | // Given 162 | test_book.title = "My wonderful book*"; 163 | // When 164 | const unit = new Book(null, test_book); 165 | // Then 166 | expect(unit.title).toBe("My wonderful book"); 167 | }); 168 | }); 169 | 170 | describe("Title with '|' character", () => { 171 | test("Title with '|' character should have it replaced with empty char", () => { 172 | // Given 173 | test_book.title = "My wonderful book|"; 174 | // When 175 | const unit = new Book(null, test_book); 176 | // Then 177 | expect(unit.title).toBe("My wonderful book"); 178 | }); 179 | }); 180 | 181 | describe("Title with '\\' character", () => { 182 | test("Title with '\\' character should have it replaced with empty char", () => { 183 | // Given 184 | test_book.title = "My wonderful book\\"; 185 | // When 186 | const unit = new Book(null, test_book); 187 | // Then 188 | expect(unit.title).toBe("My wonderful book"); 189 | }); 190 | }); 191 | 192 | describe("Title with '/' character", () => { 193 | test("Title with '/' character should have it replaced with empty char", () => { 194 | // Given 195 | test_book.title = "My wonderful book/"; 196 | // When 197 | const unit = new Book(null, test_book); 198 | // Then 199 | expect(unit.title).toBe("My wonderful book"); 200 | }); 201 | }); 202 | 203 | describe("Title with ':' character", () => { 204 | test("Title with ':' character should have it replaced with empty char", () => { 205 | // Given 206 | test_book.title = "My wonderful book:"; 207 | // When 208 | const unit = new Book(null, test_book); 209 | // Then 210 | expect(unit.title).toBe("My wonderful book"); 211 | }); 212 | }); 213 | 214 | describe("Title with '\"' character", () => { 215 | test("Title with '\"' character should have it replaced with empty char", () => { 216 | // Given 217 | test_book.title = 'My wonderful book"'; 218 | // When 219 | const unit = new Book(null, test_book); 220 | // Then 221 | expect(unit.title).toBe("My wonderful book"); 222 | }); 223 | }); 224 | 225 | 226 | describe("Series information parser", () => { 227 | test("No series", () => { 228 | // Given 229 | test_book.title = 'My wonderful book'; 230 | // When 231 | const unit = new Book(null, test_book); 232 | // Then 233 | expect(unit.series).toBe(""); 234 | expect(unit.seriesName).toBe(""); 235 | expect(unit.seriesNumber).toBe(0); 236 | }); 237 | 238 | test("Series with (, #)", () => { 239 | // Given 240 | test_book.title = 'My wonderful book (My series, #15)'; 241 | // When 242 | const unit = new Book(null, test_book); 243 | // Then 244 | expect(unit.series).toBe("My series, #15"); 245 | expect(unit.seriesName).toBe("My series"); 246 | expect(unit.seriesNumber).toBe(15); 247 | }); 248 | 249 | test("Series with ( #)", () => { 250 | // Given 251 | test_book.title = 'My wonderful book (My series #15)'; 252 | // When 253 | const unit = new Book(null, test_book); 254 | // Then 255 | expect(unit.series).toBe("My series #15"); 256 | expect(unit.seriesName).toBe("My series"); 257 | expect(unit.seriesNumber).toBe(15); 258 | }); 259 | 260 | test("Series without number", () => { 261 | // Given 262 | test_book.title = 'My wonderful book (My series)'; 263 | // When 264 | const unit = new Book(null, test_book); 265 | // Then 266 | expect(unit.series).toBe(""); 267 | expect(unit.seriesName).toBe(""); 268 | expect(unit.seriesNumber).toBe(0); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /src/settings/Settings.ts: -------------------------------------------------------------------------------- 1 | import { App, debounce, Notice, PluginSettingTab, Setting } from "obsidian"; 2 | import { FolderSuggest } from "./suggesters/FolderSuggester"; 3 | import Booksidian from "../../main"; 4 | 5 | const debouncedSaveSettings = debounce( 6 | (callback: () => void) => callback(), 7 | 500, 8 | true, 9 | ); 10 | 11 | export class Settings extends PluginSettingTab { 12 | plugin: Booksidian; 13 | currentYAML: { [key: string]: string }; 14 | 15 | constructor(app: App, plugin: Booksidian) { 16 | super(app, plugin); 17 | this.plugin = plugin; 18 | this.currentYAML = plugin.settings.frontmatterDictionary; 19 | } 20 | 21 | getSelectedCount(): string { 22 | const selected = Object.keys(this.getYAML()).length; 23 | const total = 20; 24 | return `${selected}/${total}`; 25 | } 26 | 27 | // eslint-disable-next-line @typescript-eslint/ban-types 28 | private getYAML(): { [key: string]: string } { 29 | return this.currentYAML; 30 | } 31 | 32 | getDisplay(option: string, label?: string): string { 33 | label = label ? label : option; 34 | 35 | if (this.optionIsSelected(option)) { 36 | return "🟢 - " + label; 37 | } 38 | return "⚫ - " + label; 39 | } 40 | 41 | optionIsSelected(option: string): boolean { 42 | return this.currentYAML.hasOwnProperty(option); 43 | } 44 | 45 | display(): void { 46 | const { containerEl } = this; 47 | 48 | containerEl.empty(); 49 | containerEl.createEl("h3", { text: "Goodreads RSS Feed" }); 50 | 51 | // set the target folder for the exports 52 | new Setting(containerEl) 53 | .setName("Target Folder") 54 | .setDesc( 55 | "Path to where to store the book notes. Can be either a relative path within the vault, or absolute outside of the vault. If you leave this empty, the books will be created in the root of the vault.", 56 | ) 57 | .addSearch((cb) => { 58 | try { 59 | new FolderSuggest(this.app, cb.inputEl); 60 | } catch (e) { 61 | console.error(e); // Improved error handling 62 | } 63 | cb.setPlaceholder("Vault root") 64 | .setValue(this.plugin.settings.targetFolderPath) 65 | .onChange(async (value) => { 66 | this.plugin.settings.targetFolderPath = value 67 | .replace( 68 | /[\\/]+$/g, // matches any trailing slashes 69 | "", 70 | ) 71 | .trim(); 72 | await this.plugin.saveSettings(); 73 | }); 74 | }); 75 | 76 | // set the base url for all goodreads rss feeds 77 | new Setting(containerEl) 78 | .setName("RSS Base URL") 79 | .setDesc( 80 | "Please add your RSS Base URL here (everything before the shelf name).", 81 | ) 82 | .setTooltip("https://www.goodreads.com/ ... &shelf=") 83 | .addText((text) => { 84 | text.setValue(this.plugin.settings.goodreadsBaseUrl) 85 | .setPlaceholder("https://www.goodreads.com/ ... &shelf=") 86 | .onChange(async (value) => { 87 | debouncedSaveSettings(async () => { 88 | const validPattern = 89 | /^https?:\/\/.*?\/review\/list_rss\/\d+\?key=[a-zA-Z0-9-_]+&shelf=/; 90 | 91 | const result = value.trim().match(validPattern); 92 | 93 | // Save the url only when it matches the pattern 94 | if (result) { 95 | this.plugin.settings.goodreadsBaseUrl = 96 | result[0]; 97 | text.inputEl.value = result[0]; 98 | } else if (value.trim().length === 0) { 99 | this.plugin.settings.goodreadsBaseUrl = ""; 100 | } else { 101 | new Notice( 102 | "Booksidian: Could not parse RSS Base URL", 103 | ); 104 | return; 105 | } 106 | 107 | await this.plugin.saveSettings(); 108 | }); 109 | }); 110 | text.inputEl.style.minWidth = "18rem"; 111 | text.inputEl.style.maxWidth = "18rem"; 112 | }); 113 | 114 | // set the goodreads shelves that should be exported 115 | new Setting(containerEl) 116 | .setName("Your Goodreads Shelves") 117 | .setDesc( 118 | "Here you can specify which shelves you'd like to export. Please separate the values with a comma and make sure you got the names right. ", 119 | ) 120 | .setTooltip("You can check the proper naming in the RSS url.") 121 | .addTextArea((text) => { 122 | text.inputEl.rows = 6; 123 | text.setPlaceholder("Your Shelves") 124 | .setValue(this.plugin.settings.goodreadsShelves) 125 | .onChange(async (value) => { 126 | this.plugin.settings.goodreadsShelves = value; 127 | await this.plugin.saveSettings(); 128 | }); 129 | }); 130 | 131 | new Setting(containerEl) 132 | .setName("Configure resync frequency") 133 | .setDesc( 134 | "If not set to manual, Booksidian will resync with Goodreads RSS at configured interval", 135 | ) 136 | .addDropdown((dropdown) => { 137 | dropdown.addOption("0", "Manual"); 138 | dropdown.addOption("60", "Every 1 hour"); 139 | dropdown.addOption((12 * 60).toString(), "Every 12 hours"); 140 | dropdown.addOption((24 * 60).toString(), "Every 24 hours"); 141 | 142 | dropdown.setValue(this.plugin.settings.frequency); 143 | 144 | dropdown.onChange((newValue) => { 145 | this.plugin.settings.frequency = newValue; 146 | this.plugin.saveSettings(); 147 | 148 | this.plugin.configureSchedule(); 149 | }); 150 | }); 151 | 152 | new Setting(containerEl) 153 | .setName("Overwrite") 154 | .setDesc( 155 | "When syncing with Goodreads, overwrite existing notes. Modifications to notes will be lost, but changes from Goodreads will now be picked up.", 156 | ) 157 | .addToggle((toggle) => { 158 | toggle.setValue(this.plugin.settings.overwrite); 159 | 160 | toggle.onChange((newValue) => { 161 | this.plugin.settings.overwrite = newValue; 162 | this.plugin.saveSettings(); 163 | }); 164 | }); 165 | 166 | containerEl.createEl("h4", { text: "Book covers" }); 167 | 168 | new Setting(containerEl) 169 | .setName("Download covers") 170 | .setDesc( 171 | "Whether the cover image for each book should be downloaded", 172 | ) 173 | .addToggle((toggle) => { 174 | toggle.setValue(this.plugin.settings.coverDownload); 175 | toggle.onChange( 176 | async (value) => 177 | (this.plugin.settings.coverDownload = value), 178 | ); 179 | }); 180 | 181 | new Setting(containerEl) 182 | .setName("Cover download folder") 183 | .setDesc( 184 | 'Path to where the cover images should be downloaded to. Like Target Folder, the path can be relative to the vault or absolute outside of the vault. If left empty, a folder named "cover" will be created under Target Folder.', 185 | ) 186 | .addSearch((cb) => { 187 | try { 188 | new FolderSuggest(this.app, cb.inputEl); 189 | } catch (e) { 190 | console.error(e); // Improved error handling 191 | } 192 | cb.setPlaceholder("Target Folder/cover") 193 | .setValue(this.plugin.settings.coverDownloadLocation) 194 | .onChange(async (value) => { 195 | this.plugin.settings.coverDownloadLocation = 196 | value.trim(); 197 | await this.plugin.saveSettings(); 198 | }); 199 | }); 200 | 201 | containerEl.createEl("h3", { text: "Body" }); 202 | containerEl.createEl("p", { 203 | text: "You can specify the content of the book-note by using {{placeholders}}. You can see the full list of placeholders in the dropdown of the frontmatter. You can choose the frontmatter placeholders you'd like and apply specific formatting to each of them.", 204 | }); 205 | 206 | // set the title of the book-note 207 | new Setting(containerEl) 208 | .setName("Naming Pattern") 209 | .setTooltip("You don't need to add '.md' to the filename") 210 | .addText((text) => { 211 | text.setValue(this.plugin.settings.fileName); 212 | text.onChange(async (value) => { 213 | this.plugin.settings.fileName = value; 214 | await this.plugin.saveSettings(); 215 | }); 216 | }); 217 | 218 | // set the body content of the book-note 219 | new Setting(containerEl) 220 | .setName("Content of the book-note") 221 | .setTooltip("Don't forget to wrap the placeholders in {{}}.") 222 | .addTextArea((text) => { 223 | text.inputEl.rows = 6; 224 | text.setValue(this.plugin.settings.bodyString); 225 | text.onChange(async (value) => { 226 | this.plugin.settings.bodyString = value; 227 | await this.plugin.saveSettings(); 228 | }); 229 | }); 230 | 231 | containerEl.createEl("h3", { text: "Frontmatter" }); 232 | 233 | if (Object.keys(this.currentYAML).length > 0) { 234 | containerEl.createEl("p", { 235 | text: "You can add custom frontmatter to your books. Please use the dropdown to choose the frontmatter you'd like to add.", 236 | }); 237 | } 238 | // containerEl.createEl("pre", { 239 | // text: "key: value", 240 | // attr: { style: "font-size: 12px; color: #999;" }, 241 | // }); 242 | // } 243 | 244 | new Setting(containerEl) 245 | .setName("Available Fields") 246 | 247 | .addDropdown((dropdown) => 248 | dropdown 249 | .addOption("", `${this.getSelectedCount()}`) 250 | .addOption("id", `${this.getDisplay("id")}`) 251 | .addOption("author", `${this.getDisplay("author")}`) 252 | .addOption( 253 | "title", 254 | `${this.getDisplay("title", "title (formatted for filenames/links)")}`, 255 | ) 256 | .addOption( 257 | "fullTitle", 258 | `${this.getDisplay("fullTitle", "fullTitle (formatted, includes subtitle)")}`, 259 | ) 260 | .addOption("rawTitle", `${this.getDisplay("rawTitle")}`) 261 | .addOption("subtitle", `${this.getDisplay("subtitle")}`) 262 | .addOption("pages", `${this.getDisplay("pages")}`) 263 | .addOption("series", `${this.getDisplay("series")}`) 264 | .addOption("seriesName", `${this.getDisplay("seriesName")}`) 265 | .addOption( 266 | "seriesNumber", 267 | `${this.getDisplay("seriesNumber")}`, 268 | ) 269 | .addOption( 270 | "description", 271 | `${this.getDisplay("description")}`, 272 | ) 273 | .addOption("cover", `${this.getDisplay("cover")}`) 274 | .addOption("coverImage", `${this.getDisplay("coverImage")}`) 275 | .addOption("isbn", `${this.getDisplay("isbn")}`) 276 | .addOption("review", `${this.getDisplay("review")}`) 277 | .addOption("rating", `${this.getDisplay("rating")}`) 278 | .addOption("avgRating", `${this.getDisplay("avgRating")}`) 279 | .addOption("dateAdded", `${this.getDisplay("dateAdded")}`) 280 | .addOption( 281 | "dateCreated", 282 | `${this.getDisplay("dateCreated")}`, 283 | ) 284 | .addOption("dateRead", `${this.getDisplay("dateRead")}`) 285 | .addOption( 286 | "datePublished", 287 | `${this.getDisplay("datePublished")}`, 288 | ) 289 | .addOption("shelves", `${this.getDisplay("shelves")}`) 290 | .addOption("bookPage", `${this.getDisplay("bookPage")}`) 291 | .onChange(async (value: string) => { 292 | if (this.optionIsSelected(value)) { 293 | delete this.currentYAML[value]; 294 | } else { 295 | if (value === "coverImage") 296 | // we want coverImage to default to a link 297 | this.currentYAML[value] = `[[${value}]]`; 298 | else this.currentYAML[value] = value; 299 | } 300 | await this.plugin.saveSettings(); 301 | this.display(); 302 | }), 303 | ) 304 | .addExtraButton((button) => 305 | button 306 | .onClick(async () => { 307 | this.display(); 308 | }) 309 | .setIcon("sync") 310 | .setTooltip("Refresh Previews"), 311 | ); 312 | 313 | Object.keys(this.currentYAML).forEach((key) => { 314 | const value = this.currentYAML[key]; 315 | new Setting(containerEl) 316 | .setName(key + ": " + value) 317 | .addExtraButton( 318 | (button) => 319 | button 320 | .setTooltip("Convert to link") 321 | .onClick(async () => { 322 | if (value.startsWith("[[")) { 323 | this.currentYAML[key] = value.replace( 324 | /[[\]]/g, 325 | "", 326 | ); 327 | } else { 328 | this.currentYAML[key] = "[[" + value + "]]"; 329 | } 330 | await this.plugin.saveSettings(); 331 | this.display(); 332 | }) 333 | .setIcon("bracket-glyph").setTooltip, 334 | ) 335 | .addText((text) => 336 | text 337 | .setPlaceholder("") 338 | .setValue(this.currentYAML[key]) 339 | .onChange(async (value) => { 340 | this.currentYAML[key] = value; 341 | await this.plugin.saveSettings(); 342 | }), 343 | ) 344 | .addExtraButton((button) => 345 | button 346 | .onClick(async () => { 347 | delete this.currentYAML[key]; 348 | await this.plugin.saveSettings(); 349 | this.display(); 350 | }) 351 | .setIcon("trash") 352 | .setTooltip("Remove"), 353 | ); 354 | }); 355 | containerEl.classList.add("booksidian-plugin__settings"); 356 | } 357 | } 358 | --------------------------------------------------------------------------------