From 35ed9b95ee76fc5d972eeb4a05788c309ab6b87d Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Thu, 26 Jun 2025 22:16:11 -0400 Subject: [PATCH] Add reading log functionality --- sass/styles.scss | 1 + sass/views/reading-progress.scss | 32 +++++ src/const.ts | 10 ++ src/main.ts | 204 +++++++++++++++++++++++++++- src/settings/settings.ts | 149 ++++++++++++++++++++ src/settings/suggesters/core.ts | 9 +- src/settings/suggesters/field.ts | 39 ++++++ src/utils/storage.ts | 114 ++++++++++++++++ src/views/goodreads-search-modal.ts | 4 +- src/views/reading-progress-modal.ts | 92 +++++++++++++ styles.css | 26 ++++ 11 files changed, 669 insertions(+), 11 deletions(-) create mode 100644 sass/views/reading-progress.scss create mode 100644 src/const.ts create mode 100644 src/settings/suggesters/field.ts create mode 100644 src/utils/storage.ts create mode 100644 src/views/reading-progress-modal.ts diff --git a/sass/styles.scss b/sass/styles.scss index e806364..0e2800c 100644 --- a/sass/styles.scss +++ b/sass/styles.scss @@ -1,3 +1,4 @@ @use "views/goodreads-search.scss"; @use "views/goodreads-search-suggest.scss"; +@use "views/reading-progress.scss"; @use "settings.scss"; diff --git a/sass/views/reading-progress.scss b/sass/views/reading-progress.scss new file mode 100644 index 0000000..6f26fb1 --- /dev/null +++ b/sass/views/reading-progress.scss @@ -0,0 +1,32 @@ +.obt-reading-progress { + &__desc { + margin-bottom: 1rem; + font-size: var(--text-ui-smaller); + color: var(--text-muted); + + p { + margin: 0; + } + } + + &__input { + padding-bottom: 18px; + + input { + width: 100%; + } + } + + &__pct { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 18px; + } + + &__toggle { + display: flex; + align-items: center; + flex-grow: 1; + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..43e92cb --- /dev/null +++ b/src/const.ts @@ -0,0 +1,10 @@ +export const TO_BE_READ_STATE = "To Be Read"; +export const IN_PROGRESS_STATE = "Currently Reading"; +export const READ_STATE = "Read"; + +export const CONTENT_TYPE_EXTENSIONS: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", +}; diff --git a/src/main.ts b/src/main.ts index 38fc431..34288a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,22 +8,27 @@ import { getBookByLegacyId } from "@data-sources/goodreads"; import { Templater } from "./utils/templater"; import { GoodreadsSearchModal } from "@views/goodreads-search-modal"; import { GoodreadsSearchSuggestModal } from "@views/goodreads-search-suggest-modal"; - -const CONTENT_TYPE_EXTENSIONS: Record = { - "image/jpeg": "jpg", - "image/png": "png", - "image/gif": "gif", - "image/webp": "webp", -}; +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"; 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", @@ -31,6 +36,30 @@ export default class BookTrackerPlugin extends Plugin { 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)); } @@ -137,4 +166,165 @@ export default class BookTrackerPlugin extends Plugin { 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); + + 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); + 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; + } + + 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; + } + + await this.readingLog.addEntry(activeFile.basename, 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; + }); + + 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); + } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index d2a728d..75016b3 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -2,6 +2,7 @@ import BookTrackerPlugin from "@src/main"; import { App, PluginSettingTab, Setting } from "obsidian"; import { FileSuggest } from "./suggesters/file"; import { FolderSuggest } from "./suggesters/folder"; +import { FieldSuggest } from "./suggesters/field"; export interface BookTrackerPluginSettings { templateFile: string; @@ -11,6 +12,12 @@ export interface BookTrackerPluginSettings { coverDirectory: string; groupCoversByFirstLetter: boolean; overwriteExistingCovers: boolean; + statusProperty: string; + startDateProperty: string; + endDateProperty: string; + ratingProperty: string; + pageLengthProperty: string; + readingLogDirectory: string; } export const DEFAULT_SETTINGS: BookTrackerPluginSettings = { @@ -21,6 +28,12 @@ export const DEFAULT_SETTINGS: BookTrackerPluginSettings = { coverDirectory: "images/covers", groupCoversByFirstLetter: true, overwriteExistingCovers: false, + statusProperty: "status", + startDateProperty: "startDate", + endDateProperty: "endDate", + ratingProperty: "rating", + pageLengthProperty: "pageLength", + readingLogDirectory: "reading-logs", }; export class BookTrackerSettingTab extends PluginSettingTab { @@ -48,6 +61,142 @@ export class BookTrackerSettingTab extends PluginSettingTab { this.coverDirectorySetting(); this.groupCoversByFirstLetterSetting(); this.overwriteExistingCoversSetting(); + + this.heading("Reading Progress Settings"); + this.statusPropertySetting(); + this.startDatePropertySetting(); + this.endDatePropertySetting(); + this.ratingPropertySetting(); + this.pageLengthPropertySetting(); + this.readingLogDirectorySetting(); + } + + readingLogDirectorySetting() { + return new Setting(this.containerEl) + .setName("Reading Log Directory") + .setDesc("Select the directory where reading logs will be stored") + .addSearch((cb) => { + try { + new FolderSuggest(this.app, cb.inputEl); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("reading-logs") + .setValue(this.plugin.settings.readingLogDirectory) + .onChange(async (value) => { + this.plugin.settings.readingLogDirectory = value; + await this.plugin.saveSettings(); + }); + }); + } + + pageLengthPropertySetting() { + return new Setting(this.containerEl) + .setName("Page Length Property") + .setDesc( + "Property used to track the total number of pages in a book." + ) + .addSearch((cb) => { + try { + new FieldSuggest(this.app, cb.inputEl, ["number"]); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("pageLength") + .setValue(this.plugin.settings.pageLengthProperty) + .onChange(async (value) => { + this.plugin.settings.pageLengthProperty = value; + await this.plugin.saveSettings(); + }); + }); + } + + ratingPropertySetting() { + return new Setting(this.containerEl) + .setName("Rating Property") + .setDesc("Property used to track the rating of a book.") + .addSearch((cb) => { + try { + new FieldSuggest(this.app, cb.inputEl, ["number"]); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("rating") + .setValue(this.plugin.settings.ratingProperty) + .onChange(async (value) => { + this.plugin.settings.ratingProperty = value; + await this.plugin.saveSettings(); + }); + }); + } + + endDatePropertySetting() { + return new Setting(this.containerEl) + .setName("End Date Property") + .setDesc("Property used to track the end date of reading a book.") + .addSearch((cb) => { + try { + new FieldSuggest(this.app, cb.inputEl, ["date"]); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("endDate") + .setValue(this.plugin.settings.endDateProperty) + .onChange(async (value) => { + this.plugin.settings.endDateProperty = value; + await this.plugin.saveSettings(); + }); + }); + } + + startDatePropertySetting() { + return new Setting(this.containerEl) + .setName("Start Date Property") + .setDesc("Property used to track the start date of reading a book.") + .addSearch((cb) => { + try { + new FieldSuggest(this.app, cb.inputEl, ["date"]); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("startDate") + .setValue(this.plugin.settings.startDateProperty) + .onChange(async (value) => { + this.plugin.settings.startDateProperty = value; + await this.plugin.saveSettings(); + }); + }); + } + + statusPropertySetting() { + return new Setting(this.containerEl) + .setName("Status Property") + .setDesc("Property used to track the reading status of a book.") + .addSearch((cb) => { + try { + new FieldSuggest(this.app, cb.inputEl, ["text"]); + } catch { + // If the suggest fails, we can just ignore it. + // This might happen if the plugin is not fully loaded yet. + } + + cb.setPlaceholder("status") + .setValue(this.plugin.settings.statusProperty) + .onChange(async (value) => { + this.plugin.settings.statusProperty = value; + await this.plugin.saveSettings(); + }); + }); } overwriteExistingCoversSetting() { diff --git a/src/settings/suggesters/core.ts b/src/settings/suggesters/core.ts index d8fdbdb..36196cf 100644 --- a/src/settings/suggesters/core.ts +++ b/src/settings/suggesters/core.ts @@ -139,9 +139,12 @@ export abstract class TextInputSuggest implements ISuggestOwner { ); } - onInputChanged(): void { + async onInputChanged(): Promise { const inputStr = this.inputEl.value; - const suggestions = this.getSuggestions(inputStr); + let suggestions = this.getSuggestions(inputStr); + if (suggestions instanceof Promise) { + suggestions = await suggestions; + } if (!suggestions) { this.close(); @@ -196,7 +199,7 @@ export abstract class TextInputSuggest implements ISuggestOwner { this.suggestEl.detach(); } - abstract getSuggestions(inputStr: string): T[]; + abstract getSuggestions(inputStr: string): T[] | Promise; abstract renderSuggestion(item: T, el: HTMLElement): void; abstract selectSuggestion(item: T): void; } diff --git a/src/settings/suggesters/field.ts b/src/settings/suggesters/field.ts new file mode 100644 index 0000000..47ee1cc --- /dev/null +++ b/src/settings/suggesters/field.ts @@ -0,0 +1,39 @@ +import { App } from "obsidian"; +import { TextInputSuggest } from "./core"; + +export class FieldSuggest extends TextInputSuggest { + constructor( + app: App, + inputEl: HTMLInputElement, + private readonly accepts?: string[] + ) { + super(app, inputEl); + } + + async getSuggestions(inputStr: string): Promise { + const typesContent = await this.app.vault.adapter.read( + this.app.vault.configDir + "/types.json" + ); + const types = JSON.parse(typesContent).types; + + return Object.entries(types) + .filter(([field, type]) => { + if (this.accepts && !this.accepts.includes(type as string)) { + return false; + } + + return field.toLowerCase().includes(inputStr.toLowerCase()); + }) + .map(([field, _]) => field); + } + + renderSuggestion(field: string, el: HTMLElement): void { + el.setText(field); + } + + selectSuggestion(field: string): void { + this.inputEl.value = field; + this.inputEl.trigger("input"); + this.close(); + } +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..9a7ebb0 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,114 @@ +import BookTrackerPlugin from "@src/main"; +import { App } from "obsidian"; + +export class Storage { + public constructor( + private readonly app: App, + private readonly plugin: BookTrackerPlugin + ) {} + + private getFilePath(filename: string): string { + return `${this.plugin.manifest.dir!!}/${filename}`; + } + + public async readJSON(filename: string): Promise { + const filePath = this.getFilePath(filename); + const content = await this.app.vault.adapter.read(filePath); + if (!content) { + return null; + } + + try { + return JSON.parse(content) as T; + } catch (error) { + console.error(`Error parsing JSON from ${filePath}:`, error); + return null; + } + } + + public async writeJSON(filename: string, data: T): Promise { + const filePath = this.getFilePath(filename); + const content = JSON.stringify(data, null, 2); + await this.app.vault.adapter.write(filePath, content); + } +} + +interface ReadingLogEntry { + readonly book: string; + readonly pagesRead: number; + readonly pagesReadTotal: number; + readonly createdAt: Date; +} + +export class ReadingLog { + private entries: ReadingLogEntry[] = []; + + public constructor( + private readonly app: App, + private readonly plugin: BookTrackerPlugin + ) { + this.loadEntries().catch((error) => { + console.error("Failed to load reading log entries:", error); + }); + } + + private async loadEntries() { + const entries = await this.plugin.storage.readJSON( + "reading-log.json" + ); + if (entries) { + this.entries = entries; + } + } + + private async storeEntries() { + await this.plugin.storage.writeJSON("reading-log.json", this.entries); + } + + public getLatestEntry(book: string): ReadingLogEntry | null { + const entriesForBook = this.entries.filter( + (entry) => entry.book === book + ); + + return entriesForBook.length > 0 + ? entriesForBook[entriesForBook.length - 1] + : null; + } + + public async addEntry(book: string, pageEnded: number): Promise { + const latestEntry = this.getLatestEntry(book); + + const newEntry: ReadingLogEntry = { + book, + pagesRead: latestEntry + ? pageEnded - latestEntry.pagesReadTotal + : pageEnded, + pagesReadTotal: pageEnded, + createdAt: new Date(), + }; + + this.entries.push(newEntry); + await this.storeEntries(); + } + + public async removeEntries(book: string): Promise { + this.entries = this.entries.filter((entry) => entry.book !== book); + await this.storeEntries(); + } + + public async removeLastEntry(book: string): Promise { + const latestEntryIndex = this.entries.findLastIndex( + (entry) => entry.book === book + ); + + if (latestEntryIndex !== -1) { + this.entries.splice(latestEntryIndex, 1); + await this.storeEntries(); + } + } + + public async clearEntries(): Promise { + this.entries = []; + await this.storeEntries(); + } +} diff --git a/src/views/goodreads-search-modal.ts b/src/views/goodreads-search-modal.ts index cb287c3..69fc59a 100644 --- a/src/views/goodreads-search-modal.ts +++ b/src/views/goodreads-search-modal.ts @@ -15,6 +15,7 @@ export class GoodreadsSearchModal extends Modal { async doSearch(): Promise { if (!this.query || this.query.trim() === "") { this.onSearch(new Error("Search query cannot be empty."), []); + this.close(); return; } @@ -31,6 +32,8 @@ export class GoodreadsSearchModal extends Modal { this.onSearch(null, results); } catch (error) { this.onSearch(error, []); + } finally { + this.close(); } } @@ -62,7 +65,6 @@ export class GoodreadsSearchModal extends Modal { } else { resolve(results); } - modal.close(); }); modal.open(); }); diff --git a/src/views/reading-progress-modal.ts b/src/views/reading-progress-modal.ts new file mode 100644 index 0000000..28705c0 --- /dev/null +++ b/src/views/reading-progress-modal.ts @@ -0,0 +1,92 @@ +import { App, Modal, ToggleComponent } from "obsidian"; + +export class ReadingProgressModal extends Modal { + private value: number; + private percentage: boolean = false; + + constructor( + app: App, + private readonly onSubmit: (pageNumber: number) => void + ) { + super(app); + this.value = 0; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.classList.add("obt-reading-progress"); + contentEl.createEl("h2", { text: "Enter Reading Progress" }); + + contentEl.createDiv({ cls: "obt-reading-progress__desc" }, (descEl) => { + descEl.createEl("p", { + text: "Enter the page number or percentage of the book you have read.", + }); + descEl.createEl("p", { + text: "You can toggle between page number and percentage input.", + }); + }); + + const inputDiv = contentEl.createDiv({ + cls: "obt-reading-progress__input", + }); + + const inputEl = inputDiv.createEl("input", { + type: "number", + placeholder: "Page Number", + }); + + inputEl.addEventListener("change", (ev) => { + this.value = Math.max(1, parseInt(inputEl.value, 10)); + (ev.target as HTMLInputElement).value = this.value.toString(); + }); + + inputEl.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.onSubmit(this.value); + this.close(); + } + }); + + contentEl.createDiv({ cls: "obt-reading-progress__pct" }, (pctDiv) => { + pctDiv.createEl("label", { text: "Percentage" }); + pctDiv.createDiv( + { cls: "obt-reading-progress__toggle" }, + (toggleDiv) => { + new ToggleComponent(toggleDiv) + .setValue(this.percentage) + .onChange((value) => { + this.percentage = value; + if (value) { + inputEl.setAttribute( + "placeholder", + "Percentage (%)" + ); + inputEl.setAttribute("max", "100"); + } else { + inputEl.setAttribute( + "placeholder", + "Page Number" + ); + inputEl.removeAttribute("max"); + } + }); + } + ); + }); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + static createAndOpen(app: App): Promise { + return new Promise((resolve) => { + const modal = new ReadingProgressModal(app, (pageNumber) => { + resolve(pageNumber); + }); + modal.open(); + }); + } +} diff --git a/styles.css b/styles.css index 2050615..74cf5bf 100644 --- a/styles.css +++ b/styles.css @@ -29,6 +29,32 @@ font-size: var(--font-ui-small); } +.obt-reading-progress__desc { + margin-bottom: 1rem; + font-size: var(--text-ui-smaller); + color: var(--text-muted); +} +.obt-reading-progress__desc p { + margin: 0; +} +.obt-reading-progress__input { + padding-bottom: 18px; +} +.obt-reading-progress__input input { + width: 100%; +} +.obt-reading-progress__pct { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 18px; +} +.obt-reading-progress__toggle { + display: flex; + align-items: center; + flex-grow: 1; +} + .obt-settings .search-input-container { width: 100%; }