import { Notice, Plugin, requestUrl, TFile, type FrontMatterCache, } from "obsidian"; import { type BookTrackerPluginSettings, DEFAULT_SETTINGS, BookTrackerSettingTab, } from "@ui/settings"; import { Templater } from "@utils/Templater"; import { CONTENT_TYPE_EXTENSIONS } from "./const"; import { Storage } from "@utils/Storage"; import { ReadingLog } from "@utils/ReadingLog"; import { registerReadingLogCodeBlockProcessor, registerReadingStatsCodeBlockProcessor, } from "@ui/code-blocks"; import type { Book, BookMetadata, ReadingState } from "./types"; import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand"; import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand"; import { LogReadingProgressCommand } from "@commands/LogReadingProgressCommand"; import { LogReadingFinishedCommand } from "@commands/LogReadingFinishedCommand"; import { ResetReadingStatusCommand } from "@commands/ResetReadingStatusCommand"; import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand"; import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand"; import { Goodreads } from "@data-sources/Goodreads"; import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand"; import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock"; import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand"; import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock"; import { registerAToZChallengeCodeBlockProcessor } from "@ui/code-blocks/AToZChallengeCodeBlock"; import moment from "@external/moment"; import { compressImage } from "@utils/image"; export default class BookTrackerPlugin extends Plugin { public settings: BookTrackerPluginSettings; public templater: Templater; public storage: Storage; public readingLog: ReadingLog; public goodreads: Goodreads = new Goodreads(); async onload() { await this.loadSettings(); this.templater = new Templater(this.app); this.storage = new Storage(this); this.readingLog = new ReadingLog(this.storage); this.addCommand( new SearchGoodreadsCommand( this.app, this.goodreads, this.createEntry.bind(this) ) ); this.addCommand(new LogReadingStartedCommand(this.app, this.settings)); this.addCommand(new LogReadingProgressCommand(this)); this.addCommand(new LogReadingFinishedCommand(this)); this.addCommand( new ResetReadingStatusCommand( this.app, this.readingLog, this.settings ) ); this.addCommand(new BackupReadingLogCommand(this.readingLog)); this.addCommand( new RestoreReadingLogBackupCommand( this.app, this.storage, this.readingLog ) ); this.addCommand( new CreateBookFromGoodreadsUrlCommand( this.goodreads, this.createEntry.bind(this) ) ); this.addCommand(new ReloadReadingLogCommand(this.readingLog)); this.addSettingTab(new BookTrackerSettingTab(this)); registerReadingLogCodeBlockProcessor(this); registerReadingStatsCodeBlockProcessor(this); registerShelfCodeBlockProcessor(this); registerReadingCalendarCodeBlockProcessor(this); registerAToZChallengeCodeBlockProcessor(this); } onunload() {} async loadSettings() { this.settings = Object.assign( {}, DEFAULT_SETTINGS, await this.loadData() ); } async saveSettings() { await this.saveData(this.settings); } async downloadCoverImage( coverImageUrl: string, fileName: string, overwrite?: boolean ): Promise { const response = await requestUrl(coverImageUrl); const contentType = response.headers["content-type"]; const extension = CONTENT_TYPE_EXTENSIONS[contentType || ""] || ""; if (extension === "") { throw new Error("Unsupported content type: " + contentType); } let filePath = this.settings.coverFolder + "/"; if (this.settings.groupCoversByFirstLetter) { let groupName = fileName.charAt(0).toUpperCase(); if (!/^[A-Z]$/.test(groupName)) { groupName = "#"; } filePath += groupName + "/"; } filePath += fileName + "." + extension; let file = this.app.vault.getFileByPath(filePath); if (file) { if (this.settings.overwriteExistingCovers || overwrite) { await this.app.vault.modifyBinary(file, response.arrayBuffer); } else { new Notice("Cover image already exists: " + filePath); return file; } } else { file = await this.app.vault.createBinary( filePath, response.arrayBuffer ); } await compressImage(this.app, file, { height: 400, quality: 0.8, maintainAspectRatio: true, }); return file; } async createEntry(book: Book): Promise { const fileName = this.templater .renderTemplate(this.settings.fileNameFormat, { title: book.title, authors: book.authors.map((a) => a.name).join(", "), }) .replace(/[/:*?<>|""]/g, ""); const data: Record = { book }; if (this.settings.downloadCovers && book.coverImageUrl) { const coverImageFile = await this.downloadCoverImage( book.coverImageUrl, fileName ); data.coverImagePath = coverImageFile.path; } const renderedContent = await this.templater.renderTemplateFile( this.settings.templateFile, data ); const filePath = this.settings.tbrFolder + "/" + fileName + ".md"; const file = await this.app.vault.create(filePath, renderedContent); await this.app.workspace.getLeaf().openFile(file); } getBookMetadata(file: TFile): BookMetadata | null { const metadata = this.app.metadataCache.getFileCache(file); if (!metadata) { return null; } return this.frontmatterToMetadata(metadata.frontmatter); } frontmatterToMetadata(fm: FrontMatterCache | undefined): BookMetadata { const getString = (key: string) => { const value = fm?.[key]; if (typeof value === "string") { return value; } return ""; }; const getStringArray = (key: string) => { const value = fm?.[key]; if (Array.isArray(value)) { return value as string[]; } return []; }; const getNumber = (key: string) => { const value = fm?.[key]; if (typeof value === "number") { return value; } else if (typeof value === "string") { return parseFloat(value); } return 0; }; const getDate = (key: string) => { const value = fm?.[key]; if (typeof value === "string" || value instanceof Date) { return moment(value); } return null; }; return { title: getString(this.settings.titleProperty), subtitle: getString(this.settings.subtitleProperty), description: getString(this.settings.descriptionProperty), authors: getStringArray(this.settings.authorsProperty), seriesTitle: getString(this.settings.seriesTitleProperty), seriesPosition: getNumber(this.settings.seriesPositionProperty), startDate: getDate(this.settings.startDateProperty)!, endDate: getDate(this.settings.endDateProperty)!, status: getString(this.settings.statusProperty) as ReadingState, rating: getNumber(this.settings.ratingProperty), spice: getNumber(this.settings.spiceProperty), format: getString(this.settings.formatProperty), source: getStringArray(this.settings.sourceProperty), categories: getStringArray(this.settings.categoriesProperty), publisher: getString(this.settings.publisherProperty), publishDate: getDate(this.settings.publishDateProperty)!, pageCount: getNumber(this.settings.pageCountProperty), isbn: getString(this.settings.isbnProperty), coverImageUrl: getString(this.settings.coverImageUrlProperty), localCoverPath: getString(this.settings.localCoverPathProperty), }; } }