[
  {
    "path": ".editorconfig",
    "content": "# 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\nindent_size = 4\r\ntab_width = 4\r\n"
  },
  {
    "path": ".eslintignore",
    "content": "npm node_modules\nbuild"
  },
  {
    "path": ".eslintrc",
    "content": "{\n    \"root\": true,\n    \"parser\": \"@typescript-eslint/parser\",\n    \"env\": { \"node\": true },\n    \"plugins\": [\n      \"@typescript-eslint\"\n    ],\n    \"extends\": [\n      \"eslint:recommended\",\n      \"plugin:@typescript-eslint/eslint-recommended\",\n      \"plugin:@typescript-eslint/recommended\"\n    ], \n    \"parserOptions\": {\n        \"sourceType\": \"module\"\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"off\",\n      \"@typescript-eslint/no-unused-vars\": [\"error\", { \"args\": \"none\" }],\n      \"@typescript-eslint/ban-ts-comment\": \"off\",\n      \"no-prototype-builtins\": \"off\",\n      \"@typescript-eslint/no-empty-function\": \"off\"\n    } \n  }"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "ko_fi: michabrugger\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release Obsidian plugin\n\non:\n  push:\n    tags:\n      - \"*\"\n\nenv:\n  PLUGIN_NAME: booksidian-plugin\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js\n        uses: actions/setup-node@v5\n\n      - name: Build\n        id: build\n        run: |\n          npm install\n          npm run build\n\n          echo \"tag_name=$(git tag --sort version:refname | tail -n 1)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: artifacts\n          path: |\n            ./main.js\n            ./manifest.json\n            ./styles.css\n\n  release:\n    runs-on: ubuntu-latest\n\n    needs: build\n\n    permissions:\n      contents: write\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: artifacts\n\n      - name: Create release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          gh release create ${{github.ref}} --generate-notes \\\n          main.js \\\n          manifest.json\n"
  },
  {
    "path": ".gitignore",
    "content": "# 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.js file in the repo.\r\n# They should be uploaded to GitHub releases instead.\r\nmain.js\r\n\r\n# Exclude sourcemaps\r\n*.map\r\n\r\n# obsidian\r\ndata.json\r\n\r\n# Exclude macOS Finder (System Explorer) View States\r\n.DS_Store\r\n"
  },
  {
    "path": ".npmrc",
    "content": "tag-version-prefix=\"\""
  },
  {
    "path": ".prettierrc",
    "content": "{\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",
    "content": "MIT License\n\nCopyright (c) 2022 MichaBrugger\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Booksidian\r\n\r\nBooksidian brings your Goodreads data to Obsidian.\r\n\r\nYou 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).\r\n\r\n![image](https://user-images.githubusercontent.com/46029522/152006018-bfab5d8a-e829-4dbd-b19e-84a9af19e258.png)\r\n\r\n## Setup Instructions\r\n\r\nPlease 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.\r\n\r\n#### Creating Shelves\r\nYou can create those in Goodreads und `My Books` and then `Add shelf` in the left-side menu:\r\n![image](https://user-images.githubusercontent.com/46029522/152001408-87c88a68-b161-4dfd-9845-d6036a05992b.png)\r\n\r\n#### Getting the Feed Base-URL\r\nYou 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.\r\n\r\n![image](https://user-images.githubusercontent.com/46029522/152004240-2580c551-d603-4119-9dd5-95a3bf68b764.png)\r\n\r\n\r\nThis 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.\r\n\r\n![image](https://user-images.githubusercontent.com/46029522/152002763-444c05e1-3a5f-426b-9493-beb99deb9aa3.png)\r\n\r\n### Running Booksidian\r\nYou can run the Booksidian sync by executing the \"Booksidian Sync\" command or by pressing the \"B\" in your menu bar.\r\n\r\nAlternatively, you can set Booksidian to sync automatically by updating the `frequency` in the plugin settings.\r\n\r\n### Overwriting Notes\r\n\r\nBy 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.\r\n\r\nTo 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.\r\n\r\n## Output\r\n\r\nIn 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: \r\n\r\n![image](https://user-images.githubusercontent.com/46029522/151970426-377a5997-7c15-4670-b423-17bb04b3720a.png)\r\n\r\nYou 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:\r\n\r\n```dataview\r\ntable without id (\"![](\" + cover +\")\") as Cover, author as Author\r\nwhere cover != null\r\nsort rating desc\r\n```\r\n\r\nPlease check out the amazing work of these two [here](https://github.com/blacksmithgu/obsidian-dataview) and [here](https://github.com/kepano/obsidian-minimal).\r\n\r\n### Linking back to Goodreads\r\n\r\nThe 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:\r\n\r\n```\r\nhttps://www.goodreads.com/book/show/{{id}}\r\n```\r\n\r\n"
  },
  {
    "path": "const/frontmatter.ts",
    "content": "export const FRONTMATTER_LINES = \"---\";\n"
  },
  {
    "path": "const/goodreads.ts",
    "content": "export interface GoodreadsBook {\n\tauthor: string;\n\ttitle: string;\n\tlink: string;\n\tpubDate: string;\n\tisbn: string;\n\tuser_review: string | undefined;\n\tbook_description: string;\n\tuser_rating: string;\n\taverage_rating: string;\n\tuser_read_at: string;\n\tuser_date_added: string;\n\tuser_date_created: string;\n\tbook_published: string;\n\tidentifiers: Identifiers;\n\tcontent: string;\n\tcontentSnippet: string;\n\tguid: string;\n\tuser_shelves: string;\n\timage_url: string;\n\timage_path: string;\n}\n\nexport interface Identifiers {\n\t$: Book_id;\n\tnum_pages: string[];\n}\n\nexport interface Book_id {\n\tid: string;\n}\n"
  },
  {
    "path": "const/rssParser.ts",
    "content": "// there seems to be an issue with \"import Parser from 'rss-parser';\"\n// I've decided to stick with the current way, even though it's not ideal\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst Parser = require(\"rss-parser\");\n\n// making small changes to the returned keys\nexport const rssParser = new Parser({\n\tcustomFields: {\n\t\titem: [\n\t\t\t[\"author_name\", \"author\"],\n\t\t\t\"isbn\",\n\t\t\t\"user_rating\",\n\t\t\t\"user_review\",\n\t\t\t\"book_description\",\n\t\t\t\"average_rating\",\n\t\t\t\"user_read_at\",\n\t\t\t\"user_date_added\",\n\t\t\t\"user_date_created\",\n\t\t\t\"book_published\",\n\t\t\t[\"book\", \"identifiers\"],\n\t\t\t\"user_shelves\",\n\t\t\t[\"book_large_image_url\", \"image_url\"],\n\t\t],\n\t},\n});\n"
  },
  {
    "path": "const/settings.ts",
    "content": "export interface BooksidianSettings {\n\ttargetFolderPath: string;\n\tgoodreadsBaseUrl: string;\n\tgoodreadsShelves: string;\n\tfileName: string;\n\tfrontmatterDictionary: CurrentYAML;\n\tbodyString: string;\n\tfrequency: string;\n\toverwrite: boolean;\n\tcoverDownload: boolean;\n\tcoverDownloadLocation: string;\n}\n\nexport interface CurrentYAML {\n\t[key: string]: string;\n}\n\nexport const DEFAULT_SETTINGS: BooksidianSettings = {\n\ttargetFolderPath: \"\",\n\tfileName: \"{{title}}\",\n\tgoodreadsBaseUrl: \"https://www.goodreads.com/review/list_rss/...\",\n\tgoodreadsShelves: \"currently-reading\",\n\tfrontmatterDictionary: {},\n\tbodyString: \"# {{title}}\\n\\nauthor::[[{{author}}]]\",\n\tfrequency: \"0\", // manual\n\toverwrite: false,\n\tcoverDownload: false,\n\tcoverDownloadLocation: \"\",\n};\n"
  },
  {
    "path": "esbuild.config.mjs",
    "content": "import esbuild from \"esbuild\";\nimport process from \"process\";\nimport builtins from 'builtin-modules'\n\nconst banner =\n`/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\nif you want to view the source, please visit the github repository of this plugin\n*/\n`;\n\nconst prod = (process.argv[2] === 'production');\n\nesbuild.build({\n\tbanner: {\n\t\tjs: banner,\n\t},\n\tentryPoints: ['main.ts'],\n\tbundle: true,\n\texternal: [\n\t\t'obsidian',\n\t\t'electron',\n\t\t'@codemirror/autocomplete',\n\t\t'@codemirror/closebrackets',\n\t\t'@codemirror/collab',\n\t\t'@codemirror/commands',\n\t\t'@codemirror/comment',\n\t\t'@codemirror/fold',\n\t\t'@codemirror/gutter',\n\t\t'@codemirror/highlight',\n\t\t'@codemirror/history',\n\t\t'@codemirror/language',\n\t\t'@codemirror/lint',\n\t\t'@codemirror/matchbrackets',\n\t\t'@codemirror/panel',\n\t\t'@codemirror/rangeset',\n\t\t'@codemirror/rectangular-selection',\n\t\t'@codemirror/search',\n\t\t'@codemirror/state',\n\t\t'@codemirror/stream-parser',\n\t\t'@codemirror/text',\n\t\t'@codemirror/tooltip',\n\t\t'@codemirror/view',\n\t\t...builtins],\n\tformat: 'cjs',\n\twatch: !prod,\n\ttarget: 'es2016',\n\tlogLevel: \"info\",\n\tsourcemap: prod ? false : 'inline',\n\ttreeShaking: true,\n\toutfile: 'main.js',\n}).catch(() => process.exit(1));\n"
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {\n    transform: {'^.+\\\\.ts?$': 'ts-jest'},\n    testEnvironment: 'node',\n    testRegex: 'test/.*Test.ts',\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],\n    moduleDirectories: ['node_modules', 'src', 'const', 'test'],\n    modulePaths: ['<rootDir>'],\n    moduleNameMapper: {\n      \"^@/(.*)$\": \"<rootDir>/src/\"\n    }\n  };"
  },
  {
    "path": "main.ts",
    "content": "import { Plugin } from \"obsidian\";\nimport { Shelf } from \"src/Shelf\";\nimport { Settings } from \"src/settings/Settings\";\nimport { BooksidianSettings, DEFAULT_SETTINGS } from \"const/settings\";\n\nexport default class Booksidian extends Plugin {\n\tsettings: BooksidianSettings;\n\tscheduleInterval: null | number = null;\n\n\tasync onload() {\n\t\tawait this.loadSettings();\n\n\t\t// This creates an icon in the left ribbon.\n\t\tthis.addRibbonIcon(\n\t\t\t\"bold-glyph\",\n\t\t\t\"Booksidian Sync\",\n\t\t\t(evt: MouseEvent) => {\n\t\t\t\tthis.updateLibrary();\n\t\t\t},\n\t\t);\n\n\t\t// This adds a simple command that can be triggered anywhere\n\t\tthis.addCommand({\n\t\t\tid: \"booksidian-sync\",\n\t\t\tname: \"Booksidian Sync\",\n\t\t\tcallback: () => {\n\t\t\t\tthis.updateLibrary();\n\t\t\t},\n\t\t});\n\n\t\t// This adds a settings tab so the user can configure various aspects of the plugin\n\t\tthis.addSettingTab(new Settings(this.app, this));\n\t}\n\n\tupdateLibrary() {\n\t\tthis.settings.goodreadsShelves.split(\",\").forEach(async (_shelf) => {\n\t\t\tconst shelf = new Shelf(this, _shelf.trim());\n\t\t\tawait shelf.createFolder();\n\t\t\tawait shelf.fetchGoodreadsFeed();\n\t\t\tawait shelf.createBookFiles();\n\t\t});\n\t}\n\n\tasync loadSettings() {\n\t\tthis.settings = Object.assign(\n\t\t\t{},\n\t\t\tDEFAULT_SETTINGS,\n\t\t\tawait this.loadData(),\n\t\t);\n\t}\n\n\tasync saveSettings() {\n\t\tawait this.saveData(this.settings);\n\t}\n\n\tasync configureSchedule() {\n\t\tconst minutes = parseInt(this.settings.frequency);\n\t\tconst milliseconds = minutes * 60 * 1000; // minutes * seconds * milliseconds\n\t\tconsole.log(\n\t\t\t\"Booksidian plugin: setting interval to \",\n\t\t\tmilliseconds,\n\t\t\t\"milliseconds\",\n\t\t);\n\t\twindow.clearInterval(this.scheduleInterval);\n\t\tthis.scheduleInterval = null;\n\t\tif (!milliseconds) {\n\t\t\t// we got manual option\n\t\t\treturn;\n\t\t}\n\t\tthis.scheduleInterval = window.setInterval(\n\t\t\t() => this.updateLibrary(),\n\t\t\tmilliseconds,\n\t\t);\n\t\tthis.registerInterval(this.scheduleInterval);\n\t}\n}\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n\t\"id\": \"booksidian-plugin\",\n\t\"name\": \"Booksidian\",\n\t\"version\": \"0.10.1\",\n\t\"minAppVersion\": \"0.12.0\",\n\t\"description\": \"Connect Obsidian to your Goodreads.\",\n\t\"author\": \"Micha Brugger and Zachary Wright\",\n\t\"authorUrl\": \"https://github.com/MichaBrugger\",\n\t\"isDesktopOnly\": true\n}"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"booksidian\",\n\t\"author\": \"Micha Brugger\",\n\t\"version\": \"0.10.1\",\n\t\"description\": \"Connect Obsidian to your Goodreads.\",\n\t\"main\": \"main.js\",\n\t\"scripts\": {\n\t\t\"dev\": \"node esbuild.config.mjs\",\n\t\t\"build\": \"tsc -noEmit -skipLibCheck && node esbuild.config.mjs production\",\n\t\t\"version\": \"node version-bump.mjs && git add manifest.json versions.json\"\n\t},\n\t\"keywords\": [],\n\t\"license\": \"MIT\",\n\t\"devDependencies\": {\n\t\t\"@popperjs/core\": \"^2.11.8\",\n\t\t\"@types/jest\": \"^29.4.0\",\n\t\t\"@types/mustache\": \"^4.2.2\",\n\t\t\"@types/node\": \"^16.11.6\",\n\t\t\"@typescript-eslint/eslint-plugin\": \"^5.2.0\",\n\t\t\"@typescript-eslint/parser\": \"^5.2.0\",\n\t\t\"builtin-modules\": \"^3.2.0\",\n\t\t\"esbuild\": \"^0.13.12\",\n\t\t\"jest\": \"^29.4.0\",\n\t\t\"obsidian\": \"^1.5.7-1\",\n\t\t\"prettier\": \"^3.2.5\",\n\t\t\"tslib\": \"^2.3.1\",\n\t\t\"typescript\": \"^4.4.4\"\n\t},\n\t\"dependencies\": {\n\t\t\"js-yaml\": \"^4.1.0\",\n\t\t\"mustache\": \"^4.2.0\",\n\t\t\"rss-parser\": \"^3.12.0\",\n\t\t\"ts-jest\": \"^29.0.5\",\n\t\t\"turndown\": \"^7.1.2\"\n\t}\n}\n"
  },
  {
    "path": "src/Body.ts",
    "content": "import { Book } from \"src/Book\";\n\n// Following rssParser example to avoid issue with: import * as Mustache from 'mustache';\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst Mustache = require(\"mustache\");\n\nexport class Body {\n\tconstructor(\n\t\tpublic currentBody: string,\n\t\tpublic book: Book,\n\t) {}\n\n\tpublic getBody(): string {\n\t\tconst render = Mustache.render(this.currentBody, this.book) as string;\n\n\t\treturn render.replaceAll(\"&#x2F;\", \"/\");\n\t}\n}\n"
  },
  {
    "path": "src/Book.ts",
    "content": "import { CurrentYAML } from \"const/settings\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport Booksidian from \"main\";\nimport { Body } from \"./Body\";\nimport { Frontmatter } from \"./Frontmatter\";\nimport { writeFile } from \"./helpers\";\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst TurndownService = require(\"turndown\");\n\nexport class Book {\n\tid: string;\n\tpages: number;\n\ttitle: string;\n\trawTitle: string;\n\tfullTitle: string;\n\tseries: string;\n\tseriesName: string;\n\tseriesNumber: number;\n\tsubtitle: string;\n\tdescription: string;\n\tauthor: string;\n\tisbn: string;\n\treview: string;\n\trating: number;\n\tavgRating: number;\n\tshelves: string[];\n\tdateAdded: string;\n\tdateCreated: string;\n\tdateRead: string;\n\tdatePublished: string;\n\tcover: string;\n\tcoverImage: string;\n\tbookPage: string;\n\n\tconstructor(\n\t\tpublic plugin: Booksidian,\n\t\tbook: GoodreadsBook,\n\t) {\n\t\tthis.id = book.identifiers.$.id;\n\t\tthis.pages = parseInt(book.identifiers.num_pages[0]) || undefined;\n\t\tthis.title = this.cleanTitle(book.title, false);\n\t\tthis.rawTitle = book.title;\n\t\tthis.fullTitle = this.cleanTitle(book.title, true);\n\t\tthis.description = this.htmlToMarkdown(book.book_description);\n\t\tthis.author = book.author;\n\t\tthis.isbn = book.isbn;\n\t\tthis.review = this.htmlToMarkdown(book.user_review || \"\");\n\t\tthis.rating = parseInt(book.user_rating) || 0;\n\t\tthis.avgRating = parseFloat(book.average_rating) || 0;\n\t\tthis.dateAdded = this.parseDate(book.user_date_added);\n\t\tthis.dateCreated = this.parseDate(book.user_date_created);\n\t\tthis.dateRead = this.parseDate(book.user_read_at);\n\t\tthis.datePublished = this.parseDate(book.book_published);\n\t\tthis.cover = book.image_url;\n\t\tthis.coverImage = book.image_path;\n\t\tthis.shelves = this.getShelves(book.user_shelves, this.dateRead);\n\t\tthis.bookPage = `https://www.goodreads.com/book/show/${this.id}`;\n\t}\n\n\tpublic getTitle(): string {\n\t\treturn this.title;\n\t}\n\n\tpublic getContent(): string {\n\t\tconst set = this.plugin.settings;\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tthis.getFrontMatter(set.frontmatterDictionary) +\n\t\t\t\tthis.getBody(set.bodyString)\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconsole.log(error);\n\t\t}\n\t}\n\n\tprivate htmlToMarkdown(html: string) {\n\t\tconst turndownService = new TurndownService();\n\t\treturn turndownService.turndown(html);\n\t}\n\n\tprivate getShelves(shelves: string, dateRead: string): string[] {\n\t\t// Goodreads doesn't send a shelf value for books on the read shelf.\n\t\t// Infer from either a missing shelf value, or a set dateRead.\n\t\t// Check for presence of read first in case Goodreads decides to include it.\n\t\tconst outputShelves = shelves\n\t\t\t.split(\",\")\n\t\t\t.map((shelf) => shelf.trim()) // trim shelf names\n\t\t\t.filter((shelf) => shelf); // filter out empty shelf names\n\n\t\t// If the book has a read date and the `read` shelf is missing, we add it\n\t\tif (dateRead && !outputShelves.includes(\"read\"))\n\t\t\toutputShelves.push(\"read\");\n\n\t\treturn outputShelves;\n\t}\n\n\tprivate getBody(currentBody: string): string {\n\t\treturn new Body(currentBody, this).getBody();\n\t}\n\n\tprivate getFrontMatter(currentYAML: CurrentYAML): string {\n\t\tif (Object.keys(currentYAML).length > 0) {\n\t\t\treturn new Frontmatter(currentYAML, this).getFrontmatter();\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tpublic async createFile(book: Book, path: string): Promise<void> {\n\t\tconst fileName = this.getBody(this.plugin.settings.fileName);\n\t\tconst fullPath = `${path}/${fileName}.md`;\n\n\t\tconst file = this.plugin.app.vault.getFileByPath(fullPath);\n\t\tif (file && !this.plugin.settings.overwrite) return;\n\n\t\tconst bookContent = book.getContent();\n\n\t\twriteFile(fullPath, bookContent, this.plugin.app);\n\t}\n\n\tprivate cleanTitle(title: string, full: boolean) {\n\t\tthis.series = \"\";\n\t\tthis.seriesName = \"\";\n\t\tthis.seriesNumber = 0;\n\t\tthis.subtitle = \"\";\n\t\tlet series = \"\";\n\n\t\tif (title.includes(\"(\") && title.includes(\"#\")) {\n\t\t\tseries = this.getSeries(title);\n\t\t}\n\n\t\ttitle = title.replace(series, \"\");\n\n\t\tif (title.includes(\":\")) {\n\t\t\tthis.getSubTitle(title);\n\t\t}\n\n\t\tif (!full) {\n\t\t\ttitle = title.split(\":\")[0];\n\t\t}\n\n\t\t// replace remaining special characters with an empty character\n\t\ttitle = title.replace(/[&\\/\\\\#,+()$~%.'\":*?<>{}|]/g, \"\");\n\n\t\treturn title.trim();\n\t}\n\n\tprivate getSeries(title: string): string {\n\t\t// only calculate once per book\n\t\tif (this.series) {\n\t\t\treturn this.series;\n\t\t}\n\t\tlet match = title.match(/.+ \\(((.+?),? #(\\d+))\\)/);\n\n\t\tif (match) {\n\t\t\tthis.series = match[1].trim();\n\t\t\tthis.seriesName = match[2].trim();\n\t\t\tthis.seriesNumber = parseInt(match[3].trim(), 10);\n\t\t\treturn `(${match[1]})`;\n\t\t}\n\n\t\tconsole.log(\n\t\t\t`New get series parser failed for \"${title}\", falling back to legacy parser.`,\n\t\t);\n\n\t\t// fallback to old method, this is mostly for backwards compatibility in case of edge cases\n\t\tmatch = title.match(/\\((.*?)\\)/);\n\t\tif (match && match[1].contains(\"#\")) {\n\t\t\tthis.series = match[1].trim();\n\t\t\treturn match[0];\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tprivate getSubTitle(title: string) {\n\t\tthis.subtitle = title.split(\":\")[1].trim();\n\t}\n\n\tprivate parseDate(inputDate: string) {\n\t\tif (inputDate == \"\") {\n\t\t\treturn \"\";\n\t\t}\n\t\tconst date = new Date(inputDate);\n\t\treturn date.toISOString().substring(0, 10);\n\t}\n}\n"
  },
  {
    "path": "src/Frontmatter.ts",
    "content": "import { FRONTMATTER_LINES } from \"const/frontmatter\";\nimport { CurrentYAML } from \"const/settings\";\nimport { Book } from \"src/Book\";\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst yaml = require(\"js-yaml\");\n\nexport class Frontmatter {\n\tconstructor(\n\t\tpublic currentYAML: CurrentYAML,\n\t\tpublic book: Book,\n\t) {}\n\n\tpublic getFrontmatter(): string {\n\t\treturn (\n\t\t\tFRONTMATTER_LINES +\n\t\t\t\"\\n\" +\n\t\t\tthis.getFrontmatterLines() +\n\t\t\tFRONTMATTER_LINES +\n\t\t\t\"\\n\"\n\t\t);\n\t}\n\n\tprivate getFrontmatterLines(): string {\n\t\tconst output: { [key: string]: number | string | string[] } = {};\n\n\t\tObject.keys(this.currentYAML).forEach((key: string) => {\n\t\t\tconst value = this.currentYAML[key];\n\t\t\tconst [prefix, postfix] = value.split(key);\n\n\t\t\tif (key === \"shelves\") {\n\t\t\t\toutput[key] = this.book.shelves.sort().map((shelf) => {\n\t\t\t\t\treturn `${prefix}${shelf}${postfix}`;\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// If this a simple link, and the value of the string is empty, don't insert [[]]\n\n\t\t\t\tif (\n\t\t\t\t\tvalue == `[[${key}]]` &&\n\t\t\t\t\tthis.book[key as keyof Book] == \"\"\n\t\t\t\t) {\n\t\t\t\t\toutput[key] = \"\";\n\t\t\t\t} else {\n\t\t\t\t\toutput[key] =\n\t\t\t\t\t\t`${prefix}${this.book[key as keyof Book]}${postfix}`;\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\treturn yaml.dump(output);\n\t}\n}\n"
  },
  {
    "path": "src/Shelf.ts",
    "content": "import { rssParser } from \"const/rssParser\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport { Book } from \"./Book\";\nimport Booksidian from \"main\";\nimport { Notice } from \"obsidian\";\nimport * as nodeFs from \"fs\";\nimport { isAbsolute } from \"path\";\nimport { pathExist, writeBinaryFile } from \"./helpers\";\nimport { get } from \"https\";\n\nexport class Shelf {\n\tpath: string;\n\turl: string;\n\tbooks: Book[] = [];\n\n\tconstructor(\n\t\tpublic plugin: Booksidian,\n\t\tpublic shelfName: string,\n\t) {\n\t\tconst targetFolder = plugin.settings.targetFolderPath;\n\t\tthis.path = targetFolder === \"\" ? \"./\" : targetFolder;\n\n\t\tthis.url = `${plugin.settings.goodreadsBaseUrl}${shelfName.toLocaleLowerCase()}`;\n\t}\n\n\tprivate setBook(book: Book): void {\n\t\tthis.books.push(book);\n\t}\n\n\tpublic getBooks(): Book[] {\n\t\treturn this.books;\n\t}\n\n\t// create folder for each shelf (based on targetFolderPath)\n\tpublic async createFolder(): Promise<void> {\n\t\tif (isAbsolute(this.path)) {\n\t\t\tnodeFs.mkdir(this.path, { recursive: true }, (err) => {\n\t\t\t\tif (err) console.log(err);\n\t\t\t});\n\t\t} else {\n\t\t\ttry {\n\t\t\t\tawait this.plugin.app.vault.createFolder(this.path);\n\t\t\t} catch (e) {\n\t\t\t\tif (e.message.includes(\"already exists\")) return;\n\t\t\t\tconsole.warn(e);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic async fetchGoodreadsFeed(): Promise<void> {\n\t\ttry {\n\t\t\tlet page = 1;\n\t\t\twhile (true) {\n\t\t\t\tconst pagedUrl = `${this.url}&page=${page}&per_page=100`;\n\t\t\t\tconst feed = await rssParser.parseURL(pagedUrl);\n\n\t\t\t\tif (!feed.items) break;\n\n\t\t\t\tfor (const _book of feed.items as GoodreadsBook[]) {\n\t\t\t\t\tconst book = new Book(this.plugin, _book);\n\t\t\t\t\tbook.coverImage = await this.fetchCoverImage(\n\t\t\t\t\t\tbook.cover,\n\t\t\t\t\t\tbook.id,\n\t\t\t\t\t);\n\n\t\t\t\t\t// If we're currently explicitly checking the `read` shelf, we add it\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.shelfName === \"read\" &&\n\t\t\t\t\t\t!book.shelves.contains(\"read\")\n\t\t\t\t\t)\n\t\t\t\t\t\tbook.shelves.push(\"read\");\n\n\t\t\t\t\tthis.setBook(book);\n\t\t\t\t}\n\t\t\t\tpage++;\n\t\t\t\tif (!feed.items.length) break;\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.warn(e);\n\t\t}\n\t}\n\n\tprivate async fetchCoverImage(url: string, title: string) {\n\t\tif (!this.plugin.settings.coverDownload) return;\n\n\t\tlet coverDownloadLocation = this.plugin.settings.coverDownloadLocation;\n\n\t\tif (coverDownloadLocation === \"\")\n\t\t\tcoverDownloadLocation = `${this.plugin.settings.targetFolderPath || \".\"}/cover`;\n\n\t\tconst fullPath = `${coverDownloadLocation}/${title}.jpg`;\n\n\t\tif (pathExist(fullPath)) return fullPath;\n\n\t\tget(url, (response) => {\n\t\t\tresponse.setEncoding(\"binary\");\n\n\t\t\tlet rawData = new Uint16Array();\n\t\t\tresponse.on(\"data\", (chunk) => (rawData += chunk));\n\t\t\tresponse.on(\"end\", () => writeBinaryFile(fullPath, rawData));\n\t\t});\n\n\t\treturn fullPath;\n\t}\n\n\tpublic async createBookFiles(): Promise<void> {\n\t\tawait Promise.all([\n\t\t\tthis.getBooks().map((book) => book.createFile(book, this.path)),\n\t\t]);\n\t\tthis.createNotice();\n\t}\n\n\tprivate createNotice() {\n\t\tconst syncCount: number = this.getBooks().length;\n\n\t\tif (syncCount === 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst firstTitle = this.getBooks()[0].rawTitle;\n\t\tlet noticeMsg = \"\";\n\n\t\tif (syncCount === 1) {\n\t\t\tnoticeMsg = `${firstTitle} synced from Goodreads!`;\n\t\t} else {\n\t\t\tnoticeMsg = `${this.getBooks().length} books, including ${firstTitle}, synced from Goodreads!`;\n\t\t}\n\n\t\tnew Notice(noticeMsg, 5000);\n\t}\n}\n"
  },
  {
    "path": "src/helpers.ts",
    "content": "import { isAbsolute, dirname } from \"path\";\nimport * as nodeFs from \"fs\";\nimport { App } from \"obsidian\";\n\nexport async function writeFile(path: string, content: string, app: App) {\n\tif (isAbsolute(path)) {\n\t\tnodeFs.writeFile(path, content, (error) => {\n\t\t\tif (error) console.log(`Error writing ${path}`, error);\n\t\t});\n\t} else {\n\t\ttry {\n\t\t\tconst fs = app.vault.adapter;\n\t\t\tawait fs.write(path, content);\n\t\t} catch (error) {\n\t\t\tconsole.log(`Error writing ${path}`, error);\n\t\t}\n\t}\n}\n\nexport async function writeBinaryFile(path: string, content: Uint16Array) {\n\tconst filePath = isAbsolute(path)\n\t\t? path\n\t\t: `${this.app.vault.adapter.basePath}/${path}`;\n\n\tconst directory = dirname(filePath);\n\tif (!nodeFs.existsSync(directory)) nodeFs.mkdirSync(directory);\n\n\ttry {\n\t\tnodeFs.writeFileSync(filePath, content, { encoding: \"binary\" });\n\t} catch (error) {\n\t\tconsole.log(`Error writing ${filePath}`, error);\n\t}\n}\n\nexport function pathExist(path: string) {\n\tconst filePath = isAbsolute(path)\n\t\t? path\n\t\t: `${this.app.vault.adapter.basePath}/${path}`;\n\n\treturn nodeFs.existsSync(filePath);\n}\n"
  },
  {
    "path": "src/settings/Settings.ts",
    "content": "import { App, debounce, Notice, PluginSettingTab, Setting } from \"obsidian\";\nimport { FolderSuggest } from \"./suggesters/FolderSuggester\";\nimport Booksidian from \"../../main\";\n\nconst debouncedSaveSettings = debounce(\n\t(callback: () => void) => callback(),\n\t500,\n\ttrue,\n);\n\nexport class Settings extends PluginSettingTab {\n\tplugin: Booksidian;\n\tcurrentYAML: { [key: string]: string };\n\n\tconstructor(app: App, plugin: Booksidian) {\n\t\tsuper(app, plugin);\n\t\tthis.plugin = plugin;\n\t\tthis.currentYAML = plugin.settings.frontmatterDictionary;\n\t}\n\n\tgetSelectedCount(): string {\n\t\tconst selected = Object.keys(this.getYAML()).length;\n\t\tconst total = 20;\n\t\treturn `${selected}/${total}`;\n\t}\n\n\t// eslint-disable-next-line @typescript-eslint/ban-types\n\tprivate getYAML(): { [key: string]: string } {\n\t\treturn this.currentYAML;\n\t}\n\n\tgetDisplay(option: string, label?: string): string {\n\t\tlabel = label ? label : option;\n\n\t\tif (this.optionIsSelected(option)) {\n\t\t\treturn \"🟢 - \" + label;\n\t\t}\n\t\treturn \"⚫ - \" + label;\n\t}\n\n\toptionIsSelected(option: string): boolean {\n\t\treturn this.currentYAML.hasOwnProperty(option);\n\t}\n\n\tdisplay(): void {\n\t\tconst { containerEl } = this;\n\n\t\tcontainerEl.empty();\n\t\tcontainerEl.createEl(\"h3\", { text: \"Goodreads RSS Feed\" });\n\n\t\t// set the target folder for the exports\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Target Folder\")\n\t\t\t.setDesc(\n\t\t\t\t\"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.\",\n\t\t\t)\n\t\t\t.addSearch((cb) => {\n\t\t\t\ttry {\n\t\t\t\t\tnew FolderSuggest(this.app, cb.inputEl);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(e); // Improved error handling\n\t\t\t\t}\n\t\t\t\tcb.setPlaceholder(\"Vault root\")\n\t\t\t\t\t.setValue(this.plugin.settings.targetFolderPath)\n\t\t\t\t\t.onChange(async (value) => {\n\t\t\t\t\t\tthis.plugin.settings.targetFolderPath = value\n\t\t\t\t\t\t\t.replace(\n\t\t\t\t\t\t\t\t/[\\\\/]+$/g, // matches any trailing slashes\n\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.trim();\n\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t});\n\t\t\t});\n\n\t\t// set the base url for all goodreads rss feeds\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"RSS Base URL\")\n\t\t\t.setDesc(\n\t\t\t\t\"Please add your RSS Base URL here (everything before the shelf name).\",\n\t\t\t)\n\t\t\t.setTooltip(\"https://www.goodreads.com/ ... &shelf=\")\n\t\t\t.addText((text) => {\n\t\t\t\ttext.setValue(this.plugin.settings.goodreadsBaseUrl)\n\t\t\t\t\t.setPlaceholder(\"https://www.goodreads.com/ ... &shelf=\")\n\t\t\t\t\t.onChange(async (value) => {\n\t\t\t\t\t\tdebouncedSaveSettings(async () => {\n\t\t\t\t\t\t\tconst validPattern =\n\t\t\t\t\t\t\t\t/^https?:\\/\\/.*?\\/review\\/list_rss\\/\\d+\\?key=[a-zA-Z0-9-_]+&shelf=/;\n\n\t\t\t\t\t\t\tconst result = value.trim().match(validPattern);\n\n\t\t\t\t\t\t\t// Save the url only when it matches the pattern\n\t\t\t\t\t\t\tif (result) {\n\t\t\t\t\t\t\t\tthis.plugin.settings.goodreadsBaseUrl =\n\t\t\t\t\t\t\t\t\tresult[0];\n\t\t\t\t\t\t\t\ttext.inputEl.value = result[0];\n\t\t\t\t\t\t\t} else if (value.trim().length === 0) {\n\t\t\t\t\t\t\t\tthis.plugin.settings.goodreadsBaseUrl = \"\";\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tnew Notice(\n\t\t\t\t\t\t\t\t\t\"Booksidian: Could not parse RSS Base URL\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t\t});\n\t\t\t\t\t});\n\t\t\t\ttext.inputEl.style.minWidth = \"18rem\";\n\t\t\t\ttext.inputEl.style.maxWidth = \"18rem\";\n\t\t\t});\n\n\t\t// set the goodreads shelves that should be exported\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Your Goodreads Shelves\")\n\t\t\t.setDesc(\n\t\t\t\t\"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. \",\n\t\t\t)\n\t\t\t.setTooltip(\"You can check the proper naming in the RSS url.\")\n\t\t\t.addTextArea((text) => {\n\t\t\t\ttext.inputEl.rows = 6;\n\t\t\t\ttext.setPlaceholder(\"Your Shelves\")\n\t\t\t\t\t.setValue(this.plugin.settings.goodreadsShelves)\n\t\t\t\t\t.onChange(async (value) => {\n\t\t\t\t\t\tthis.plugin.settings.goodreadsShelves = value;\n\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t});\n\t\t\t});\n\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Configure resync frequency\")\n\t\t\t.setDesc(\n\t\t\t\t\"If not set to manual, Booksidian will resync with Goodreads RSS at configured interval\",\n\t\t\t)\n\t\t\t.addDropdown((dropdown) => {\n\t\t\t\tdropdown.addOption(\"0\", \"Manual\");\n\t\t\t\tdropdown.addOption(\"60\", \"Every 1 hour\");\n\t\t\t\tdropdown.addOption((12 * 60).toString(), \"Every 12 hours\");\n\t\t\t\tdropdown.addOption((24 * 60).toString(), \"Every 24 hours\");\n\n\t\t\t\tdropdown.setValue(this.plugin.settings.frequency);\n\n\t\t\t\tdropdown.onChange((newValue) => {\n\t\t\t\t\tthis.plugin.settings.frequency = newValue;\n\t\t\t\t\tthis.plugin.saveSettings();\n\n\t\t\t\t\tthis.plugin.configureSchedule();\n\t\t\t\t});\n\t\t\t});\n\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Overwrite\")\n\t\t\t.setDesc(\n\t\t\t\t\"When syncing with Goodreads, overwrite existing notes. Modifications to notes will be lost, but changes from Goodreads will now be picked up.\",\n\t\t\t)\n\t\t\t.addToggle((toggle) => {\n\t\t\t\ttoggle.setValue(this.plugin.settings.overwrite);\n\n\t\t\t\ttoggle.onChange((newValue) => {\n\t\t\t\t\tthis.plugin.settings.overwrite = newValue;\n\t\t\t\t\tthis.plugin.saveSettings();\n\t\t\t\t});\n\t\t\t});\n\n\t\tcontainerEl.createEl(\"h4\", { text: \"Book covers\" });\n\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Download covers\")\n\t\t\t.setDesc(\n\t\t\t\t\"Whether the cover image for each book should be downloaded\",\n\t\t\t)\n\t\t\t.addToggle((toggle) => {\n\t\t\t\ttoggle.setValue(this.plugin.settings.coverDownload);\n\t\t\t\ttoggle.onChange(\n\t\t\t\t\tasync (value) =>\n\t\t\t\t\t\t(this.plugin.settings.coverDownload = value),\n\t\t\t\t);\n\t\t\t});\n\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Cover download folder\")\n\t\t\t.setDesc(\n\t\t\t\t'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.',\n\t\t\t)\n\t\t\t.addSearch((cb) => {\n\t\t\t\ttry {\n\t\t\t\t\tnew FolderSuggest(this.app, cb.inputEl);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(e); // Improved error handling\n\t\t\t\t}\n\t\t\t\tcb.setPlaceholder(\"Target Folder/cover\")\n\t\t\t\t\t.setValue(this.plugin.settings.coverDownloadLocation)\n\t\t\t\t\t.onChange(async (value) => {\n\t\t\t\t\t\tthis.plugin.settings.coverDownloadLocation =\n\t\t\t\t\t\t\tvalue.trim();\n\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t});\n\t\t\t});\n\n\t\tcontainerEl.createEl(\"h3\", { text: \"Body\" });\n\t\tcontainerEl.createEl(\"p\", {\n\t\t\ttext: \"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.\",\n\t\t});\n\n\t\t// set the title of the book-note\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Naming Pattern\")\n\t\t\t.setTooltip(\"You don't need to add '.md' to the filename\")\n\t\t\t.addText((text) => {\n\t\t\t\ttext.setValue(this.plugin.settings.fileName);\n\t\t\t\ttext.onChange(async (value) => {\n\t\t\t\t\tthis.plugin.settings.fileName = value;\n\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t});\n\t\t\t});\n\n\t\t// set the body content of the book-note\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Content of the book-note\")\n\t\t\t.setTooltip(\"Don't forget to wrap the placeholders in {{}}.\")\n\t\t\t.addTextArea((text) => {\n\t\t\t\ttext.inputEl.rows = 6;\n\t\t\t\ttext.setValue(this.plugin.settings.bodyString);\n\t\t\t\ttext.onChange(async (value) => {\n\t\t\t\t\tthis.plugin.settings.bodyString = value;\n\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t});\n\t\t\t});\n\n\t\tcontainerEl.createEl(\"h3\", { text: \"Frontmatter\" });\n\n\t\tif (Object.keys(this.currentYAML).length > 0) {\n\t\t\tcontainerEl.createEl(\"p\", {\n\t\t\t\ttext: \"You can add custom frontmatter to your books. Please use the dropdown to choose the frontmatter you'd like to add.\",\n\t\t\t});\n\t\t}\n\t\t// \tcontainerEl.createEl(\"pre\", {\n\t\t// \t\ttext: \"key: value\",\n\t\t// \t\tattr: { style: \"font-size: 12px; color: #999;\" },\n\t\t// \t});\n\t\t// }\n\n\t\tnew Setting(containerEl)\n\t\t\t.setName(\"Available Fields\")\n\n\t\t\t.addDropdown((dropdown) =>\n\t\t\t\tdropdown\n\t\t\t\t\t.addOption(\"\", `${this.getSelectedCount()}`)\n\t\t\t\t\t.addOption(\"id\", `${this.getDisplay(\"id\")}`)\n\t\t\t\t\t.addOption(\"author\", `${this.getDisplay(\"author\")}`)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"title\",\n\t\t\t\t\t\t`${this.getDisplay(\"title\", \"title (formatted for filenames/links)\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"fullTitle\",\n\t\t\t\t\t\t`${this.getDisplay(\"fullTitle\", \"fullTitle (formatted, includes subtitle)\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\"rawTitle\", `${this.getDisplay(\"rawTitle\")}`)\n\t\t\t\t\t.addOption(\"subtitle\", `${this.getDisplay(\"subtitle\")}`)\n\t\t\t\t\t.addOption(\"pages\", `${this.getDisplay(\"pages\")}`)\n\t\t\t\t\t.addOption(\"series\", `${this.getDisplay(\"series\")}`)\n\t\t\t\t\t.addOption(\"seriesName\", `${this.getDisplay(\"seriesName\")}`)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"seriesNumber\",\n\t\t\t\t\t\t`${this.getDisplay(\"seriesNumber\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"description\",\n\t\t\t\t\t\t`${this.getDisplay(\"description\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\"cover\", `${this.getDisplay(\"cover\")}`)\n\t\t\t\t\t.addOption(\"coverImage\", `${this.getDisplay(\"coverImage\")}`)\n\t\t\t\t\t.addOption(\"isbn\", `${this.getDisplay(\"isbn\")}`)\n\t\t\t\t\t.addOption(\"review\", `${this.getDisplay(\"review\")}`)\n\t\t\t\t\t.addOption(\"rating\", `${this.getDisplay(\"rating\")}`)\n\t\t\t\t\t.addOption(\"avgRating\", `${this.getDisplay(\"avgRating\")}`)\n\t\t\t\t\t.addOption(\"dateAdded\", `${this.getDisplay(\"dateAdded\")}`)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"dateCreated\",\n\t\t\t\t\t\t`${this.getDisplay(\"dateCreated\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\"dateRead\", `${this.getDisplay(\"dateRead\")}`)\n\t\t\t\t\t.addOption(\n\t\t\t\t\t\t\"datePublished\",\n\t\t\t\t\t\t`${this.getDisplay(\"datePublished\")}`,\n\t\t\t\t\t)\n\t\t\t\t\t.addOption(\"shelves\", `${this.getDisplay(\"shelves\")}`)\n\t\t\t\t\t.addOption(\"bookPage\", `${this.getDisplay(\"bookPage\")}`)\n\t\t\t\t\t.onChange(async (value: string) => {\n\t\t\t\t\t\tif (this.optionIsSelected(value)) {\n\t\t\t\t\t\t\tdelete this.currentYAML[value];\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tif (value === \"coverImage\")\n\t\t\t\t\t\t\t\t// we want coverImage to default to a link\n\t\t\t\t\t\t\t\tthis.currentYAML[value] = `[[${value}]]`;\n\t\t\t\t\t\t\telse this.currentYAML[value] = value;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t\tthis.display();\n\t\t\t\t\t}),\n\t\t\t)\n\t\t\t.addExtraButton((button) =>\n\t\t\t\tbutton\n\t\t\t\t\t.onClick(async () => {\n\t\t\t\t\t\tthis.display();\n\t\t\t\t\t})\n\t\t\t\t\t.setIcon(\"sync\")\n\t\t\t\t\t.setTooltip(\"Refresh Previews\"),\n\t\t\t);\n\n\t\tObject.keys(this.currentYAML).forEach((key) => {\n\t\t\tconst value = this.currentYAML[key];\n\t\t\tnew Setting(containerEl)\n\t\t\t\t.setName(key + \": \" + value)\n\t\t\t\t.addExtraButton(\n\t\t\t\t\t(button) =>\n\t\t\t\t\t\tbutton\n\t\t\t\t\t\t\t.setTooltip(\"Convert to link\")\n\t\t\t\t\t\t\t.onClick(async () => {\n\t\t\t\t\t\t\t\tif (value.startsWith(\"[[\")) {\n\t\t\t\t\t\t\t\t\tthis.currentYAML[key] = value.replace(\n\t\t\t\t\t\t\t\t\t\t/[[\\]]/g,\n\t\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tthis.currentYAML[key] = \"[[\" + value + \"]]\";\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t\t\t\tthis.display();\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.setIcon(\"bracket-glyph\").setTooltip,\n\t\t\t\t)\n\t\t\t\t.addText((text) =>\n\t\t\t\t\ttext\n\t\t\t\t\t\t.setPlaceholder(\"\")\n\t\t\t\t\t\t.setValue(this.currentYAML[key])\n\t\t\t\t\t\t.onChange(async (value) => {\n\t\t\t\t\t\t\tthis.currentYAML[key] = value;\n\t\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\t.addExtraButton((button) =>\n\t\t\t\t\tbutton\n\t\t\t\t\t\t.onClick(async () => {\n\t\t\t\t\t\t\tdelete this.currentYAML[key];\n\t\t\t\t\t\t\tawait this.plugin.saveSettings();\n\t\t\t\t\t\t\tthis.display();\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.setIcon(\"trash\")\n\t\t\t\t\t\t.setTooltip(\"Remove\"),\n\t\t\t\t);\n\t\t});\n\t\tcontainerEl.classList.add(\"booksidian-plugin__settings\");\n\t}\n}\n"
  },
  {
    "path": "src/settings/suggesters/FolderSuggester.ts",
    "content": "// copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/FolderSuggester.ts\n// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes\n\nimport { TAbstractFile, TFolder } from \"obsidian\";\nimport { TextInputSuggest } from \"./suggest\";\n\nexport class FolderSuggest extends TextInputSuggest<TFolder> {\n\tgetSuggestions(inputStr: string): TFolder[] {\n\t\tconst abstractFiles = this.app.vault.getAllLoadedFiles();\n\t\tconst folders: TFolder[] = [];\n\t\tconst lowerCaseInputStr = inputStr.toLowerCase();\n\n\t\tabstractFiles.forEach((folder: TAbstractFile) => {\n\t\t\tif (\n\t\t\t\tfolder instanceof TFolder &&\n\t\t\t\tfolder.path.toLowerCase().contains(lowerCaseInputStr)\n\t\t\t) {\n\t\t\t\tfolders.push(folder);\n\t\t\t}\n\t\t});\n\n\t\treturn folders;\n\t}\n\n\trenderSuggestion(file: TFolder, el: HTMLElement): void {\n\t\tel.setText(file.path);\n\t}\n\n\tselectSuggestion(file: TFolder): void {\n\t\tthis.inputEl.value = file.path;\n\t\tthis.inputEl.trigger(\"input\");\n\t\tthis.close();\n\t}\n}\n"
  },
  {
    "path": "src/settings/suggesters/suggest.ts",
    "content": "// copy of https://github.com/anpigon/obsidian-book-search-plugin/blob/9bd8d0cad45196aed7fe5ab1dda62052718230eb/src/settings/suggesters/suggest.ts\n// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes\n\nimport { App, ISuggestOwner, Scope } from \"obsidian\";\nimport { createPopper, Instance as PopperInstance } from \"@popperjs/core\";\n\nconst wrapAround = (value: number, size: number): number => {\n\treturn ((value % size) + size) % size;\n};\n\nclass Suggest<T> {\n\tprivate owner: ISuggestOwner<T>;\n\tprivate values: T[];\n\tprivate suggestions: HTMLDivElement[];\n\tprivate selectedItem: number;\n\tprivate containerEl: HTMLElement;\n\n\tconstructor(\n\t\towner: ISuggestOwner<T>,\n\t\tcontainerEl: HTMLElement,\n\t\tscope: Scope,\n\t) {\n\t\tthis.owner = owner;\n\t\tthis.containerEl = containerEl;\n\n\t\tcontainerEl.on(\n\t\t\t\"click\",\n\t\t\t\".suggestion-item\",\n\t\t\tthis.onSuggestionClick.bind(this),\n\t\t);\n\t\tcontainerEl.on(\n\t\t\t\"mousemove\",\n\t\t\t\".suggestion-item\",\n\t\t\tthis.onSuggestionMouseover.bind(this),\n\t\t);\n\n\t\tscope.register([], \"ArrowUp\", (event) => {\n\t\t\tif (!event.isComposing) {\n\t\t\t\tthis.setSelectedItem(this.selectedItem - 1, true);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\n\t\tscope.register([], \"ArrowDown\", (event) => {\n\t\t\tif (!event.isComposing) {\n\t\t\t\tthis.setSelectedItem(this.selectedItem + 1, true);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\n\t\tscope.register([], \"Enter\", (event) => {\n\t\t\tif (!event.isComposing) {\n\t\t\t\tthis.useSelectedItem(event);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\t}\n\n\tonSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {\n\t\tevent.preventDefault();\n\n\t\tconst item = this.suggestions.indexOf(el);\n\t\tthis.setSelectedItem(item, false);\n\t\tthis.useSelectedItem(event);\n\t}\n\n\tonSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {\n\t\tconst item = this.suggestions.indexOf(el);\n\t\tthis.setSelectedItem(item, false);\n\t}\n\n\tsetSuggestions(values: T[]) {\n\t\tthis.containerEl.empty();\n\t\tconst suggestionEls: HTMLDivElement[] = [];\n\n\t\tvalues.forEach((value) => {\n\t\t\tconst suggestionEl = this.containerEl.createDiv(\"suggestion-item\");\n\t\t\tthis.owner.renderSuggestion(value, suggestionEl);\n\t\t\tsuggestionEls.push(suggestionEl);\n\t\t});\n\n\t\tthis.values = values;\n\t\tthis.suggestions = suggestionEls;\n\t\tthis.setSelectedItem(0, false);\n\t}\n\n\tuseSelectedItem(event: MouseEvent | KeyboardEvent) {\n\t\tconst currentValue = this.values[this.selectedItem];\n\t\tif (currentValue) {\n\t\t\tthis.owner.selectSuggestion(currentValue, event);\n\t\t}\n\t}\n\n\tsetSelectedItem(selectedIndex: number, scrollIntoView: boolean) {\n\t\tconst normalizedIndex = wrapAround(\n\t\t\tselectedIndex,\n\t\t\tthis.suggestions.length,\n\t\t);\n\t\tconst prevSelectedSuggestion = this.suggestions[this.selectedItem];\n\t\tconst selectedSuggestion = this.suggestions[normalizedIndex];\n\n\t\tprevSelectedSuggestion?.removeClass(\"is-selected\");\n\t\tselectedSuggestion?.addClass(\"is-selected\");\n\n\t\tthis.selectedItem = normalizedIndex;\n\n\t\tif (scrollIntoView) {\n\t\t\tselectedSuggestion.scrollIntoView(false);\n\t\t}\n\t}\n}\n\nexport abstract class TextInputSuggest<T> implements ISuggestOwner<T> {\n\tprivate popper: PopperInstance;\n\tprivate scope: Scope;\n\tprivate suggestEl: HTMLElement;\n\tprivate suggest: Suggest<T>;\n\n\tconstructor(\n\t\tprotected app: App,\n\t\tprotected inputEl: HTMLInputElement | HTMLTextAreaElement,\n\t) {\n\t\tthis.scope = new Scope();\n\n\t\tthis.suggestEl = createDiv(\"suggestion-container\");\n\t\tconst suggestion = this.suggestEl.createDiv(\"suggestion\");\n\t\tthis.suggest = new Suggest(this, suggestion, this.scope);\n\n\t\tthis.scope.register([], \"Escape\", this.close.bind(this));\n\n\t\tthis.inputEl.addEventListener(\"input\", this.onInputChanged.bind(this));\n\t\tthis.inputEl.addEventListener(\"focus\", this.onInputChanged.bind(this));\n\t\tthis.inputEl.addEventListener(\"blur\", this.close.bind(this));\n\t\tthis.suggestEl.on(\n\t\t\t\"mousedown\",\n\t\t\t\".suggestion-container\",\n\t\t\t(event: MouseEvent) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t},\n\t\t);\n\t}\n\n\tonInputChanged(): void {\n\t\tconst inputStr = this.inputEl.value;\n\t\tconst suggestions = this.getSuggestions(inputStr);\n\n\t\tif (!suggestions) {\n\t\t\tthis.close();\n\t\t\treturn;\n\t\t}\n\n\t\tif (suggestions.length > 0) {\n\t\t\tthis.suggest.setSuggestions(suggestions);\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t\tthis.open((<any>this.app).dom.appContainerEl, this.inputEl);\n\t\t} else {\n\t\t\tthis.close();\n\t\t}\n\t}\n\n\topen(container: HTMLElement, inputEl: HTMLElement): void {\n\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t(<any>this.app).keymap.pushScope(this.scope);\n\n\t\tcontainer.appendChild(this.suggestEl);\n\t\tthis.popper = createPopper(inputEl, this.suggestEl, {\n\t\t\tplacement: \"bottom-start\",\n\t\t\tmodifiers: [\n\t\t\t\t{\n\t\t\t\t\tname: \"sameWidth\",\n\t\t\t\t\tenabled: true,\n\t\t\t\t\tfn: ({ state, instance }) => {\n\t\t\t\t\t\t// Note: positioning needs to be calculated twice -\n\t\t\t\t\t\t// first pass - positioning it according to the width of the popper\n\t\t\t\t\t\t// second pass - position it with the width bound to the reference element\n\t\t\t\t\t\t// we need to early exit to avoid an infinite loop\n\t\t\t\t\t\tconst targetWidth = `${state.rects.reference.width}px`;\n\t\t\t\t\t\tif (state.styles.popper.width === targetWidth) {\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstate.styles.popper.width = targetWidth;\n\t\t\t\t\t\tinstance.update();\n\t\t\t\t\t},\n\t\t\t\t\tphase: \"beforeWrite\",\n\t\t\t\t\trequires: [\"computeStyles\"],\n\t\t\t\t},\n\t\t\t],\n\t\t});\n\t}\n\n\tclose(): void {\n\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t(<any>this.app).keymap.popScope(this.scope);\n\n\t\tthis.suggest.setSuggestions([]);\n\t\tif (this.popper) this.popper.destroy();\n\t\tthis.suggestEl.detach();\n\t}\n\n\tabstract getSuggestions(inputStr: string): T[];\n\tabstract renderSuggestion(item: T, el: HTMLElement): void;\n\tabstract selectSuggestion(item: T): void;\n}\n"
  },
  {
    "path": "styles.css",
    "content": ".booksidian-plugin__settings .search-input-container {\n\twidth: 100%;\n}\n\n.booksidian-plugin__settings input, textarea, select {\n\tmin-width: 200px;\n}\n"
  },
  {
    "path": "test/BookTest.ts",
    "content": "import { Book } from \"src/Book\";\nimport { GoodreadsBook } from \"const/goodreads\";\nimport { Book_id } from \"const/goodreads\";\nimport { Identifiers } from \"const/goodreads\";\n\nconst test_book_id: Book_id = {\n\tid: \"sample_id\",\n};\n\nconst test_identifier: Identifiers = {\n\t$: test_book_id,\n\tnum_pages: [\"123\"],\n};\n\nconst test_book: GoodreadsBook = {\n\tauthor: \"test_author\",\n\ttitle: \"test_title\",\n\tlink: \"test_link\",\n\tpubDate: \"01/01/1970\",\n\tisbn: \"0123456789\",\n\tuser_rating: \"5\",\n\tuser_review: \"Test review\",\n\tbook_description: \"Test description\",\n\taverage_rating: \"3\",\n\tuser_read_at: \"01/01/1970\",\n\tuser_date_added: \"01/01/1970\",\n\tuser_date_created: \"01/01/1970\",\n\tbook_published: \"01/01/1970\",\n\tidentifiers: test_identifier,\n\tcontent: \"test_content\",\n\tcontentSnippet: \"test_content_snippet\",\n\tguid: \"test_guid\",\n\tuser_shelves: \"test_shelf\",\n\timage_url: \"test_image_url\",\n\timage_path: \"test_image_url\",\n};\n\ndescribe(\"Empty title\", () => {\n\ttest(\"empty title should result in empty_string\", () => {\n\t\t// Given\n\t\ttest_book.title = \"\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"\");\n\t});\n});\n\ndescribe(\"No special character title\", () => {\n\ttest(\"No special character title should result in title\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '?' character\", () => {\n\ttest(\"Title with '?' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book?\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '#' character\", () => {\n\ttest(\"Title with '#' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book#\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '&' character\", () => {\n\ttest(\"Title with '&' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book&\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '{' character\", () => {\n\ttest(\"Title with '{' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book{\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '}' character\", () => {\n\ttest(\"Title with '}' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book}\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '%' character\", () => {\n\ttest(\"Title with '%' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book%\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '<' character\", () => {\n\ttest(\"Title with '<' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book<\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '>' character\", () => {\n\ttest(\"Title with '>' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book>\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '$' character\", () => {\n\ttest(\"Title with '$' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book$\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '*' character\", () => {\n\ttest(\"Title with '*' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book*\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '|' character\", () => {\n\ttest(\"Title with '|' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book|\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '\\\\' character\", () => {\n\ttest(\"Title with '\\\\' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book\\\\\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '/' character\", () => {\n\ttest(\"Title with '/' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book/\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with ':' character\", () => {\n\ttest(\"Title with ':' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = \"My wonderful book:\";\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\ndescribe(\"Title with '\\\"' character\", () => {\n\ttest(\"Title with '\\\"' character should have it replaced with empty char\", () => {\n\t\t// Given\n\t\ttest_book.title = 'My wonderful book\"';\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.title).toBe(\"My wonderful book\");\n\t});\n});\n\n\ndescribe(\"Series information parser\", () => {\n\ttest(\"No series\", () => {\n\t\t// Given\n\t\ttest_book.title = 'My wonderful book';\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.series).toBe(\"\");\n\t\texpect(unit.seriesName).toBe(\"\");\n\t\texpect(unit.seriesNumber).toBe(0);\n\t});\n\n\ttest(\"Series with (, #)\", () => {\n\t\t// Given\n\t\ttest_book.title = 'My wonderful book (My series, #15)';\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.series).toBe(\"My series, #15\");\n\t\texpect(unit.seriesName).toBe(\"My series\");\n\t\texpect(unit.seriesNumber).toBe(15);\n\t});\n\n\ttest(\"Series with ( #)\", () => {\n\t\t// Given\n\t\ttest_book.title = 'My wonderful book (My series #15)';\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.series).toBe(\"My series #15\");\n\t\texpect(unit.seriesName).toBe(\"My series\");\n\t\texpect(unit.seriesNumber).toBe(15);\n\t});\n\n\ttest(\"Series without number\", () => {\n\t\t// Given\n\t\ttest_book.title = 'My wonderful book (My series)';\n\t\t// When\n\t\tconst unit = new Book(null, test_book);\n\t\t// Then\n\t\texpect(unit.series).toBe(\"\");\n\t\texpect(unit.seriesName).toBe(\"\");\n\t\texpect(unit.seriesNumber).toBe(0);\n\t});\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\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\",\r\n\t\t\"target\": \"ES6\",\r\n\t\t\"allowJs\": true,\r\n\t\t\"noImplicitAny\": true,\r\n\t\t\"moduleResolution\": \"node\",\r\n\t\t\"importHelpers\": true,\r\n\t\t\"isolatedModules\": true,\r\n\t\t\"lib\": [\"DOM\", \"ES5\", \"ES6\", \"ES7\", \"ES2021.String\"]\r\n\t},\r\n\t\"include\": [\"**/*.ts\"]\r\n}\r\n"
  },
  {
    "path": "version-bump.mjs",
    "content": "import { readFileSync, writeFileSync } from \"fs\";\n\nconst targetVersion = process.env.npm_package_version;\n\n// read minAppVersion from manifest.json and bump version to target version\nlet manifest = JSON.parse(readFileSync(\"manifest.json\", \"utf8\"));\nconst { minAppVersion } = manifest;\nmanifest.version = targetVersion;\nwriteFileSync(\"manifest.json\", JSON.stringify(manifest, null, \"\\t\"));\n\n// update versions.json with target version and minAppVersion from manifest.json\nlet versions = JSON.parse(readFileSync(\"versions.json\", \"utf8\"));\nversions[targetVersion] = minAppVersion;\nwriteFileSync(\"versions.json\", JSON.stringify(versions, null, \"\\t\"));\n"
  },
  {
    "path": "versions.json",
    "content": "{\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\",\n\t\"0.3.1\": \"0.12.0\",\n\t\"0.3.2\": \"0.12.0\",\n\t\"0.3.3\": \"0.12.0\",\n\t\"0.3.4\": \"0.12.0\",\n\t\"0.3.5\": \"0.12.0\",\n\t\"0.3.6\": \"0.12.0\",\n\t\"0.3.7\": \"0.12.0\",\n\t\"0.4.0\": \"0.12.0\",\n\t\"0.4.1\": \"0.12.0\",\n\t\"0.5.0\": \"0.12.0\",\n\t\"0.5.1\": \"0.12.0\",\n\t\"0.5.2\": \"0.12.0\",\n\t\"0.6.0\": \"0.12.0\",\n\t\"0.6.1\": \"0.12.0\",\n\t\"0.7.0\": \"0.12.0\",\n\t\"0.8.0\": \"0.12.0\",\n\t\"0.8.1\": \"0.12.0\",\n\t\"0.9.0\": \"0.12.0\",\n\t\"0.9.1\": \"0.12.0\",\n\t\"0.9.2\": \"0.12.0\",\n\t\"0.9.3\": \"0.12.0\",\n\t\"0.10.0\": \"0.12.0\",\n\t\"0.10.1\": \"0.12.0\"\n}"
  }
]