Add redownload cover command and move image functions to services

This commit is contained in:
Evan Fiordeliso 2026-06-11 16:20:02 -04:00
parent 210ba33297
commit 2f89a703d3
8 changed files with 306 additions and 205 deletions

View File

@ -0,0 +1,34 @@
import type { BookTrackerPluginSettings } from "@ui/settings";
import { Notice, type Editor, type MarkdownFileInfo, type MarkdownView, type MetadataCache } from "obsidian";
import { CoverImageDownloaderService } from "../services/CoverImageDownloaderService";
import { EditorCheckCommand } from "./Command";
export class RedownloadCoverCommand extends EditorCheckCommand {
constructor(
private readonly metadataCache: MetadataCache,
private readonly settings: BookTrackerPluginSettings,
private readonly downloader: CoverImageDownloaderService,
) {
super("redownload-cover", "Redownload Cover from Current Image URL");
}
protected check(_editor: Editor, ctx: MarkdownView | MarkdownFileInfo): boolean {
return ctx.file != null;
}
protected async run(_editor: Editor, ctx: MarkdownView | MarkdownFileInfo): Promise<void> {
const file = ctx.file!;
const fm = this.metadataCache.getFileCache(file)?.frontmatter!;
const url = fm[this.settings.coverImageUrlProperty];
try {
await this.downloader.download(url, file.basename, true);
} catch (error) {
console.error("Failed to download cover image:", error);
new Notice("Failed to download cover image. Check console for details.");
return;
}
new Notice("Fetched newest cover image.");
}
}

View File

@ -1,12 +1,13 @@
import { type Editor, type MarkdownView, type MarkdownFileInfo, type App, type TFile, Notice } from "obsidian";
import { EditorCheckCommand } from "./Command";
import type { CoverImageDownloaderService } from "@services/CoverImageDownloaderService";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { Notice, type Editor, type FileManager, type MarkdownFileInfo, type MarkdownView, type TFile } from "obsidian";
import { EditorCheckCommand } from "./Command";
export class UpdateCoverFromURLCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly fileManager: FileManager,
private readonly settings: BookTrackerPluginSettings,
private readonly downloadCoverImage: (url: string, fileName: string, overwrite?: boolean) => Promise<TFile>,
private readonly downloader: CoverImageDownloaderService,
) {
super("update-cover-from-url", "Update Cover from URL");
}
@ -21,14 +22,14 @@ export class UpdateCoverFromURLCommand extends EditorCheckCommand {
let coverFile: TFile;
try {
coverFile = await this.downloadCoverImage(url, file.basename, true);
coverFile = await this.downloader.download(url, file.basename, true);
} catch (error) {
console.error("Failed to download cover image:", error);
new Notice("Failed to download cover image. Check console for details.");
return;
}
this.app.fileManager.processFrontMatter(file, (fm) => {
this.fileManager.processFrontMatter(file, (fm) => {
fm[this.settings.coverImageUrlProperty] = url;
});
new Notice("Updated cover image.")

View File

@ -130,7 +130,34 @@ export interface SearchResult {
export class Goodreads {
async getNextData(legacyId: number): Promise<NextData> {
const url = "https://www.goodreads.com/book/show/" + legacyId;
const res = await requestUrl({ url });
const res = await requestUrl({
url,
headers: {
"Accept": "text/html, application/json",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
"Device-Memory": "8",
"Downlink": "10",
"Dpr": "2",
"Ect": "4g",
"Origin": "https://www.amazon.com",
"Priority": "u=1, i",
"Rtt": "50",
"Sec-Ch-Device-Memory": "8",
"Sec-Ch-Dpr": "2",
"Sec-Ch-Ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": "\"macOS\"",
"Sec-Ch-Viewport-Width": "1170",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Viewport-Width": "1170",
"X-Amz-Amabot-Click-Attributes": "disable",
"X-Requested-With": "XMLHttpRequest",
},
});
const doc = new DOMParser().parseFromString(res.text, "text/html");
const nextDataRaw = doc.getElementById("__NEXT_DATA__")?.textContent;
if (typeof nextDataRaw !== "string") {

View File

@ -1,41 +1,39 @@
import {
Notice,
Plugin,
requestUrl,
TFile,
type FrontMatterCache,
} from "obsidian";
import {
type BookTrackerPluginSettings,
DEFAULT_SETTINGS,
BookTrackerSettingTab,
} from "@ui/settings";
import { safeString, Templater } from "@utils/Templater";
import { CONTENT_TYPE_EXTENSIONS } from "./const";
import { Storage } from "@utils/Storage";
import { ReadingLog } from "@utils/ReadingLog";
import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand";
import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
import { LogReadingFinishedCommand } from "@commands/LogReadingFinishedCommand";
import { LogReadingProgressCommand } from "@commands/LogReadingProgressCommand";
import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
import { RedownloadCoverCommand } from "@commands/RedownloadCoverCommand";
import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand";
import { ResetReadingStatusCommand } from "@commands/ResetReadingStatusCommand";
import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
import { UpdateCoverFromURLCommand } from "@commands/UpdateCoverFromURLCommand";
import { Goodreads } from "@data-sources/Goodreads";
import moment from "@external/moment";
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";
import { titleSortValue } from "@utils/text";
import { UpdateCoverFromURLCommand } from "@commands/UpdateCoverFromURLCommand";
import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock";
import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock";
import {
BookTrackerSettingTab,
DEFAULT_SETTINGS,
type BookTrackerPluginSettings,
} from "@ui/settings";
import { ReadingLog } from "@utils/ReadingLog";
import { Storage } from "@utils/Storage";
import { Templater, safeString } from "@utils/Templater";
import {
Plugin,
TFile,
type FrontMatterCache,
} from "obsidian";
import { CoverImageDownloaderService } from "./services/CoverImageDownloaderService";
import { ImageCompressorService } from "./services/ImageCompressorService";
import type { Book, BookMetadata, ReadingState } from "./types";
export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings;
@ -43,6 +41,8 @@ export default class BookTrackerPlugin extends Plugin {
public storage: Storage;
public readingLog: ReadingLog;
public goodreads: Goodreads = new Goodreads();
public imageCompressor: ImageCompressorService;
public coverImageDownloader: CoverImageDownloaderService;
private onSettingsSavedHandlers: ((
settings: BookTrackerPluginSettings
@ -58,6 +58,8 @@ export default class BookTrackerPlugin extends Plugin {
this.templater = new Templater(this.app);
this.storage = new Storage(this);
this.readingLog = new ReadingLog(this.storage);
this.imageCompressor = new ImageCompressorService(this.app.vault);
this.coverImageDownloader = new CoverImageDownloaderService(this.app.vault, this.app.fileManager, this.imageCompressor, this.settings);
this.addCommand(
new SearchGoodreadsCommand(
@ -91,7 +93,8 @@ export default class BookTrackerPlugin extends Plugin {
)
);
this.addCommand(new ReloadReadingLogCommand(this.readingLog));
this.addCommand(new UpdateCoverFromURLCommand(this.app, this.settings, this.downloadCoverImage.bind(this)));
this.addCommand(new UpdateCoverFromURLCommand(this.app.fileManager, this.settings, this.coverImageDownloader));
this.addCommand(new RedownloadCoverCommand(this.app.metadataCache, this.settings, this.coverImageDownloader));
this.addSettingTab(new BookTrackerSettingTab(this));
@ -145,65 +148,6 @@ export default class BookTrackerPlugin extends Plugin {
};
}
async downloadCoverImage(
coverImageUrl: string,
fileName: string,
overwrite?: boolean
): Promise<TFile> {
const response = await requestUrl(coverImageUrl);
const header = Object.keys(response.headers).find(k => k.toLowerCase() === "content-type") ?? "Content-Type";
const contentType = response.headers[header] ?? "application/octet-stream";
if (!contentType.startsWith("image/") && contentType !== "application/octet-stream") {
throw new Error("Unexpected content type: " + contentType);
}
const urlExtension = coverImageUrl.split(".").pop();
const extension =
CONTENT_TYPE_EXTENSIONS[contentType || ""] ?? urlExtension ?? "jpg";
let filePath = this.settings.coverFolder + "/";
if (this.settings.groupCoversByFirstLetter) {
let groupName = titleSortValue(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,
});
if (file.extension !== "jpg") {
await this.app.fileManager.renameFile(
file,
file.path.replace(/\.[^.]+$/, ".jpg")
);
}
return file;
}
async createEntry(book: Book): Promise<void> {
const fileName = this.templater
.renderTemplate(this.settings.fileNameFormat, {
@ -239,7 +183,7 @@ export default class BookTrackerPlugin extends Plugin {
};
if (this.settings.downloadCovers && book.coverImageUrl) {
const coverImageFile = await this.downloadCoverImage(
const coverImageFile = await this.coverImageDownloader.download(
book.coverImageUrl,
fileName
);

View File

@ -0,0 +1,74 @@
import { CONTENT_TYPE_EXTENSIONS } from "@src/const";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { titleSortValue } from "@utils/text";
import { Notice, requestUrl, type FileManager, type TFile, type Vault } from "obsidian";
import type { ImageCompressorService } from "./ImageCompressorService";
export class CoverImageDownloaderService {
constructor(
private readonly vault: Vault,
private readonly fileManager: FileManager,
private readonly compressor: ImageCompressorService,
private readonly settings: BookTrackerPluginSettings,
) { }
async download(
coverImageUrl: string,
fileName: string,
overwrite?: boolean
): Promise<TFile> {
const response = await requestUrl(coverImageUrl);
const header = Object.keys(response.headers).find(k => k.toLowerCase() === "content-type") ?? "Content-Type";
const contentType = response.headers[header] ?? "application/octet-stream";
if (!contentType.startsWith("image/") && contentType !== "application/octet-stream") {
throw new Error("Unexpected content type: " + contentType);
}
const urlExtension = coverImageUrl.split(".").pop();
const extension =
CONTENT_TYPE_EXTENSIONS[contentType || ""] ?? urlExtension ?? "jpg";
let filePath = this.settings.coverFolder + "/";
if (this.settings.groupCoversByFirstLetter) {
let groupName = titleSortValue(fileName).charAt(0).toUpperCase();
if (!/^[A-Z]$/.test(groupName)) {
groupName = "#";
}
filePath += groupName + "/";
}
filePath += fileName + "." + extension;
let file = this.vault.getFileByPath(filePath);
if (file) {
if (this.settings.overwriteExistingCovers || overwrite) {
await this.vault.modifyBinary(file, response.arrayBuffer);
} else {
new Notice("Cover image already exists: " + filePath);
return file;
}
} else {
file = await this.vault.createBinary(
filePath,
response.arrayBuffer
);
}
await this.compressor.compress(file, {
height: 400,
quality: 0.8,
maintainAspectRatio: true,
});
if (file.extension !== "jpg") {
await this.fileManager.renameFile(
file,
file.path.replace(/\.[^.]+$/, ".jpg")
);
}
return file;
}
}

View File

@ -0,0 +1,76 @@
import type { TFile, Vault } from "obsidian";
interface CompressOptions {
width?: number;
height?: number;
maintainAspectRatio?: boolean;
quality?: number;
}
export class ImageCompressorService {
constructor(private readonly vault: Vault) {
}
async compress(
file: TFile,
options: CompressOptions
) {
const img = await new Promise<HTMLImageElement>((resolve) => {
const img = new Image();
img.src = this.vault.getResourcePath(file);
img.onload = () => {
resolve(img);
};
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const quality = options.quality ?? 1;
let height = options.height;
let width = options.width;
if (!width && !height) {
width = img.width;
height = img.height;
} else if (!width && height) {
width = height * (img.width / img.height);
} else if (!height && width) {
height = width * (img.height / img.width);
}
if (options.maintainAspectRatio) {
const aspectRatio = img.width / img.height;
if (options.height)
if (width! > height!) {
height = width! / aspectRatio;
} else {
width = height! * aspectRatio;
}
}
canvas.width = width!;
canvas.height = height!;
ctx.drawImage(img, 0, 0, width!, height!);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to compress image"));
}
},
"image/jpeg",
quality
);
});
return this.vault.modifyBinary(file, await blob.arrayBuffer());
}
}

View File

@ -1,70 +0,0 @@
import { type App, type TFile } from "obsidian";
interface CompressOptions {
width?: number;
height?: number;
maintainAspectRatio?: boolean;
quality?: number;
}
export async function compressImage(
app: App,
file: TFile,
options: CompressOptions
) {
const img = await new Promise<HTMLImageElement>((resolve) => {
const img = new Image();
img.src = app.vault.getResourcePath(file);
img.onload = () => {
resolve(img);
};
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const quality = options.quality ?? 1;
let height = options.height;
let width = options.width;
if (!width && !height) {
width = img.width;
height = img.height;
} else if (!width && height) {
width = height * (img.width / img.height);
} else if (!height && width) {
height = width * (img.height / img.width);
}
if (options.maintainAspectRatio) {
const aspectRatio = img.width / img.height;
if (options.height)
if (width! > height!) {
height = width! / aspectRatio;
} else {
width = height! * aspectRatio;
}
}
canvas.width = width!;
canvas.height = height!;
ctx.drawImage(img, 0, 0, width!, height!);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to compress image"));
}
},
"image/jpeg",
quality
);
});
return app.vault.modifyBinary(file, await blob.arrayBuffer());
}

View File

@ -21,12 +21,27 @@
"ES7"
],
"paths": {
"@commands/*": ["src/commands/*"],
"@data-sources/*": ["src/data-sources/*"],
"@external/*": ["src/external/*"],
"@ui/*": ["src/ui/*"],
"@utils/*": ["src/utils/*"],
"@src/*": ["src/*"]
"@commands/*": [
"src/commands/*"
],
"@data-sources/*": [
"src/data-sources/*"
],
"@external/*": [
"src/external/*"
],
"@services/*": [
"src/services/*"
],
"@ui/*": [
"src/ui/*"
],
"@utils/*": [
"src/utils/*"
],
"@src/*": [
"src/*"
]
}
},
"include": [