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

View File

@ -130,7 +130,34 @@ export interface SearchResult {
export class Goodreads { export class Goodreads {
async getNextData(legacyId: number): Promise<NextData> { async getNextData(legacyId: number): Promise<NextData> {
const url = "https://www.goodreads.com/book/show/" + legacyId; 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 doc = new DOMParser().parseFromString(res.text, "text/html");
const nextDataRaw = doc.getElementById("__NEXT_DATA__")?.textContent; const nextDataRaw = doc.getElementById("__NEXT_DATA__")?.textContent;
if (typeof nextDataRaw !== "string") { if (typeof nextDataRaw !== "string") {

View File

@ -1,41 +1,39 @@
import { import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand";
Notice, import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
Plugin, import { LogReadingFinishedCommand } from "@commands/LogReadingFinishedCommand";
requestUrl, import { LogReadingProgressCommand } from "@commands/LogReadingProgressCommand";
TFile, import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
type FrontMatterCache, import { RedownloadCoverCommand } from "@commands/RedownloadCoverCommand";
} from "obsidian"; import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand";
import { import { ResetReadingStatusCommand } from "@commands/ResetReadingStatusCommand";
type BookTrackerPluginSettings, import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
DEFAULT_SETTINGS, import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
BookTrackerSettingTab, import { UpdateCoverFromURLCommand } from "@commands/UpdateCoverFromURLCommand";
} from "@ui/settings"; import { Goodreads } from "@data-sources/Goodreads";
import { safeString, Templater } from "@utils/Templater"; import moment from "@external/moment";
import { CONTENT_TYPE_EXTENSIONS } from "./const";
import { Storage } from "@utils/Storage";
import { ReadingLog } from "@utils/ReadingLog";
import { import {
registerReadingLogCodeBlockProcessor, registerReadingLogCodeBlockProcessor,
registerReadingStatsCodeBlockProcessor, registerReadingStatsCodeBlockProcessor,
} from "@ui/code-blocks"; } 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 { registerAToZChallengeCodeBlockProcessor } from "@ui/code-blocks/AToZChallengeCodeBlock";
import moment from "@external/moment"; import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock";
import { compressImage } from "@utils/image"; import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock";
import { titleSortValue } from "@utils/text"; import {
import { UpdateCoverFromURLCommand } from "@commands/UpdateCoverFromURLCommand"; 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 { export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings; public settings: BookTrackerPluginSettings;
@ -43,6 +41,8 @@ export default class BookTrackerPlugin extends Plugin {
public storage: Storage; public storage: Storage;
public readingLog: ReadingLog; public readingLog: ReadingLog;
public goodreads: Goodreads = new Goodreads(); public goodreads: Goodreads = new Goodreads();
public imageCompressor: ImageCompressorService;
public coverImageDownloader: CoverImageDownloaderService;
private onSettingsSavedHandlers: (( private onSettingsSavedHandlers: ((
settings: BookTrackerPluginSettings settings: BookTrackerPluginSettings
@ -58,6 +58,8 @@ export default class BookTrackerPlugin extends Plugin {
this.templater = new Templater(this.app); this.templater = new Templater(this.app);
this.storage = new Storage(this); this.storage = new Storage(this);
this.readingLog = new ReadingLog(this.storage); 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( this.addCommand(
new SearchGoodreadsCommand( new SearchGoodreadsCommand(
@ -91,7 +93,8 @@ export default class BookTrackerPlugin extends Plugin {
) )
); );
this.addCommand(new ReloadReadingLogCommand(this.readingLog)); 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)); 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> { async createEntry(book: Book): Promise<void> {
const fileName = this.templater const fileName = this.templater
.renderTemplate(this.settings.fileNameFormat, { .renderTemplate(this.settings.fileNameFormat, {
@ -239,7 +183,7 @@ export default class BookTrackerPlugin extends Plugin {
}; };
if (this.settings.downloadCovers && book.coverImageUrl) { if (this.settings.downloadCovers && book.coverImageUrl) {
const coverImageFile = await this.downloadCoverImage( const coverImageFile = await this.coverImageDownloader.download(
book.coverImageUrl, book.coverImageUrl,
fileName 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

@ -1,36 +1,51 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"inlineSourceMap": true, "inlineSourceMap": true,
"inlineSources": true, "inlineSources": true,
"module": "ESNext", "module": "ESNext",
"target": "ES6", "target": "ES6",
"allowJs": true, "allowJs": true,
"noImplicitAny": true, "noImplicitAny": true,
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"strictNullChecks": true, "strictNullChecks": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": [ "lib": [
"DOM", "DOM",
"ES5", "ES5",
"ES6", "ES6",
"ES7" "ES7"
], ],
"paths": { "paths": {
"@commands/*": ["src/commands/*"], "@commands/*": [
"@data-sources/*": ["src/data-sources/*"], "src/commands/*"
"@external/*": ["src/external/*"], ],
"@ui/*": ["src/ui/*"], "@data-sources/*": [
"@utils/*": ["src/utils/*"], "src/data-sources/*"
"@src/*": ["src/*"] ],
} "@external/*": [
}, "src/external/*"
"include": [ ],
"**/*.ts", "@services/*": [
"**/*.svelte" "src/services/*"
] ],
"@ui/*": [
"src/ui/*"
],
"@utils/*": [
"src/utils/*"
],
"@src/*": [
"src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.svelte"
]
} }