Repository: MichaBrugger/booksidian_plugin Branch: master Commit: 341871e8626c Files: 32 Total size: 50.2 KB Directory structure: gitextract_bkflcujb/ ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE.md ├── README.md ├── const/ │ ├── frontmatter.ts │ ├── goodreads.ts │ ├── rssParser.ts │ └── settings.ts ├── esbuild.config.mjs ├── jest.config.js ├── main.ts ├── manifest.json ├── package.json ├── src/ │ ├── Body.ts │ ├── Book.ts │ ├── Frontmatter.ts │ ├── Shelf.ts │ ├── helpers.ts │ └── settings/ │ ├── Settings.ts │ └── suggesters/ │ ├── FolderSuggester.ts │ └── suggest.ts ├── styles.css ├── test/ │ └── BookTest.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # top-most EditorConfig file root = true [*] charset = utf-8 insert_final_newline = true indent_style = tab indent_size = 4 tab_width = 4 ================================================ FILE: .eslintignore ================================================ npm node_modules build ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "@typescript-eslint/parser", "env": { "node": true }, "plugins": [ "@typescript-eslint" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" ], "parserOptions": { "sourceType": "module" }, "rules": { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", "@typescript-eslint/no-empty-function": "off" } } ================================================ FILE: .github/FUNDING.yml ================================================ ko_fi: michabrugger ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Obsidian plugin on: push: tags: - "*" env: PLUGIN_NAME: booksidian-plugin jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v5 - name: Build id: build run: | npm install npm run build echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT - uses: actions/upload-artifact@v4 with: name: artifacts path: | ./main.js ./manifest.json ./styles.css release: runs-on: ubuntu-latest needs: build permissions: contents: write steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: artifacts - name: Create release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create ${{github.ref}} --generate-notes \ main.js \ manifest.json ================================================ FILE: .gitignore ================================================ # vscode .vscode .devcontainer # Intellij *.iml .idea # npm node_modules # Don't include the compiled main.js file in the repo. # They should be uploaded to GitHub releases instead. main.js # Exclude sourcemaps *.map # obsidian data.json # Exclude macOS Finder (System Explorer) View States .DS_Store ================================================ FILE: .npmrc ================================================ tag-version-prefix="" ================================================ FILE: .prettierrc ================================================ { "trailingComma": "all", "useTabs": true, "semi": true, "singleQuote": false, "tabWidth": 4 } ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2022 MichaBrugger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Booksidian Booksidian brings your Goodreads data to Obsidian. 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). ![image](https://user-images.githubusercontent.com/46029522/152006018-bfab5d8a-e829-4dbd-b19e-84a9af19e258.png) ## Setup Instructions 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. #### Creating Shelves You can create those in Goodreads und `My Books` and then `Add shelf` in the left-side menu: ![image](https://user-images.githubusercontent.com/46029522/152001408-87c88a68-b161-4dfd-9845-d6036a05992b.png) #### Getting the Feed Base-URL 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. ![image](https://user-images.githubusercontent.com/46029522/152004240-2580c551-d603-4119-9dd5-95a3bf68b764.png) 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. ![image](https://user-images.githubusercontent.com/46029522/152002763-444c05e1-3a5f-426b-9493-beb99deb9aa3.png) ### Running Booksidian You can run the Booksidian sync by executing the "Booksidian Sync" command or by pressing the "B" in your menu bar. Alternatively, you can set Booksidian to sync automatically by updating the `frequency` in the plugin settings. ### Overwriting Notes 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. 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. ## Output 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: ![image](https://user-images.githubusercontent.com/46029522/151970426-377a5997-7c15-4670-b423-17bb04b3720a.png) 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: ```dataview table without id ("![](" + cover +")") as Cover, author as Author where cover != null sort rating desc ``` Please check out the amazing work of these two [here](https://github.com/blacksmithgu/obsidian-dataview) and [here](https://github.com/kepano/obsidian-minimal). ### Linking back to Goodreads 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: ``` https://www.goodreads.com/book/show/{{id}} ``` ================================================ FILE: const/frontmatter.ts ================================================ export const FRONTMATTER_LINES = "---"; ================================================ FILE: const/goodreads.ts ================================================ export interface GoodreadsBook { author: string; title: string; link: string; pubDate: string; isbn: string; user_review: string | undefined; book_description: string; user_rating: string; average_rating: string; user_read_at: string; user_date_added: string; user_date_created: string; book_published: string; identifiers: Identifiers; content: string; contentSnippet: string; guid: string; user_shelves: string; image_url: string; image_path: string; } export interface Identifiers { $: Book_id; num_pages: string[]; } export interface Book_id { id: string; } ================================================ FILE: const/rssParser.ts ================================================ // there seems to be an issue with "import Parser from 'rss-parser';" // I've decided to stick with the current way, even though it's not ideal // eslint-disable-next-line @typescript-eslint/no-var-requires const Parser = require("rss-parser"); // making small changes to the returned keys export const rssParser = new Parser({ customFields: { item: [ ["author_name", "author"], "isbn", "user_rating", "user_review", "book_description", "average_rating", "user_read_at", "user_date_added", "user_date_created", "book_published", ["book", "identifiers"], "user_shelves", ["book_large_image_url", "image_url"], ], }, }); ================================================ FILE: const/settings.ts ================================================ export interface BooksidianSettings { targetFolderPath: string; goodreadsBaseUrl: string; goodreadsShelves: string; fileName: string; frontmatterDictionary: CurrentYAML; bodyString: string; frequency: string; overwrite: boolean; coverDownload: boolean; coverDownloadLocation: string; } export interface CurrentYAML { [key: string]: string; } export const DEFAULT_SETTINGS: BooksidianSettings = { targetFolderPath: "", fileName: "{{title}}", goodreadsBaseUrl: "https://www.goodreads.com/review/list_rss/...", goodreadsShelves: "currently-reading", frontmatterDictionary: {}, bodyString: "# {{title}}\n\nauthor::[[{{author}}]]", frequency: "0", // manual overwrite: false, coverDownload: false, coverDownloadLocation: "", }; ================================================ FILE: esbuild.config.mjs ================================================ import esbuild from "esbuild"; import process from "process"; import builtins from 'builtin-modules' const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ `; const prod = (process.argv[2] === 'production'); esbuild.build({ banner: { js: banner, }, entryPoints: ['main.ts'], bundle: true, external: [ 'obsidian', 'electron', '@codemirror/autocomplete', '@codemirror/closebrackets', '@codemirror/collab', '@codemirror/commands', '@codemirror/comment', '@codemirror/fold', '@codemirror/gutter', '@codemirror/highlight', '@codemirror/history', '@codemirror/language', '@codemirror/lint', '@codemirror/matchbrackets', '@codemirror/panel', '@codemirror/rangeset', '@codemirror/rectangular-selection', '@codemirror/search', '@codemirror/state', '@codemirror/stream-parser', '@codemirror/text', '@codemirror/tooltip', '@codemirror/view', ...builtins], format: 'cjs', watch: !prod, target: 'es2016', logLevel: "info", sourcemap: prod ? false : 'inline', treeShaking: true, outfile: 'main.js', }).catch(() => process.exit(1)); ================================================ FILE: jest.config.js ================================================ module.exports = { transform: {'^.+\\.ts?$': 'ts-jest'}, testEnvironment: 'node', testRegex: 'test/.*Test.ts', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleDirectories: ['node_modules', 'src', 'const', 'test'], modulePaths: [''], moduleNameMapper: { "^@/(.*)$": "/src/" } }; ================================================ FILE: main.ts ================================================ import { Plugin } from "obsidian"; import { Shelf } from "src/Shelf"; import { Settings } from "src/settings/Settings"; import { BooksidianSettings, DEFAULT_SETTINGS } from "const/settings"; export default class Booksidian extends Plugin { settings: BooksidianSettings; scheduleInterval: null | number = null; async onload() { await this.loadSettings(); // This creates an icon in the left ribbon. this.addRibbonIcon( "bold-glyph", "Booksidian Sync", (evt: MouseEvent) => { this.updateLibrary(); }, ); // This adds a simple command that can be triggered anywhere this.addCommand({ id: "booksidian-sync", name: "Booksidian Sync", callback: () => { this.updateLibrary(); }, }); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new Settings(this.app, this)); } updateLibrary() { this.settings.goodreadsShelves.split(",").forEach(async (_shelf) => { const shelf = new Shelf(this, _shelf.trim()); await shelf.createFolder(); await shelf.fetchGoodreadsFeed(); await shelf.createBookFiles(); }); } async loadSettings() { this.settings = Object.assign( {}, DEFAULT_SETTINGS, await this.loadData(), ); } async saveSettings() { await this.saveData(this.settings); } async configureSchedule() { const minutes = parseInt(this.settings.frequency); const milliseconds = minutes * 60 * 1000; // minutes * seconds * milliseconds console.log( "Booksidian plugin: setting interval to ", milliseconds, "milliseconds", ); window.clearInterval(this.scheduleInterval); this.scheduleInterval = null; if (!milliseconds) { // we got manual option return; } this.scheduleInterval = window.setInterval( () => this.updateLibrary(), milliseconds, ); this.registerInterval(this.scheduleInterval); } } ================================================ FILE: manifest.json ================================================ { "id": "booksidian-plugin", "name": "Booksidian", "version": "0.10.1", "minAppVersion": "0.12.0", "description": "Connect Obsidian to your Goodreads.", "author": "Micha Brugger and Zachary Wright", "authorUrl": "https://github.com/MichaBrugger", "isDesktopOnly": true } ================================================ FILE: package.json ================================================ { "name": "booksidian", "author": "Micha Brugger", "version": "0.10.1", "description": "Connect Obsidian to your Goodreads.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json" }, "keywords": [], "license": "MIT", "devDependencies": { "@popperjs/core": "^2.11.8", "@types/jest": "^29.4.0", "@types/mustache": "^4.2.2", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "^5.2.0", "@typescript-eslint/parser": "^5.2.0", "builtin-modules": "^3.2.0", "esbuild": "^0.13.12", "jest": "^29.4.0", "obsidian": "^1.5.7-1", "prettier": "^3.2.5", "tslib": "^2.3.1", "typescript": "^4.4.4" }, "dependencies": { "js-yaml": "^4.1.0", "mustache": "^4.2.0", "rss-parser": "^3.12.0", "ts-jest": "^29.0.5", "turndown": "^7.1.2" } } ================================================ FILE: src/Body.ts ================================================ import { Book } from "src/Book"; // Following rssParser example to avoid issue with: import * as Mustache from 'mustache'; // eslint-disable-next-line @typescript-eslint/no-var-requires const Mustache = require("mustache"); export class Body { constructor( public currentBody: string, public book: Book, ) {} public getBody(): string { const render = Mustache.render(this.currentBody, this.book) as string; return render.replaceAll("/", "/"); } } ================================================ FILE: src/Book.ts ================================================ import { CurrentYAML } from "const/settings"; import { GoodreadsBook } from "const/goodreads"; import Booksidian from "main"; import { Body } from "./Body"; import { Frontmatter } from "./Frontmatter"; import { writeFile } from "./helpers"; // eslint-disable-next-line @typescript-eslint/no-var-requires const TurndownService = require("turndown"); export class Book { id: string; pages: number; title: string; rawTitle: string; fullTitle: string; series: string; seriesName: string; seriesNumber: number; subtitle: string; description: string; author: string; isbn: string; review: string; rating: number; avgRating: number; shelves: string[]; dateAdded: string; dateCreated: string; dateRead: string; datePublished: string; cover: string; coverImage: string; bookPage: string; constructor( public plugin: Booksidian, book: GoodreadsBook, ) { this.id = book.identifiers.$.id; this.pages = parseInt(book.identifiers.num_pages[0]) || undefined; this.title = this.cleanTitle(book.title, false); this.rawTitle = book.title; this.fullTitle = this.cleanTitle(book.title, true); this.description = this.htmlToMarkdown(book.book_description); this.author = book.author; this.isbn = book.isbn; this.review = this.htmlToMarkdown(book.user_review || ""); this.rating = parseInt(book.user_rating) || 0; this.avgRating = parseFloat(book.average_rating) || 0; this.dateAdded = this.parseDate(book.user_date_added); this.dateCreated = this.parseDate(book.user_date_created); this.dateRead = this.parseDate(book.user_read_at); this.datePublished = this.parseDate(book.book_published); this.cover = book.image_url; this.coverImage = book.image_path; this.shelves = this.getShelves(book.user_shelves, this.dateRead); this.bookPage = `https://www.goodreads.com/book/show/${this.id}`; } public getTitle(): string { return this.title; } public getContent(): string { const set = this.plugin.settings; try { return ( this.getFrontMatter(set.frontmatterDictionary) + this.getBody(set.bodyString) ); } catch (error) { console.log(error); } } private htmlToMarkdown(html: string) { const turndownService = new TurndownService(); return turndownService.turndown(html); } private getShelves(shelves: string, dateRead: string): string[] { // Goodreads doesn't send a shelf value for books on the read shelf. // Infer from either a missing shelf value, or a set dateRead. // Check for presence of read first in case Goodreads decides to include it. const outputShelves = shelves .split(",") .map((shelf) => shelf.trim()) // trim shelf names .filter((shelf) => shelf); // filter out empty shelf names // If the book has a read date and the `read` shelf is missing, we add it if (dateRead && !outputShelves.includes("read")) outputShelves.push("read"); return outputShelves; } private getBody(currentBody: string): string { return new Body(currentBody, this).getBody(); } private getFrontMatter(currentYAML: CurrentYAML): string { if (Object.keys(currentYAML).length > 0) { return new Frontmatter(currentYAML, this).getFrontmatter(); } return ""; } public async createFile(book: Book, path: string): Promise { const fileName = this.getBody(this.plugin.settings.fileName); const fullPath = `${path}/${fileName}.md`; const file = this.plugin.app.vault.getFileByPath(fullPath); if (file && !this.plugin.settings.overwrite) return; const bookContent = book.getContent(); writeFile(fullPath, bookContent, this.plugin.app); } private cleanTitle(title: string, full: boolean) { this.series = ""; this.seriesName = ""; this.seriesNumber = 0; this.subtitle = ""; let series = ""; if (title.includes("(") && title.includes("#")) { series = this.getSeries(title); } title = title.replace(series, ""); if (title.includes(":")) { this.getSubTitle(title); } if (!full) { title = title.split(":")[0]; } // replace remaining special characters with an empty character title = title.replace(/[&\/\\#,+()$~%.'":*?<>{}|]/g, ""); return title.trim(); } private getSeries(title: string): string { // only calculate once per book if (this.series) { return this.series; } let match = title.match(/.+ \(((.+?),? #(\d+))\)/); if (match) { this.series = match[1].trim(); this.seriesName = match[2].trim(); this.seriesNumber = parseInt(match[3].trim(), 10); return `(${match[1]})`; } console.log( `New get series parser failed for "${title}", falling back to legacy parser.`, ); // fallback to old method, this is mostly for backwards compatibility in case of edge cases match = title.match(/\((.*?)\)/); if (match && match[1].contains("#")) { this.series = match[1].trim(); return match[0]; } return ""; } private getSubTitle(title: string) { this.subtitle = title.split(":")[1].trim(); } private parseDate(inputDate: string) { if (inputDate == "") { return ""; } const date = new Date(inputDate); return date.toISOString().substring(0, 10); } } ================================================ FILE: src/Frontmatter.ts ================================================ import { FRONTMATTER_LINES } from "const/frontmatter"; import { CurrentYAML } from "const/settings"; import { Book } from "src/Book"; // eslint-disable-next-line @typescript-eslint/no-var-requires const yaml = require("js-yaml"); export class Frontmatter { constructor( public currentYAML: CurrentYAML, public book: Book, ) {} public getFrontmatter(): string { return ( FRONTMATTER_LINES + "\n" + this.getFrontmatterLines() + FRONTMATTER_LINES + "\n" ); } private getFrontmatterLines(): string { const output: { [key: string]: number | string | string[] } = {}; Object.keys(this.currentYAML).forEach((key: string) => { const value = this.currentYAML[key]; const [prefix, postfix] = value.split(key); if (key === "shelves") { output[key] = this.book.shelves.sort().map((shelf) => { return `${prefix}${shelf}${postfix}`; }); } else { // If this a simple link, and the value of the string is empty, don't insert [[]] if ( value == `[[${key}]]` && this.book[key as keyof Book] == "" ) { output[key] = ""; } else { output[key] = `${prefix}${this.book[key as keyof Book]}${postfix}`; } } }); return yaml.dump(output); } } ================================================ FILE: src/Shelf.ts ================================================ import { rssParser } from "const/rssParser"; import { GoodreadsBook } from "const/goodreads"; import { Book } from "./Book"; import Booksidian from "main"; import { Notice } from "obsidian"; import * as nodeFs from "fs"; import { isAbsolute } from "path"; import { pathExist, writeBinaryFile } from "./helpers"; import { get } from "https"; export class Shelf { path: string; url: string; books: Book[] = []; constructor( public plugin: Booksidian, public shelfName: string, ) { const targetFolder = plugin.settings.targetFolderPath; this.path = targetFolder === "" ? "./" : targetFolder; this.url = `${plugin.settings.goodreadsBaseUrl}${shelfName.toLocaleLowerCase()}`; } private setBook(book: Book): void { this.books.push(book); } public getBooks(): Book[] { return this.books; } // create folder for each shelf (based on targetFolderPath) public async createFolder(): Promise { if (isAbsolute(this.path)) { nodeFs.mkdir(this.path, { recursive: true }, (err) => { if (err) console.log(err); }); } else { try { await this.plugin.app.vault.createFolder(this.path); } catch (e) { if (e.message.includes("already exists")) return; console.warn(e); } } } public async fetchGoodreadsFeed(): Promise { try { let page = 1; while (true) { const pagedUrl = `${this.url}&page=${page}&per_page=100`; const feed = await rssParser.parseURL(pagedUrl); if (!feed.items) break; for (const _book of feed.items as GoodreadsBook[]) { const book = new Book(this.plugin, _book); book.coverImage = await this.fetchCoverImage( book.cover, book.id, ); // If we're currently explicitly checking the `read` shelf, we add it if ( this.shelfName === "read" && !book.shelves.contains("read") ) book.shelves.push("read"); this.setBook(book); } page++; if (!feed.items.length) break; } } catch (e) { console.warn(e); } } private async fetchCoverImage(url: string, title: string) { if (!this.plugin.settings.coverDownload) return; let coverDownloadLocation = this.plugin.settings.coverDownloadLocation; if (coverDownloadLocation === "") coverDownloadLocation = `${this.plugin.settings.targetFolderPath || "."}/cover`; const fullPath = `${coverDownloadLocation}/${title}.jpg`; if (pathExist(fullPath)) return fullPath; get(url, (response) => { response.setEncoding("binary"); let rawData = new Uint16Array(); response.on("data", (chunk) => (rawData += chunk)); response.on("end", () => writeBinaryFile(fullPath, rawData)); }); return fullPath; } public async createBookFiles(): Promise { await Promise.all([ this.getBooks().map((book) => book.createFile(book, this.path)), ]); this.createNotice(); } private createNotice() { const syncCount: number = this.getBooks().length; if (syncCount === 0) { return; } const firstTitle = this.getBooks()[0].rawTitle; let noticeMsg = ""; if (syncCount === 1) { noticeMsg = `${firstTitle} synced from Goodreads!`; } else { noticeMsg = `${this.getBooks().length} books, including ${firstTitle}, synced from Goodreads!`; } new Notice(noticeMsg, 5000); } } ================================================ FILE: src/helpers.ts ================================================ import { isAbsolute, dirname } from "path"; import * as nodeFs from "fs"; import { App } from "obsidian"; export async function writeFile(path: string, content: string, app: App) { if (isAbsolute(path)) { nodeFs.writeFile(path, content, (error) => { if (error) console.log(`Error writing ${path}`, error); }); } else { try { const fs = app.vault.adapter; await fs.write(path, content); } catch (error) { console.log(`Error writing ${path}`, error); } } } export async function writeBinaryFile(path: string, content: Uint16Array) { const filePath = isAbsolute(path) ? path : `${this.app.vault.adapter.basePath}/${path}`; const directory = dirname(filePath); if (!nodeFs.existsSync(directory)) nodeFs.mkdirSync(directory); try { nodeFs.writeFileSync(filePath, content, { encoding: "binary" }); } catch (error) { console.log(`Error writing ${filePath}`, error); } } export function pathExist(path: string) { const filePath = isAbsolute(path) ? path : `${this.app.vault.adapter.basePath}/${path}`; return nodeFs.existsSync(filePath); } ================================================ FILE: src/settings/Settings.ts ================================================ import { App, debounce, Notice, PluginSettingTab, Setting } from "obsidian"; import { FolderSuggest } from "./suggesters/FolderSuggester"; import Booksidian from "../../main"; const debouncedSaveSettings = debounce( (callback: () => void) => callback(), 500, true, ); export class Settings extends PluginSettingTab { plugin: Booksidian; currentYAML: { [key: string]: string }; constructor(app: App, plugin: Booksidian) { super(app, plugin); this.plugin = plugin; this.currentYAML = plugin.settings.frontmatterDictionary; } getSelectedCount(): string { const selected = Object.keys(this.getYAML()).length; const total = 20; return `${selected}/${total}`; } // eslint-disable-next-line @typescript-eslint/ban-types private getYAML(): { [key: string]: string } { return this.currentYAML; } getDisplay(option: string, label?: string): string { label = label ? label : option; if (this.optionIsSelected(option)) { return "🟢 - " + label; } return "⚫ - " + label; } optionIsSelected(option: string): boolean { return this.currentYAML.hasOwnProperty(option); } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h3", { text: "Goodreads RSS Feed" }); // set the target folder for the exports new Setting(containerEl) .setName("Target Folder") .setDesc( "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.", ) .addSearch((cb) => { try { new FolderSuggest(this.app, cb.inputEl); } catch (e) { console.error(e); // Improved error handling } cb.setPlaceholder("Vault root") .setValue(this.plugin.settings.targetFolderPath) .onChange(async (value) => { this.plugin.settings.targetFolderPath = value .replace( /[\\/]+$/g, // matches any trailing slashes "", ) .trim(); await this.plugin.saveSettings(); }); }); // set the base url for all goodreads rss feeds new Setting(containerEl) .setName("RSS Base URL") .setDesc( "Please add your RSS Base URL here (everything before the shelf name).", ) .setTooltip("https://www.goodreads.com/ ... &shelf=") .addText((text) => { text.setValue(this.plugin.settings.goodreadsBaseUrl) .setPlaceholder("https://www.goodreads.com/ ... &shelf=") .onChange(async (value) => { debouncedSaveSettings(async () => { const validPattern = /^https?:\/\/.*?\/review\/list_rss\/\d+\?key=[a-zA-Z0-9-_]+&shelf=/; const result = value.trim().match(validPattern); // Save the url only when it matches the pattern if (result) { this.plugin.settings.goodreadsBaseUrl = result[0]; text.inputEl.value = result[0]; } else if (value.trim().length === 0) { this.plugin.settings.goodreadsBaseUrl = ""; } else { new Notice( "Booksidian: Could not parse RSS Base URL", ); return; } await this.plugin.saveSettings(); }); }); text.inputEl.style.minWidth = "18rem"; text.inputEl.style.maxWidth = "18rem"; }); // set the goodreads shelves that should be exported new Setting(containerEl) .setName("Your Goodreads Shelves") .setDesc( "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. ", ) .setTooltip("You can check the proper naming in the RSS url.") .addTextArea((text) => { text.inputEl.rows = 6; text.setPlaceholder("Your Shelves") .setValue(this.plugin.settings.goodreadsShelves) .onChange(async (value) => { this.plugin.settings.goodreadsShelves = value; await this.plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Configure resync frequency") .setDesc( "If not set to manual, Booksidian will resync with Goodreads RSS at configured interval", ) .addDropdown((dropdown) => { dropdown.addOption("0", "Manual"); dropdown.addOption("60", "Every 1 hour"); dropdown.addOption((12 * 60).toString(), "Every 12 hours"); dropdown.addOption((24 * 60).toString(), "Every 24 hours"); dropdown.setValue(this.plugin.settings.frequency); dropdown.onChange((newValue) => { this.plugin.settings.frequency = newValue; this.plugin.saveSettings(); this.plugin.configureSchedule(); }); }); new Setting(containerEl) .setName("Overwrite") .setDesc( "When syncing with Goodreads, overwrite existing notes. Modifications to notes will be lost, but changes from Goodreads will now be picked up.", ) .addToggle((toggle) => { toggle.setValue(this.plugin.settings.overwrite); toggle.onChange((newValue) => { this.plugin.settings.overwrite = newValue; this.plugin.saveSettings(); }); }); containerEl.createEl("h4", { text: "Book covers" }); new Setting(containerEl) .setName("Download covers") .setDesc( "Whether the cover image for each book should be downloaded", ) .addToggle((toggle) => { toggle.setValue(this.plugin.settings.coverDownload); toggle.onChange( async (value) => (this.plugin.settings.coverDownload = value), ); }); new Setting(containerEl) .setName("Cover download folder") .setDesc( '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.', ) .addSearch((cb) => { try { new FolderSuggest(this.app, cb.inputEl); } catch (e) { console.error(e); // Improved error handling } cb.setPlaceholder("Target Folder/cover") .setValue(this.plugin.settings.coverDownloadLocation) .onChange(async (value) => { this.plugin.settings.coverDownloadLocation = value.trim(); await this.plugin.saveSettings(); }); }); containerEl.createEl("h3", { text: "Body" }); containerEl.createEl("p", { 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.", }); // set the title of the book-note new Setting(containerEl) .setName("Naming Pattern") .setTooltip("You don't need to add '.md' to the filename") .addText((text) => { text.setValue(this.plugin.settings.fileName); text.onChange(async (value) => { this.plugin.settings.fileName = value; await this.plugin.saveSettings(); }); }); // set the body content of the book-note new Setting(containerEl) .setName("Content of the book-note") .setTooltip("Don't forget to wrap the placeholders in {{}}.") .addTextArea((text) => { text.inputEl.rows = 6; text.setValue(this.plugin.settings.bodyString); text.onChange(async (value) => { this.plugin.settings.bodyString = value; await this.plugin.saveSettings(); }); }); containerEl.createEl("h3", { text: "Frontmatter" }); if (Object.keys(this.currentYAML).length > 0) { containerEl.createEl("p", { text: "You can add custom frontmatter to your books. Please use the dropdown to choose the frontmatter you'd like to add.", }); } // containerEl.createEl("pre", { // text: "key: value", // attr: { style: "font-size: 12px; color: #999;" }, // }); // } new Setting(containerEl) .setName("Available Fields") .addDropdown((dropdown) => dropdown .addOption("", `${this.getSelectedCount()}`) .addOption("id", `${this.getDisplay("id")}`) .addOption("author", `${this.getDisplay("author")}`) .addOption( "title", `${this.getDisplay("title", "title (formatted for filenames/links)")}`, ) .addOption( "fullTitle", `${this.getDisplay("fullTitle", "fullTitle (formatted, includes subtitle)")}`, ) .addOption("rawTitle", `${this.getDisplay("rawTitle")}`) .addOption("subtitle", `${this.getDisplay("subtitle")}`) .addOption("pages", `${this.getDisplay("pages")}`) .addOption("series", `${this.getDisplay("series")}`) .addOption("seriesName", `${this.getDisplay("seriesName")}`) .addOption( "seriesNumber", `${this.getDisplay("seriesNumber")}`, ) .addOption( "description", `${this.getDisplay("description")}`, ) .addOption("cover", `${this.getDisplay("cover")}`) .addOption("coverImage", `${this.getDisplay("coverImage")}`) .addOption("isbn", `${this.getDisplay("isbn")}`) .addOption("review", `${this.getDisplay("review")}`) .addOption("rating", `${this.getDisplay("rating")}`) .addOption("avgRating", `${this.getDisplay("avgRating")}`) .addOption("dateAdded", `${this.getDisplay("dateAdded")}`) .addOption( "dateCreated", `${this.getDisplay("dateCreated")}`, ) .addOption("dateRead", `${this.getDisplay("dateRead")}`) .addOption( "datePublished", `${this.getDisplay("datePublished")}`, ) .addOption("shelves", `${this.getDisplay("shelves")}`) .addOption("bookPage", `${this.getDisplay("bookPage")}`) .onChange(async (value: string) => { if (this.optionIsSelected(value)) { delete this.currentYAML[value]; } else { if (value === "coverImage") // we want coverImage to default to a link this.currentYAML[value] = `[[${value}]]`; else this.currentYAML[value] = value; } await this.plugin.saveSettings(); this.display(); }), ) .addExtraButton((button) => button .onClick(async () => { this.display(); }) .setIcon("sync") .setTooltip("Refresh Previews"), ); Object.keys(this.currentYAML).forEach((key) => { const value = this.currentYAML[key]; new Setting(containerEl) .setName(key + ": " + value) .addExtraButton( (button) => button .setTooltip("Convert to link") .onClick(async () => { if (value.startsWith("[[")) { this.currentYAML[key] = value.replace( /[[\]]/g, "", ); } else { this.currentYAML[key] = "[[" + value + "]]"; } await this.plugin.saveSettings(); this.display(); }) .setIcon("bracket-glyph").setTooltip, ) .addText((text) => text .setPlaceholder("") .setValue(this.currentYAML[key]) .onChange(async (value) => { this.currentYAML[key] = value; await this.plugin.saveSettings(); }), ) .addExtraButton((button) => button .onClick(async () => { delete this.currentYAML[key]; await this.plugin.saveSettings(); this.display(); }) .setIcon("trash") .setTooltip("Remove"), ); }); containerEl.classList.add("booksidian-plugin__settings"); } } ================================================ FILE: src/settings/suggesters/FolderSuggester.ts ================================================ // copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/FolderSuggester.ts // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes import { TAbstractFile, TFolder } from "obsidian"; import { TextInputSuggest } from "./suggest"; export class FolderSuggest extends TextInputSuggest { getSuggestions(inputStr: string): TFolder[] { const abstractFiles = this.app.vault.getAllLoadedFiles(); const folders: TFolder[] = []; const lowerCaseInputStr = inputStr.toLowerCase(); abstractFiles.forEach((folder: TAbstractFile) => { if ( folder instanceof TFolder && folder.path.toLowerCase().contains(lowerCaseInputStr) ) { folders.push(folder); } }); return folders; } renderSuggestion(file: TFolder, el: HTMLElement): void { el.setText(file.path); } selectSuggestion(file: TFolder): void { this.inputEl.value = file.path; this.inputEl.trigger("input"); this.close(); } } ================================================ FILE: src/settings/suggesters/suggest.ts ================================================ // copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/suggest.ts // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes import { App, ISuggestOwner, Scope } from "obsidian"; import { createPopper, Instance as PopperInstance } from "@popperjs/core"; const wrapAround = (value: number, size: number): number => { return ((value % size) + size) % size; }; class Suggest { private owner: ISuggestOwner; private values: T[]; private suggestions: HTMLDivElement[]; private selectedItem: number; private containerEl: HTMLElement; constructor( owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope, ) { this.owner = owner; this.containerEl = containerEl; containerEl.on( "click", ".suggestion-item", this.onSuggestionClick.bind(this), ); containerEl.on( "mousemove", ".suggestion-item", this.onSuggestionMouseover.bind(this), ); scope.register([], "ArrowUp", (event) => { if (!event.isComposing) { this.setSelectedItem(this.selectedItem - 1, true); return false; } }); scope.register([], "ArrowDown", (event) => { if (!event.isComposing) { this.setSelectedItem(this.selectedItem + 1, true); return false; } }); scope.register([], "Enter", (event) => { if (!event.isComposing) { this.useSelectedItem(event); return false; } }); } onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { event.preventDefault(); const item = this.suggestions.indexOf(el); this.setSelectedItem(item, false); this.useSelectedItem(event); } onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { const item = this.suggestions.indexOf(el); this.setSelectedItem(item, false); } setSuggestions(values: T[]) { this.containerEl.empty(); const suggestionEls: HTMLDivElement[] = []; values.forEach((value) => { const suggestionEl = this.containerEl.createDiv("suggestion-item"); this.owner.renderSuggestion(value, suggestionEl); suggestionEls.push(suggestionEl); }); this.values = values; this.suggestions = suggestionEls; this.setSelectedItem(0, false); } useSelectedItem(event: MouseEvent | KeyboardEvent) { const currentValue = this.values[this.selectedItem]; if (currentValue) { this.owner.selectSuggestion(currentValue, event); } } setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { const normalizedIndex = wrapAround( selectedIndex, this.suggestions.length, ); const prevSelectedSuggestion = this.suggestions[this.selectedItem]; const selectedSuggestion = this.suggestions[normalizedIndex]; prevSelectedSuggestion?.removeClass("is-selected"); selectedSuggestion?.addClass("is-selected"); this.selectedItem = normalizedIndex; if (scrollIntoView) { selectedSuggestion.scrollIntoView(false); } } } export abstract class TextInputSuggest implements ISuggestOwner { private popper: PopperInstance; private scope: Scope; private suggestEl: HTMLElement; private suggest: Suggest; constructor( protected app: App, protected inputEl: HTMLInputElement | HTMLTextAreaElement, ) { this.scope = new Scope(); this.suggestEl = createDiv("suggestion-container"); const suggestion = this.suggestEl.createDiv("suggestion"); this.suggest = new Suggest(this, suggestion, this.scope); this.scope.register([], "Escape", this.close.bind(this)); this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); this.inputEl.addEventListener("blur", this.close.bind(this)); this.suggestEl.on( "mousedown", ".suggestion-container", (event: MouseEvent) => { event.preventDefault(); }, ); } onInputChanged(): void { const inputStr = this.inputEl.value; const suggestions = this.getSuggestions(inputStr); if (!suggestions) { this.close(); return; } if (suggestions.length > 0) { this.suggest.setSuggestions(suggestions); // eslint-disable-next-line @typescript-eslint/no-explicit-any this.open((this.app).dom.appContainerEl, this.inputEl); } else { this.close(); } } open(container: HTMLElement, inputEl: HTMLElement): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.app).keymap.pushScope(this.scope); container.appendChild(this.suggestEl); this.popper = createPopper(inputEl, this.suggestEl, { placement: "bottom-start", modifiers: [ { name: "sameWidth", enabled: true, fn: ({ state, instance }) => { // Note: positioning needs to be calculated twice - // first pass - positioning it according to the width of the popper // second pass - position it with the width bound to the reference element // we need to early exit to avoid an infinite loop const targetWidth = `${state.rects.reference.width}px`; if (state.styles.popper.width === targetWidth) { return; } state.styles.popper.width = targetWidth; instance.update(); }, phase: "beforeWrite", requires: ["computeStyles"], }, ], }); } close(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.app).keymap.popScope(this.scope); this.suggest.setSuggestions([]); if (this.popper) this.popper.destroy(); this.suggestEl.detach(); } abstract getSuggestions(inputStr: string): T[]; abstract renderSuggestion(item: T, el: HTMLElement): void; abstract selectSuggestion(item: T): void; } ================================================ FILE: styles.css ================================================ .booksidian-plugin__settings .search-input-container { width: 100%; } .booksidian-plugin__settings input, textarea, select { min-width: 200px; } ================================================ FILE: test/BookTest.ts ================================================ import { Book } from "src/Book"; import { GoodreadsBook } from "const/goodreads"; import { Book_id } from "const/goodreads"; import { Identifiers } from "const/goodreads"; const test_book_id: Book_id = { id: "sample_id", }; const test_identifier: Identifiers = { $: test_book_id, num_pages: ["123"], }; const test_book: GoodreadsBook = { author: "test_author", title: "test_title", link: "test_link", pubDate: "01/01/1970", isbn: "0123456789", user_rating: "5", user_review: "Test review", book_description: "Test description", average_rating: "3", user_read_at: "01/01/1970", user_date_added: "01/01/1970", user_date_created: "01/01/1970", book_published: "01/01/1970", identifiers: test_identifier, content: "test_content", contentSnippet: "test_content_snippet", guid: "test_guid", user_shelves: "test_shelf", image_url: "test_image_url", image_path: "test_image_url", }; describe("Empty title", () => { test("empty title should result in empty_string", () => { // Given test_book.title = ""; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe(""); }); }); describe("No special character title", () => { test("No special character title should result in title", () => { // Given test_book.title = "My wonderful book"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '?' character", () => { test("Title with '?' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book?"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '#' character", () => { test("Title with '#' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book#"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '&' character", () => { test("Title with '&' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book&"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '{' character", () => { test("Title with '{' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book{"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '}' character", () => { test("Title with '}' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book}"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '%' character", () => { test("Title with '%' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book%"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '<' character", () => { test("Title with '<' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book<"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '>' character", () => { test("Title with '>' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book>"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '$' character", () => { test("Title with '$' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book$"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '*' character", () => { test("Title with '*' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book*"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '|' character", () => { test("Title with '|' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book|"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '\\' character", () => { test("Title with '\\' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book\\"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '/' character", () => { test("Title with '/' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book/"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with ':' character", () => { test("Title with ':' character should have it replaced with empty char", () => { // Given test_book.title = "My wonderful book:"; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Title with '\"' character", () => { test("Title with '\"' character should have it replaced with empty char", () => { // Given test_book.title = 'My wonderful book"'; // When const unit = new Book(null, test_book); // Then expect(unit.title).toBe("My wonderful book"); }); }); describe("Series information parser", () => { test("No series", () => { // Given test_book.title = 'My wonderful book'; // When const unit = new Book(null, test_book); // Then expect(unit.series).toBe(""); expect(unit.seriesName).toBe(""); expect(unit.seriesNumber).toBe(0); }); test("Series with (, #)", () => { // Given test_book.title = 'My wonderful book (My series, #15)'; // When const unit = new Book(null, test_book); // Then expect(unit.series).toBe("My series, #15"); expect(unit.seriesName).toBe("My series"); expect(unit.seriesNumber).toBe(15); }); test("Series with ( #)", () => { // Given test_book.title = 'My wonderful book (My series #15)'; // When const unit = new Book(null, test_book); // Then expect(unit.series).toBe("My series #15"); expect(unit.seriesName).toBe("My series"); expect(unit.seriesNumber).toBe(15); }); test("Series without number", () => { // Given test_book.title = 'My wonderful book (My series)'; // When const unit = new Book(null, test_book); // Then expect(unit.series).toBe(""); expect(unit.seriesName).toBe(""); expect(unit.seriesNumber).toBe(0); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES6", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", "importHelpers": true, "isolatedModules": true, "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021.String"] }, "include": ["**/*.ts"] } ================================================ FILE: version-bump.mjs ================================================ import { readFileSync, writeFileSync } from "fs"; const targetVersion = process.env.npm_package_version; // read minAppVersion from manifest.json and bump version to target version let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); const { minAppVersion } = manifest; manifest.version = targetVersion; writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); // update versions.json with target version and minAppVersion from manifest.json let versions = JSON.parse(readFileSync("versions.json", "utf8")); versions[targetVersion] = minAppVersion; writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); ================================================ FILE: versions.json ================================================ { "0.1.0": "0.12.0", "0.1.1": "0.12.0", "0.1.2": "0.12.0", "0.1.3": "0.12.0", "0.2.0": "0.12.0", "0.3.0": "0.12.0", "0.3.1": "0.12.0", "0.3.2": "0.12.0", "0.3.3": "0.12.0", "0.3.4": "0.12.0", "0.3.5": "0.12.0", "0.3.6": "0.12.0", "0.3.7": "0.12.0", "0.4.0": "0.12.0", "0.4.1": "0.12.0", "0.5.0": "0.12.0", "0.5.1": "0.12.0", "0.5.2": "0.12.0", "0.6.0": "0.12.0", "0.6.1": "0.12.0", "0.7.0": "0.12.0", "0.8.0": "0.12.0", "0.8.1": "0.12.0", "0.9.0": "0.12.0", "0.9.1": "0.12.0", "0.9.2": "0.12.0", "0.9.3": "0.12.0", "0.10.0": "0.12.0", "0.10.1": "0.12.0" }