generated from tpl/obsidian-sample-plugin
			Add reading log functionality
This commit is contained in:
		
							parent
							
								
									785ca0e884
								
							
						
					
					
						commit
						35ed9b95ee
					
				| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
@use "views/goodreads-search.scss";
 | 
			
		||||
@use "views/goodreads-search-suggest.scss";
 | 
			
		||||
@use "views/reading-progress.scss";
 | 
			
		||||
@use "settings.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;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										204
									
								
								src/main.ts
								
								
								
								
							
							
						
						
									
										204
									
								
								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<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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
		});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								styles.css
								
								
								
								
							
							
						
						
									
										26
									
								
								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%;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue