obsidian-book-tracker/src/main.ts

366 lines
9.2 KiB
TypeScript

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