Add reading log functionality

This commit is contained in:
Evan Fiordeliso 2025-06-26 22:16:11 -04:00
parent 785ca0e884
commit 35ed9b95ee
11 changed files with 669 additions and 11 deletions

View File

@ -1,3 +1,4 @@
@use "views/goodreads-search.scss";
@use "views/goodreads-search-suggest.scss";
@use "views/reading-progress.scss";
@use "settings.scss";

View File

@ -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;
}
}

10
src/const.ts Normal file
View File

@ -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<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
};

View File

@ -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<string, string> = {
"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);
}
}

View File

@ -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() {

View File

@ -139,9 +139,12 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
);
}
onInputChanged(): void {
async onInputChanged(): Promise<void> {
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<T> implements ISuggestOwner<T> {
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract getSuggestions(inputStr: string): T[] | Promise<T[]>;
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}

View File

@ -0,0 +1,39 @@
import { App } from "obsidian";
import { TextInputSuggest } from "./core";
export class FieldSuggest extends TextInputSuggest<string> {
constructor(
app: App,
inputEl: HTMLInputElement,
private readonly accepts?: string[]
) {
super(app, inputEl);
}
async getSuggestions(inputStr: string): Promise<string[]> {
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();
}
}

114
src/utils/storage.ts Normal file
View File

@ -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<T>(filename: string): Promise<T | null> {
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<T>(filename: string, data: T): Promise<void> {
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<ReadingLogEntry[]>(
"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<void> {
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<void> {
this.entries = this.entries.filter((entry) => entry.book !== book);
await this.storeEntries();
}
public async removeLastEntry(book: string): Promise<void> {
const latestEntryIndex = this.entries.findLastIndex(
(entry) => entry.book === book
);
if (latestEntryIndex !== -1) {
this.entries.splice(latestEntryIndex, 1);
await this.storeEntries();
}
}
public async clearEntries(): Promise<void> {
this.entries = [];
await this.storeEntries();
}
}

View File

@ -15,6 +15,7 @@ export class GoodreadsSearchModal extends Modal {
async doSearch(): Promise<void> {
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();
});

View File

@ -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<number> {
return new Promise((resolve) => {
const modal = new ReadingProgressModal(app, (pageNumber) => {
resolve(pageNumber);
});
modal.open();
});
}
}

View File

@ -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%;
}