master 341871e8626c cached
32 files
50.2 KB
14.5k tokens
67 symbols
1 requests
Download .txt
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: ['<rootDir>'],
    moduleNameMapper: {
      "^@/(.*)$": "<rootDir>/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("&#x2F;", "/");
	}
}


================================================
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<void> {
		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<void> {
		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<void> {
		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<void> {
		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<TFolder> {
	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<T> {
	private owner: ISuggestOwner<T>;
	private values: T[];
	private suggestions: HTMLDivElement[];
	private selectedItem: number;
	private containerEl: HTMLElement;

	constructor(
		owner: ISuggestOwner<T>,
		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<T> implements ISuggestOwner<T> {
	private popper: PopperInstance;
	private scope: Scope;
	private suggestEl: HTMLElement;
	private suggest: Suggest<T>;

	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((<any>this.app).dom.appContainerEl, this.inputEl);
		} else {
			this.close();
		}
	}

	open(container: HTMLElement, inputEl: HTMLElement): void {
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		(<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
		(<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"
}
Download .txt
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
Download .txt
SYMBOL INDEX (67 symbols across 12 files)

FILE: const/frontmatter.ts
  constant FRONTMATTER_LINES (line 1) | const FRONTMATTER_LINES = "---";

FILE: const/goodreads.ts
  type GoodreadsBook (line 1) | interface GoodreadsBook {
  type Identifiers (line 24) | interface Identifiers {
  type Book_id (line 29) | interface Book_id {

FILE: const/settings.ts
  type BooksidianSettings (line 1) | interface BooksidianSettings {
  type CurrentYAML (line 14) | interface CurrentYAML {
  constant DEFAULT_SETTINGS (line 18) | const DEFAULT_SETTINGS: BooksidianSettings = {

FILE: main.ts
  class Booksidian (line 6) | class Booksidian extends Plugin {
    method onload (line 10) | async onload() {
    method updateLibrary (line 35) | updateLibrary() {
    method loadSettings (line 44) | async loadSettings() {
    method saveSettings (line 52) | async saveSettings() {
    method configureSchedule (line 56) | async configureSchedule() {

FILE: src/Body.ts
  class Body (line 8) | class Body {
    method constructor (line 9) | constructor(
    method getBody (line 14) | public getBody(): string {

FILE: src/Book.ts
  class Book (line 11) | class Book {
    method constructor (line 36) | constructor(
    method getTitle (line 61) | public getTitle(): string {
    method getContent (line 65) | public getContent(): string {
    method htmlToMarkdown (line 77) | private htmlToMarkdown(html: string) {
    method getShelves (line 82) | private getShelves(shelves: string, dateRead: string): string[] {
    method getBody (line 98) | private getBody(currentBody: string): string {
    method getFrontMatter (line 102) | private getFrontMatter(currentYAML: CurrentYAML): string {
    method createFile (line 109) | public async createFile(book: Book, path: string): Promise<void> {
    method cleanTitle (line 121) | private cleanTitle(title: string, full: boolean) {
    method getSeries (line 148) | private getSeries(title: string): string {
    method getSubTitle (line 175) | private getSubTitle(title: string) {
    method parseDate (line 179) | private parseDate(inputDate: string) {

FILE: src/Frontmatter.ts
  class Frontmatter (line 8) | class Frontmatter {
    method constructor (line 9) | constructor(
    method getFrontmatter (line 14) | public getFrontmatter(): string {
    method getFrontmatterLines (line 24) | private getFrontmatterLines(): string {

FILE: src/Shelf.ts
  class Shelf (line 11) | class Shelf {
    method constructor (line 16) | constructor(
    method setBook (line 26) | private setBook(book: Book): void {
    method getBooks (line 30) | public getBooks(): Book[] {
    method createFolder (line 35) | public async createFolder(): Promise<void> {
    method fetchGoodreadsFeed (line 50) | public async fetchGoodreadsFeed(): Promise<void> {
    method fetchCoverImage (line 83) | private async fetchCoverImage(url: string, title: string) {
    method createBookFiles (line 106) | public async createBookFiles(): Promise<void> {
    method createNotice (line 113) | private createNotice() {

FILE: src/helpers.ts
  function writeFile (line 5) | async function writeFile(path: string, content: string, app: App) {
  function writeBinaryFile (line 20) | async function writeBinaryFile(path: string, content: Uint16Array) {
  function pathExist (line 35) | function pathExist(path: string) {

FILE: src/settings/Settings.ts
  class Settings (line 11) | class Settings extends PluginSettingTab {
    method constructor (line 15) | constructor(app: App, plugin: Booksidian) {
    method getSelectedCount (line 21) | getSelectedCount(): string {
    method getYAML (line 28) | private getYAML(): { [key: string]: string } {
    method getDisplay (line 32) | getDisplay(option: string, label?: string): string {
    method optionIsSelected (line 41) | optionIsSelected(option: string): boolean {
    method display (line 45) | display(): void {

FILE: src/settings/suggesters/FolderSuggester.ts
  class FolderSuggest (line 7) | class FolderSuggest extends TextInputSuggest<TFolder> {
    method getSuggestions (line 8) | getSuggestions(inputStr: string): TFolder[] {
    method renderSuggestion (line 25) | renderSuggestion(file: TFolder, el: HTMLElement): void {
    method selectSuggestion (line 29) | selectSuggestion(file: TFolder): void {

FILE: src/settings/suggesters/suggest.ts
  class Suggest (line 11) | class Suggest<T> {
    method constructor (line 18) | constructor(
    method onSuggestionClick (line 59) | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
    method onSuggestionMouseover (line 67) | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
    method setSuggestions (line 72) | setSuggestions(values: T[]) {
    method useSelectedItem (line 87) | useSelectedItem(event: MouseEvent | KeyboardEvent) {
    method setSelectedItem (line 94) | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
  method constructor (line 119) | constructor(
  method onInputChanged (line 143) | onInputChanged(): void {
  method open (line 161) | open(container: HTMLElement, inputEl: HTMLElement): void {
  method close (line 191) | close(): void {
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (59K chars).
[
  {
    "path": ".editorconfig",
    "chars": 148,
    "preview": "# top-most EditorConfig file\r\nroot = true\r\n\r\n[*]\r\ncharset = utf-8\r\ninsert_final_newline = true\r\nindent_style = tab\r\ninde"
  },
  {
    "path": ".eslintignore",
    "chars": 22,
    "preview": "npm node_modules\nbuild"
  },
  {
    "path": ".eslintrc",
    "chars": 627,
    "preview": "{\n    \"root\": true,\n    \"parser\": \"@typescript-eslint/parser\",\n    \"env\": { \"node\": true },\n    \"plugins\": [\n      \"@typ"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 20,
    "preview": "ko_fi: michabrugger\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 1066,
    "preview": "name: Release Obsidian plugin\n\non:\n  push:\n    tags:\n      - \"*\"\n\nenv:\n  PLUGIN_NAME: booksidian-plugin\n\njobs:\n  build:\n"
  },
  {
    "path": ".gitignore",
    "chars": 330,
    "preview": "# vscode\r\n.vscode\r\n.devcontainer\r\n\r\n# Intellij\r\n*.iml\r\n.idea\r\n\r\n# npm\r\nnode_modules\r\n\r\n# Don't include the compiled main"
  },
  {
    "path": ".npmrc",
    "chars": 21,
    "preview": "tag-version-prefix=\"\""
  },
  {
    "path": ".prettierrc",
    "chars": 100,
    "preview": "{\n\t\"trailingComma\": \"all\",\n\t\"useTabs\": true,\n\t\"semi\": true,\n\t\"singleQuote\": false,\n\t\"tabWidth\": 4\n}\n"
  },
  {
    "path": "LICENSE.md",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2022 MichaBrugger\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 3534,
    "preview": "# Booksidian\r\n\r\nBooksidian brings your Goodreads data to Obsidian.\r\n\r\nYou can set both the body and the frontmatter for "
  },
  {
    "path": "const/frontmatter.ts",
    "chars": 40,
    "preview": "export const FRONTMATTER_LINES = \"---\";\n"
  },
  {
    "path": "const/goodreads.ts",
    "chars": 587,
    "preview": "export interface GoodreadsBook {\n\tauthor: string;\n\ttitle: string;\n\tlink: string;\n\tpubDate: string;\n\tisbn: string;\n\tuser_"
  },
  {
    "path": "const/rssParser.ts",
    "chars": 666,
    "preview": "// there seems to be an issue with \"import Parser from 'rss-parser';\"\n// I've decided to stick with the current way, eve"
  },
  {
    "path": "const/settings.ts",
    "chars": 746,
    "preview": "export interface BooksidianSettings {\n\ttargetFolderPath: string;\n\tgoodreadsBaseUrl: string;\n\tgoodreadsShelves: string;\n\t"
  },
  {
    "path": "esbuild.config.mjs",
    "chars": 1176,
    "preview": "import esbuild from \"esbuild\";\nimport process from \"process\";\nimport builtins from 'builtin-modules'\n\nconst banner =\n`/*"
  },
  {
    "path": "jest.config.js",
    "chars": 359,
    "preview": "module.exports = {\n    transform: {'^.+\\\\.ts?$': 'ts-jest'},\n    testEnvironment: 'node',\n    testRegex: 'test/.*Test.ts"
  },
  {
    "path": "main.ts",
    "chars": 1870,
    "preview": "import { Plugin } from \"obsidian\";\nimport { Shelf } from \"src/Shelf\";\nimport { Settings } from \"src/settings/Settings\";\n"
  },
  {
    "path": "manifest.json",
    "chars": 278,
    "preview": "{\n\t\"id\": \"booksidian-plugin\",\n\t\"name\": \"Booksidian\",\n\t\"version\": \"0.10.1\",\n\t\"minAppVersion\": \"0.12.0\",\n\t\"description\": \""
  },
  {
    "path": "package.json",
    "chars": 946,
    "preview": "{\n\t\"name\": \"booksidian\",\n\t\"author\": \"Micha Brugger\",\n\t\"version\": \"0.10.1\",\n\t\"description\": \"Connect Obsidian to your Goo"
  },
  {
    "path": "src/Body.ts",
    "chars": 469,
    "preview": "import { Book } from \"src/Book\";\n\n// Following rssParser example to avoid issue with: import * as Mustache from 'mustach"
  },
  {
    "path": "src/Book.ts",
    "chars": 5096,
    "preview": "import { CurrentYAML } from \"const/settings\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport Booksidian from \"m"
  },
  {
    "path": "src/Frontmatter.ts",
    "chars": 1235,
    "preview": "import { FRONTMATTER_LINES } from \"const/frontmatter\";\nimport { CurrentYAML } from \"const/settings\";\nimport { Book } fro"
  },
  {
    "path": "src/Shelf.ts",
    "chars": 3253,
    "preview": "import { rssParser } from \"const/rssParser\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport { Book } from \"./Bo"
  },
  {
    "path": "src/helpers.ts",
    "chars": 1083,
    "preview": "import { isAbsolute, dirname } from \"path\";\nimport * as nodeFs from \"fs\";\nimport { App } from \"obsidian\";\n\nexport async "
  },
  {
    "path": "src/settings/Settings.ts",
    "chars": 11163,
    "preview": "import { App, debounce, Notice, PluginSettingTab, Setting } from \"obsidian\";\nimport { FolderSuggest } from \"./suggesters"
  },
  {
    "path": "src/settings/suggesters/FolderSuggester.ts",
    "chars": 1044,
    "preview": "// copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/sett"
  },
  {
    "path": "src/settings/suggesters/suggest.ts",
    "chars": 5638,
    "preview": "// copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/sett"
  },
  {
    "path": "styles.css",
    "chars": 148,
    "preview": ".booksidian-plugin__settings .search-input-container {\n\twidth: 100%;\n}\n\n.booksidian-plugin__settings input, textarea, se"
  },
  {
    "path": "test/BookTest.ts",
    "chars": 7099,
    "preview": "import { Book } from \"src/Book\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport { Book_id } from \"const/goodrea"
  },
  {
    "path": "tsconfig.json",
    "chars": 362,
    "preview": "{\r\n\t\"compilerOptions\": {\r\n\t\t\"baseUrl\": \".\",\r\n\t\t\"inlineSourceMap\": true,\r\n\t\t\"inlineSources\": true,\r\n\t\t\"module\": \"ESNext\","
  },
  {
    "path": "version-bump.mjs",
    "chars": 648,
    "preview": "import { readFileSync, writeFileSync } from \"fs\";\n\nconst targetVersion = process.env.npm_package_version;\n\n// read minAp"
  },
  {
    "path": "versions.json",
    "chars": 584,
    "preview": "{\n\t\"0.1.0\": \"0.12.0\",\n\t\"0.1.1\": \"0.12.0\",\n\t\"0.1.2\": \"0.12.0\",\n\t\"0.1.3\": \"0.12.0\",\n\t\"0.2.0\": \"0.12.0\",\n\t\"0.3.0\": \"0.12.0\""
  }
]

About this extraction

This page contains the full source code of the MichaBrugger/booksidian_plugin GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (50.2 KB), approximately 14.5k tokens, and a symbol index with 67 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!