import { Notice, Plugin, requestUrl } from "obsidian"; import { type BookTrackerPluginSettings, BookTrackerSettingTab, DEFAULT_SETTINGS, } from "./settings/settings"; import { getBookByLegacyId, type SearchResult } from "@data-sources/goodreads"; import { Templater } from "./utils/templater"; import { GoodreadsSearchModal } from "@views/goodreads-search-modal"; import { GoodreadsSearchSuggestModal } from "@views/goodreads-search-suggest-modal"; import { CONTENT_TYPE_EXTENSIONS, IN_PROGRESS_STATE, READ_STATE, TO_BE_READ_STATE, } from "./const"; import { ReadingLog, Storage } from "@utils/storage"; import { ReadingProgressModal } from "@views/reading-progress-modal"; import { RatingModal } from "@views/rating-modal"; import { renderCodeBlockProcessor } from "@utils/svelte"; import ReadingLogViewer from "@components/ReadingLogViewer.svelte"; export default class BookTrackerPlugin extends Plugin { settings: BookTrackerPluginSettings; templater: Templater; storage: Storage; readingLog: ReadingLog; async onload() { await this.loadSettings(); this.templater = new Templater(this.app); this.storage = new Storage(this.app, this); this.readingLog = new ReadingLog(this.app, this); this.addCommand({ id: "search-goodreads", name: "Search Goodreads", callback: () => this.searchGoodreads(), }); this.addCommand({ id: "log-reading-started", name: "Log Reading Started", callback: () => this.logReadingStarted(), }); this.addCommand({ id: "log-reading-progress", name: "Log Reading Progress", callback: () => this.logReadingProgress(), }); this.addCommand({ id: "log-reading-completed", name: "Log Reading Completed", callback: () => this.logReadingFinished(), }); this.addCommand({ id: "reset-reading-status", name: "Reset Reading Status", callback: () => this.resetReadingStatus(), }); this.addSettingTab(new BookTrackerSettingTab(this.app, this)); this.registerMarkdownCodeBlockProcessor( "readinglog", renderCodeBlockProcessor(ReadingLogViewer, { app: this.app, readingLog: this.readingLog, }) ); } 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 ): 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.coverDirectory + "/"; if (this.settings.groupCoversByFirstLetter) { let groupName = fileName.charAt(0).toUpperCase(); if (!/^[A-Z]$/.test(groupName)) { groupName = "#"; } filePath += groupName + "/"; } filePath += fileName + "." + extension; const existingFile = this.app.vault.getFileByPath(filePath); if (existingFile) { if (this.settings.overwriteExistingCovers) { await this.app.vault.modifyBinary( existingFile, response.arrayBuffer ); } else { new Notice("Cover image already exists: " + filePath); return filePath; } } await this.app.vault.createBinary(filePath, response.arrayBuffer); return filePath; } async createEntryFromGoodreads(legacyId: number): Promise { try { const book = await getBookByLegacyId(legacyId); 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) { data.coverImagePath = await this.downloadCoverImage( book.coverImageUrl, fileName ); } const renderedContent = await this.templater.renderTemplateFile( this.settings.templateFile, data ); if (renderedContent) { await this.app.vault.create( this.settings.tbrDirectory + "/" + fileName + ".md", renderedContent ); } } catch (error) { console.error("Failed to create book entry:", error); } } async searchGoodreads(): Promise { let results: SearchResult[]; try { results = await GoodreadsSearchModal.createAndOpen(this.app); } catch (error) { console.error("Failed to search Goodreads:", error); new Notice( "Failed to search Goodreads. Check console for details." ); return; } const selectedBook = await GoodreadsSearchSuggestModal.createAndOpen( this.app, results ); if (selectedBook) { await this.createEntryFromGoodreads(selectedBook.legacyId); } else { new Notice("No book selected."); } } logReadingStarted(): void { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("No active file to mark as currently reading."); return; } if (activeFile.extension !== "md") { new Notice("Active file is not a markdown file."); return; } if (!this.settings.statusProperty) { new Notice("Status property is not set in settings."); return; } if (!this.settings.startDateProperty) { new Notice("Start date property is not set in settings."); return; } // @ts-expect-error Moment is provided by Obsidian const startDate = moment().format("YYYY-MM-DD"); this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => { frontMatter[this.settings.statusProperty] = IN_PROGRESS_STATE; frontMatter[this.settings.startDateProperty] = startDate; }); new Notice("Reading started for " + activeFile.name); } async logReadingProgress() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("No active file to log reading progress."); return; } if (activeFile.extension !== "md") { new Notice("Active file is not a markdown file."); return; } const fileName = activeFile.basename; if (!this.settings.pageLengthProperty) { new Notice("Page length property is not set in settings."); return; } const pageLength = (this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[ this.settings.pageLengthProperty ] as number | undefined) ?? 0; if (pageLength <= 0) { new Notice( "Page length property is not set or is invalid in the active file." ); return; } const pageNumber = await ReadingProgressModal.createAndOpen( this.app, pageLength ); if (pageNumber <= 0 || pageNumber > pageLength) { new Notice( `Invalid page number: ${pageNumber}. It must be between 1 and ${pageLength}.` ); return; } await this.readingLog.addEntry(fileName, pageNumber, pageLength); new Notice( `Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageLength}.` ); } async logReadingFinished() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("No active file to mark as finished reading."); return; } if (activeFile.extension !== "md") { new Notice("Active file is not a markdown file."); return; } if (!this.settings.statusProperty) { new Notice("Status property is not set in settings."); return; } if (!this.settings.endDateProperty) { new Notice("End date property is not set in settings."); return; } if (!this.settings.ratingProperty) { new Notice("Rating property is not set in settings."); return; } const pageLength = (this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[ this.settings.pageLengthProperty ] as number | undefined) ?? 0; if (pageLength <= 0) { new Notice( "Page length property is not set or is invalid in the active file." ); return; } const rating = await RatingModal.createAndOpen(this.app); await this.readingLog.addEntry( activeFile.basename, pageLength, pageLength ); // @ts-expect-error Moment is provided by Obsidian const endDate = moment().format("YYYY-MM-DD"); this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => { frontMatter[this.settings.statusProperty] = READ_STATE; frontMatter[this.settings.endDateProperty] = endDate; frontMatter[this.settings.ratingProperty] = rating; }); new Notice("Reading finished for " + activeFile.name); } resetReadingStatus(): any { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("No active file to reset reading status."); return; } if (activeFile.extension !== "md") { new Notice("Active file is not a markdown file."); return; } if (!this.settings.statusProperty) { new Notice("Status property is not set in settings."); return; } if (!this.settings.startDateProperty) { new Notice("Start date property is not set in settings."); return; } if (!this.settings.endDateProperty) { new Notice("End date property is not set in settings."); return; } this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => { frontMatter[this.settings.statusProperty] = TO_BE_READ_STATE; frontMatter[this.settings.startDateProperty] = ""; frontMatter[this.settings.endDateProperty] = ""; }); new Notice("Reading status reset for " + activeFile.name); } }