obsidian-book-tracker/src/main.ts

251 lines
7.5 KiB
TypeScript

import {
Notice,
Plugin,
requestUrl,
TFile,
type FrontMatterCache,
} from "obsidian";
import {
type BookTrackerPluginSettings,
DEFAULT_SETTINGS,
BookTrackerSettingTab,
} from "@ui/settings";
import { Templater } from "@utils/Templater";
import { CONTENT_TYPE_EXTENSIONS } from "./const";
import { Storage } from "@utils/Storage";
import { ReadingLog } from "@utils/ReadingLog";
import {
registerReadingLogCodeBlockProcessor,
registerReadingStatsCodeBlockProcessor,
} from "@ui/code-blocks";
import type { Book, BookMetadata, ReadingState } 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";
import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
import { Goodreads } from "@data-sources/Goodreads";
import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand";
import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock";
import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand";
import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock";
import { registerAToZChallengeCodeBlockProcessor } from "@ui/code-blocks/AToZChallengeCodeBlock";
import moment from "@external/moment";
import { compressImage } from "@utils/image";
export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings;
public templater: Templater;
public storage: Storage;
public readingLog: ReadingLog;
public goodreads: Goodreads = new Goodreads();
async onload() {
await this.loadSettings();
this.templater = new Templater(this.app);
this.storage = new Storage(this);
this.readingLog = new ReadingLog(this.storage);
this.addCommand(
new SearchGoodreadsCommand(
this.app,
this.goodreads,
this.createEntry.bind(this)
)
);
this.addCommand(new LogReadingStartedCommand(this.app, this.settings));
this.addCommand(new LogReadingProgressCommand(this));
this.addCommand(new LogReadingFinishedCommand(this));
this.addCommand(
new ResetReadingStatusCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addCommand(new BackupReadingLogCommand(this.readingLog));
this.addCommand(
new RestoreReadingLogBackupCommand(
this.app,
this.storage,
this.readingLog
)
);
this.addCommand(
new CreateBookFromGoodreadsUrlCommand(
this.goodreads,
this.createEntry.bind(this)
)
);
this.addCommand(new ReloadReadingLogCommand(this.readingLog));
this.addSettingTab(new BookTrackerSettingTab(this));
registerReadingLogCodeBlockProcessor(this);
registerReadingStatsCodeBlockProcessor(this);
registerShelfCodeBlockProcessor(this);
registerReadingCalendarCodeBlockProcessor(this);
registerAToZChallengeCodeBlockProcessor(this);
}
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,
overwrite?: boolean
): Promise<TFile> {
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.coverFolder + "/";
if (this.settings.groupCoversByFirstLetter) {
let groupName = fileName.charAt(0).toUpperCase();
if (!/^[A-Z]$/.test(groupName)) {
groupName = "#";
}
filePath += groupName + "/";
}
filePath += fileName + "." + extension;
let file = this.app.vault.getFileByPath(filePath);
if (file) {
if (this.settings.overwriteExistingCovers || overwrite) {
await this.app.vault.modifyBinary(file, response.arrayBuffer);
} else {
new Notice("Cover image already exists: " + filePath);
return file;
}
} else {
file = await this.app.vault.createBinary(
filePath,
response.arrayBuffer
);
}
await compressImage(this.app, file, {
height: 400,
quality: 0.8,
maintainAspectRatio: true,
});
return file;
}
async createEntry(book: Book): Promise<void> {
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) {
const coverImageFile = await this.downloadCoverImage(
book.coverImageUrl,
fileName
);
data.coverImagePath = coverImageFile.path;
}
const renderedContent = await this.templater.renderTemplateFile(
this.settings.templateFile,
data
);
const filePath = this.settings.tbrFolder + "/" + fileName + ".md";
const file = await this.app.vault.create(filePath, renderedContent);
await this.app.workspace.getLeaf().openFile(file);
}
getBookMetadata(file: TFile): BookMetadata | null {
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata) {
return null;
}
return this.frontmatterToMetadata(metadata.frontmatter);
}
frontmatterToMetadata(fm: FrontMatterCache | undefined): BookMetadata {
const getString = (key: string) => {
const value = fm?.[key];
if (typeof value === "string") {
return value;
}
return "";
};
const getStringArray = (key: string) => {
const value = fm?.[key];
if (Array.isArray(value)) {
return value as string[];
}
return [];
};
const getNumber = (key: string) => {
const value = fm?.[key];
if (typeof value === "number") {
return value;
} else if (typeof value === "string") {
return parseFloat(value);
}
return 0;
};
const getDate = (key: string) => {
const value = fm?.[key];
if (typeof value === "string" || value instanceof Date) {
return moment(value);
}
return null;
};
return {
title: getString(this.settings.titleProperty),
subtitle: getString(this.settings.subtitleProperty),
description: getString(this.settings.descriptionProperty),
authors: getStringArray(this.settings.authorsProperty),
seriesTitle: getString(this.settings.seriesTitleProperty),
seriesPosition: getNumber(this.settings.seriesPositionProperty),
startDate: getDate(this.settings.startDateProperty)!,
endDate: getDate(this.settings.endDateProperty)!,
status: getString(this.settings.statusProperty) as ReadingState,
rating: getNumber(this.settings.ratingProperty),
spice: getNumber(this.settings.spiceProperty),
format: getString(this.settings.formatProperty),
source: getStringArray(this.settings.sourceProperty),
categories: getStringArray(this.settings.categoriesProperty),
publisher: getString(this.settings.publisherProperty),
publishDate: getDate(this.settings.publishDateProperty)!,
pageCount: getNumber(this.settings.pageCountProperty),
isbn: getString(this.settings.isbnProperty),
coverImageUrl: getString(this.settings.coverImageUrlProperty),
localCoverPath: getString(this.settings.localCoverPathProperty),
};
}
}