Move commands into classes

This commit is contained in:
Evan Fiordeliso 2025-06-30 16:09:43 -04:00
parent e4a416ec2f
commit 5a492f558c
8 changed files with 418 additions and 250 deletions

99
src/commands/Command.ts Normal file
View File

@ -0,0 +1,99 @@
import type {
Editor,
Hotkey,
MarkdownFileInfo,
MarkdownView,
Command as ObsidianCommand,
} from "obsidian";
export abstract class Command implements ObsidianCommand {
private _icon?: string;
public get icon(): string | undefined {
return this._icon;
}
protected setIcon(icon: string) {
this._icon = icon;
}
private _mobileOnly?: boolean;
public get mobileOnly(): boolean | undefined {
return this._mobileOnly;
}
protected setMobileOnly(mobileOnly: boolean) {
this._mobileOnly = mobileOnly;
}
private _repeatable?: boolean;
public get repeatable(): boolean | undefined {
return this._repeatable;
}
protected setRepeatable(repeatable: boolean) {
this._repeatable = repeatable;
}
private _hotkeys: Hotkey[] = [];
public get hotkeys(): Hotkey[] {
return this._hotkeys;
}
protected addHotkey(hotkey: Hotkey) {
this._hotkeys.push(hotkey);
}
protected removeHotkey(hotkey: Hotkey) {
this._hotkeys = this._hotkeys.filter(
(h) => h.key !== hotkey.key && h.modifiers !== h.modifiers
);
}
protected clearHotkeys() {
this._hotkeys = [];
}
constructor(public id: string, public name: string) {}
callback?(): any;
checkCallback?(checking: boolean): boolean;
editorCallback?(
editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean;
editorCheckCallback?(
checking: boolean,
editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean | void;
}
export abstract class CheckCommand extends Command {
checkCallback(checking: boolean): boolean {
if (!this.check()) return false;
if (!checking) {
this.run();
}
return true;
}
protected abstract check(): boolean;
protected abstract run(): void | Promise<void>;
}
export abstract class EditorCheckCommand extends Command {
editorCheckCallback(
checking: boolean,
editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean | void {
if (!this.check(editor, ctx)) return false;
if (!checking) {
this.run(editor, ctx);
}
return true;
}
protected abstract check(
editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean;
protected abstract run(
editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): void | Promise<void>;
}

View File

@ -0,0 +1,75 @@
import {
type Editor,
type MarkdownView,
type MarkdownFileInfo,
type App,
Notice,
TFile,
} from "obsidian";
import { EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { RatingModal } from "@ui/modals";
import type { ReadingLog } from "@utils/ReadingLog";
import { READ_STATE } from "@src/const";
export class LogReadingFinishedCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly readingLog: ReadingLog,
private readonly settings: BookTrackerPluginSettings
) {
super("log-reading-finished", "Log Reading Finished");
}
private getPageCount(file: TFile): number {
return (
(this.app.metadataCache.getFileCache(file)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0
);
}
protected check(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean {
return !(
ctx.file === null ||
this.settings.statusProperty === "" ||
this.settings.endDateProperty === "" ||
this.settings.ratingProperty === "" ||
this.settings.pageCountProperty === "" ||
this.getPageCount(ctx.file) <= 0
);
}
protected async run(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): Promise<void> {
const file = ctx.file!;
const fileName = file.basename;
const pageCount = this.getPageCount(file);
const rating = await RatingModal.createAndOpen(
this.app,
this.settings.spiceProperty !== ""
);
await this.readingLog.addEntry(fileName, pageCount, pageCount);
// @ts-expect-error Moment is provided by Obsidian
const endDate = moment().format("YYYY-MM-DD");
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = READ_STATE;
frontMatter[this.settings.endDateProperty] = endDate;
frontMatter[this.settings.ratingProperty] = rating;
if (this.settings.spiceProperty !== "") {
frontMatter[this.settings.spiceProperty] = rating;
}
});
new Notice("Reading finished for " + fileName);
}
}

View File

@ -0,0 +1,66 @@
import {
type Editor,
type MarkdownView,
type MarkdownFileInfo,
type App,
Notice,
TFile,
} from "obsidian";
import { Command, EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { ReadingProgressModal } from "@ui/modals";
import type { ReadingLog } from "@utils/ReadingLog";
export class LogReadingProgressCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly readingLog: ReadingLog,
private readonly settings: BookTrackerPluginSettings
) {
super("log-reading-progress", "Log Reading Progress");
}
private getPageCount(file: TFile): number {
return (
(this.app.metadataCache.getFileCache(file)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0
);
}
protected check(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean {
return !(
ctx.file === null ||
this.settings.pageCountProperty === "" ||
this.getPageCount(ctx.file) <= 0
);
}
protected async run(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): Promise<void> {
const file = ctx.file!;
const fileName = file.basename;
const pageCount = this.getPageCount(file);
const pageNumber = await ReadingProgressModal.createAndOpen(
this.app,
pageCount
);
if (pageNumber <= 0 || pageNumber > pageCount) {
new Notice(
`Invalid page number: ${pageNumber}. It must be between 1 and ${pageCount}.`
);
return;
}
await this.readingLog.addEntry(fileName, pageNumber, pageCount);
new Notice(
`Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageCount}.`
);
}
}

View File

@ -0,0 +1,47 @@
import {
type Editor,
type MarkdownView,
type MarkdownFileInfo,
type App,
Notice,
} from "obsidian";
import { EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { IN_PROGRESS_STATE } from "@src/const";
export class LogReadingStartedCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly settings: BookTrackerPluginSettings
) {
super("log-reading-started", "Log Reading Started");
}
protected check(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean {
return !(
ctx.file === null ||
this.settings.statusProperty === "" ||
this.settings.startDateProperty === ""
);
}
protected async run(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): Promise<void> {
const file = ctx.file!;
// @ts-expect-error Moment is provided by Obsidian
const startDate = moment().format("YYYY-MM-DD");
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = IN_PROGRESS_STATE;
frontMatter[this.settings.startDateProperty] = startDate;
});
new Notice("Reading started for " + file.basename);
}
}

View File

@ -0,0 +1,50 @@
import {
Notice,
type App,
type Editor,
type MarkdownFileInfo,
type MarkdownView,
} from "obsidian";
import { Command, EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import type { ReadingLog } from "@utils/ReadingLog";
import { TO_BE_READ_STATE } from "@src/const";
export class ResetReadingStatusCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly readingLog: ReadingLog,
private readonly settings: BookTrackerPluginSettings
) {
super("reset-reading-status", "Reset Reading Status");
}
protected check(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): boolean {
return !(
ctx.file === null ||
this.settings.statusProperty === "" ||
this.settings.startDateProperty === "" ||
this.settings.endDateProperty === ""
);
}
protected run(
_editor: Editor,
ctx: MarkdownView | MarkdownFileInfo
): void | Promise<void> {
const file = ctx.file!;
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = TO_BE_READ_STATE;
frontMatter[this.settings.startDateProperty] = "";
frontMatter[this.settings.endDateProperty] = "";
});
this.readingLog.removeEntries(file.basename);
new Notice("Reading status reset for " + file.basename);
}
}

View File

@ -0,0 +1,47 @@
import { getBookByLegacyId, type SearchResult } from "@data-sources/goodreads";
import { GoodreadsSearchModal, GoodreadsSearchSuggestModal } from "@ui/modals";
import { App, Notice } from "obsidian";
import { Command } from "./Command";
import type { Book } from "@src/types";
export class SearchGoodreadsCommand extends Command {
constructor(
private readonly app: App,
private readonly cb: (book: Book) => void
) {
super("search-goodreads", "Search Goodreads");
}
async callback() {
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 selectedResult = await GoodreadsSearchSuggestModal.createAndOpen(
this.app,
results
);
if (!selectedResult) {
new Notice("No book selected.");
return;
}
let book: Book;
try {
book = await getBookByLegacyId(selectedResult.legacyId);
} catch (error) {
console.error("Failed to get book:", error);
new Notice("Failed to get book. Check console for details.");
return;
}
this.cb(book);
}
}

View File

@ -4,23 +4,17 @@ import {
DEFAULT_SETTINGS,
BookTrackerSettingTab,
} from "@ui/settings";
import { getBookByLegacyId, type SearchResult } from "@data-sources/goodreads";
import { Templater } from "@utils/Templater";
import {
GoodreadsSearchModal,
GoodreadsSearchSuggestModal,
ReadingProgressModal,
RatingModal,
} from "@ui/modals";
import {
CONTENT_TYPE_EXTENSIONS,
IN_PROGRESS_STATE,
READ_STATE,
TO_BE_READ_STATE,
} from "./const";
import { CONTENT_TYPE_EXTENSIONS } from "./const";
import { Storage } from "@utils/Storage";
import { ReadingLog } from "@utils/ReadingLog";
import { registerReadingLogCodeBlockProcessor } from "@ui/code-blocks";
import type { Book } 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";
export default class BookTrackerPlugin extends Plugin {
settings: BookTrackerPluginSettings;
@ -35,35 +29,31 @@ export default class BookTrackerPlugin extends Plugin {
this.storage = new Storage(this.app, this);
this.readingLog = new ReadingLog(this.storage);
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.addCommand(
new SearchGoodreadsCommand(this.app, this.createEntry.bind(this))
);
this.addCommand(new LogReadingStartedCommand(this.app, this.settings));
this.addCommand(
new LogReadingProgressCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addCommand(
new LogReadingFinishedCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addCommand(
new ResetReadingStatusCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addSettingTab(new BookTrackerSettingTab(this));
@ -124,10 +114,8 @@ export default class BookTrackerPlugin extends Plugin {
return filePath;
}
async createEntryFromGoodreads(legacyId: number): Promise<void> {
async createEntry(book: Book): Promise<void> {
try {
const book = await getBookByLegacyId(legacyId);
const fileName = this.templater
.renderTemplate(this.settings.fileNameFormat, {
title: book.title,
@ -159,209 +147,4 @@ export default class BookTrackerPlugin extends Plugin {
console.error("Failed to create book entry:", error);
}
}
async searchGoodreads(): Promise<void> {
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.pageCountProperty) {
new Notice("Page count property is not set in settings.");
return;
}
const pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0;
if (pageCount <= 0) {
new Notice(
"Page length property is not set or is invalid in the active file."
);
return;
}
const pageNumber = await ReadingProgressModal.createAndOpen(
this.app,
pageCount
);
if (pageNumber <= 0 || pageNumber > pageCount) {
new Notice(
`Invalid page number: ${pageNumber}. It must be between 1 and ${pageCount}.`
);
return;
}
await this.readingLog.addEntry(fileName, pageNumber, pageCount);
new Notice(
`Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageCount}.`
);
}
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 pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0;
if (pageCount <= 0) {
new Notice(
"Page count property is not set or is invalid in the active file."
);
return;
}
const rating = await RatingModal.createAndOpen(
this.app,
this.settings.spiceProperty !== ""
);
await this.readingLog.addEntry(
activeFile.basename,
pageCount,
pageCount
);
// @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;
if (this.settings.spiceProperty !== "") {
frontMatter[this.settings.spiceProperty] = 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);
}
}

View File

@ -20,6 +20,7 @@
"ES7"
],
"paths": {
"@commands/*": ["src/commands/*"],
"@data-sources/*": ["src/data-sources/*"],
"@ui/*": ["src/ui/*"],
"@utils/*": ["src/utils/*"],